init game theme

This commit is contained in:
tangly1024.com
2024-03-18 18:57:49 +08:00
parent fc3e60e94c
commit ff77d30cae
41 changed files with 2366 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
/**
* 检测是否用了任意一种广告屏蔽插件
* @returns {JSX.Element|null} 如果检测到广告屏蔽插件则返回提示信息否则返回null
*/
export default function AdBlockerDetect() {
const [isAdBlocker, setIsAdBlocker] = useState(false)
const [noticeCountdown, setNoticeCountdown] = useState(10) // 广告拦截弹窗提示倒计时
const router = useRouter()
useEffect(() => {
let adsCheckCountdown = 10 // 广告拦截检测倒计时
// GoogleAds 是否被拦截
const adLoadTimer = setInterval(() => {
if (window.adsbygoogle) {
clearInterval(adLoadTimer)
checkAdBlocker()
} else {
if (adsCheckCountdown > 1) {
adsCheckCountdown--
} else {
clearInterval(adLoadTimer)
setIsAdBlocker(true)
}
}
}, 1000)
return () => clearInterval(adLoadTimer)
}, [router])
/**
* 检测广告单元可见度
*/
const checkAdBlocker = () => {
const ads = document.querySelectorAll('.adsbygoogle')
if (ads.length === 0) {
setIsAdBlocker(true)
} else {
let adEffect = false
for (const ad of ads) {
const adStyle = getComputedStyle(ad)
if (adStyle.display !== 'none' && adStyle.visibility !== 'hidden') {
adEffect = true
break
}
}
if (!adEffect) {
setIsAdBlocker(true)
}
}
}
useEffect(() => {
if (isAdBlocker) {
const timer = setInterval(() => {
setNoticeCountdown(prevCountdown => {
if (prevCountdown <= 0) {
clearInterval(timer)
setIsAdBlocker(false)
return 0
} else {
return prevCountdown - 1
}
})
}, 1000)
return () => clearInterval(timer)
}
}, [isAdBlocker])
if (!isAdBlocker) {
return null
}
return (
<>
<div className="fixed w-screen h-screen z-40 flex justify-center items-center bg-black bg-opacity-75 top-0 left-0">
<div className="fc-dialog-content z-50 bg-white rounded-md p-4 max-w-md">
<div className="fc-dialog-headline">
<h1 className="fc-dialog-headline-text text-3xl">
Please allow ads on our site
</h1>
</div>
<hr className="my-4" />
<div className="fc-dialog-body">
<p className="fc-dialog-body-text text-xl">
{
"Looks like you're using an ad blocker. We rely on advertising to help fund our site."
}
</p>
</div>
<div className="flex justify-center mt-4">
<button
onClick={() => {
setIsAdBlocker(false)
}}
className="px-12 py-2 gap-2 bg-green-600 rounded text-white "
>
OK ({noticeCountdown})
</button>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import dynamic from 'next/dynamic'
const NotionPage = dynamic(() => import('@/components/NotionPage'))
const Announcement = ({ notice, className }) => {
if (notice?.blockMap) {
return <div className={className}>
<section id='announcement-wrapper' className='mb-10'>
{notice && (<div id="announcement-content">
<NotionPage post={notice} className='text-center ' />
</div>)}
</section>
</div>
} else {
return null
}
}
export default Announcement

View File

@@ -0,0 +1,33 @@
import { useRouter } from 'next/router'
import { useGlobal } from '@/lib/global'
/**
* 加密文章校验组件
* @param {password, validPassword} props
* @param password 正确的密码
* @param validPassword(bool) 回调函数校验正确回调入参为true
* @returns
*/
export const ArticleFooter = props => {
const router = useRouter()
const { locale } = useGlobal()
return <div className="flex justify-between font-medium text-gray-500 dark:text-gray-400">
<a>
<button
onClick={() => router.push('/')}
className="mt-2 cursor-pointer hover:text-black dark:hover:text-gray-100"
>
{locale.POST.BACK}
</button>
</a>
<a>
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="mt-2 cursor-pointer hover:text-black dark:hover:text-gray-100"
>
{locale.POST.TOP}
</button>
</a>
</div>
}

View File

@@ -0,0 +1,56 @@
import Image from 'next/image'
import TagItem from './TagItem'
import md5 from 'js-md5'
import { siteConfig } from '@/lib/config'
import NotionIcon from '@/components/NotionIcon'
export const ArticleInfo = (props) => {
const { post } = props
const emailHash = md5(siteConfig('CONTACT_EMAIL', '#'))
return <section className="flex-wrap flex mt-2 text-gray--600 dark:text-gray-400 font-light leading-8">
<div>
<h1 className="font-bold text-3xl text-black dark:text-white">
<NotionIcon icon={post?.pageIcon} />{post?.title}
</h1>
{post?.type !== 'Page' && <>
<nav className="flex mt-7 items-start text-gray-500 dark:text-gray-400">
<div className="flex mb-4">
<a href={siteConfig('CONTACT_GITHUB', '#')} className="flex">
<Image
alt={siteConfig('AUTHOR')}
width={24}
height={24}
src={`https://gravatar.com/avatar/${emailHash}`}
className="rounded-full"
/>
<p className="ml-2 md:block">{siteConfig('AUTHOR')}</p>
</a>
<span className="block">&nbsp;/&nbsp;</span>
</div>
<div className="mr-2 mb-4 md:ml-0">
{post?.publishDay}
</div>
{post?.tags && (
<div className="flex flex-nowrap max-w-full overflow-x-auto article-tags">
{post?.tags.map(tag => (
<TagItem key={tag} tag={tag} />
))}
</div>
)}
<span className="hidden busuanzi_container_page_pv mr-2">
<i className='mr-1 fas fa-eye' />
&nbsp;
<span className="mr-2 busuanzi_value_page_pv" />
</span>
</nav>
</>}
</div>
</section>
}

View File

@@ -0,0 +1,53 @@
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'>
<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,44 @@
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
import { checkContainHttp, sliceUrlFromHttp } from '@/lib/utils'
/**
* 归档分组文章
* @param {*} param0
* @returns
*/
export default function BlogArchiveItem({ 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 => {
const url = checkContainHttp(post.slug) ? sliceUrlFromHttp(post.slug) : `${siteConfig('SUB_PATH', '')}/${post.slug}`
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.date?.start_date}
</span>{' '}
&nbsp;
<Link
href={url}
passHref
className="dark:text-gray-400 dark:hover:text-gray-300 overflow-x-hidden hover:underline cursor-pointer text-gray-600">
{post.title}
</Link>
</div>
</li>
})}
</ul>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { useGameGlobal } from '..'
import Tags from './Tags'
export default function BlogListBar(props) {
const { tag, setFilterKey } = useGameGlobal()
const handleSearchChange = val => {
setFilterKey(val)
}
if (tag) {
return (
<div className='mb-4'>
<div className='relative'>
<input
type='text'
placeholder={tag ? `Search in #${tag}` : 'Search Articles'}
className='outline-none block w-full border px-4 py-2 border-black bg-white text-black dark:bg-night dark:border-white dark:text-white'
onChange={e => handleSearchChange(e.target.value)}
/>
<svg
className='absolute right-3 top-3 h-5 w-5 text-black dark:text-white'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'></path>
</svg>
</div>
<Tags {...props} />
</div>
)
} else {
return <></>
}
}

View File

@@ -0,0 +1,50 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { useRouter } from 'next/router'
import Link from 'next/link'
import BlogPost from './BlogPost'
export const BlogListPage = props => {
const { page = 1, posts, postCount } = props
const { locale } = useGlobal()
const router = useRouter()
const totalPage = Math.ceil(postCount / parseInt(siteConfig('POSTS_PER_PAGE')))
const currentPage = +page
const showPrev = currentPage > 1
const showNext = currentPage < totalPage && posts?.length > 0
const pagePrefix = router.asPath.split('?')[0].replace(/\/page\/[1-9]\d*/, '').replace(/\/$/, '')
return (
<div className="w-full md:pr-12 my-6">
<div id="posts-wrapper">
{posts?.map(post => (
<BlogPost key={post.id} post={post}/>
))}
</div>
<div className="flex justify-between text-xs">
<Link
href={{ pathname: currentPage - 1 === 1 ? `${pagePrefix}/` : `${pagePrefix}/page/${currentPage - 1}`, query: router.query.s ? { s: router.query.s } : {} }}
className={`${showPrev ? ' ' : ' invisible block pointer-events-none '}no-underline py-2 px-3 rounded`}>
<button rel="prev" className="block cursor-pointer">
{locale.PAGINATION.PREV}
</button>
</Link>
<Link
href={{ pathname: `${pagePrefix}/page/${currentPage + 1}`, query: router.query.s ? { s: router.query.s } : {} }}
className={`${showNext ? ' ' : 'invisible pointer-events-none '} no-underline py-2 px-3 rounded`}>
<button rel="next" className="block cursor-pointer">
{locale.PAGINATION.NEXT}
</button>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import throttle from 'lodash.throttle'
import { deepClone } from '@/lib/utils'
import { siteConfig } from '@/lib/config'
import { useCallback, useEffect, useRef, useState } from 'react'
export const BlogListScroll = props => {
const { posts } = props
const { locale } = useGlobal()
const [page, updatePage] = useState(1)
let hasMore = false
const postsToShow = posts && Array.isArray(posts)
? deepClone(posts).slice(0, parseInt(siteConfig('POSTS_PER_PAGE')) * page)
: []
if (posts) {
const totalCount = posts.length
hasMore = page * parseInt(siteConfig('POSTS_PER_PAGE')) < totalCount
}
const handleGetMore = () => {
if (!hasMore) return
updatePage(page + 1)
}
const targetRef = useRef(null)
// 监听滚动自动分页加载
const scrollTrigger = useCallback(throttle(() => {
const scrollS = window.scrollY + window.outerHeight
const clientHeight = targetRef ? (targetRef.current ? (targetRef.current.clientHeight) : 0) : 0
if (scrollS > clientHeight + 100) {
handleGetMore()
}
}, 500))
useEffect(() => {
window.addEventListener('scroll', scrollTrigger)
return () => {
window.removeEventListener('scroll', scrollTrigger)
}
})
return (
<div id="posts-wrapper" className="w-full md:pr-12 mb-12" ref={targetRef}>
{postsToShow.map(p => (
<article key={p.id} className="mb-12" >
<h2 className="mb-4">
<Link
href={`/${p.slug}`}
className="text-black text-xl md:text-2xl no-underline hover:underline">
{p.title}
</Link>
</h2>
<div className="mb-4 text-sm text-gray-700">
by <a href="#" className="text-gray-700">{siteConfig('AUTHOR')}</a> on {p.date?.start_date || p.createdTime}
<span className="font-bold mx-1"> | </span>
<a href="#" className="text-gray-700">{p.category}</a>
<span className="font-bold mx-1"> | </span>
{/* <a href="#" className="text-gray-700">2 Comments</a> */}
</div>
<p className="text-gray-700 leading-normal">
{p.summary}
</p>
</article>
))}
<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,41 @@
import Link from 'next/link'
import { siteConfig } from '@/lib/config'
import { checkContainHttp, sliceUrlFromHttp } from '@/lib/utils'
import NotionIcon from '@/components/NotionIcon'
import NotionPage from '@/components/NotionPage'
const BlogPost = ({ post }) => {
const url = checkContainHttp(post.slug) ? sliceUrlFromHttp(post.slug) : `${siteConfig('SUB_PATH', '')}/${post.slug}`
const showPreview = siteConfig('POST_LIST_PREVIEW') && post.blockMap
return (
(<Link href={url}>
<article key={post.id} className="mb-6 md:mb-8">
<header className="flex flex-col justify-between md:flex-row md:items-baseline">
<h2 className="text-lg md:text-xl font-medium mb-2 cursor-pointer text-black dark:text-gray-100">
<NotionIcon icon={post.pageIcon} />{post.title}
</h2>
<time className="flex-shrink-0 text-gray-600 dark:text-gray-400">
{post?.publishDay}
</time>
</header>
<main>
{!showPreview && <p className="hidden md:block leading-8 text-gray-700 dark:text-gray-300">
{post.summary}
</p>}
{showPreview && post?.blockMap && (
<div className="overflow-ellipsis truncate">
<NotionPage post={post} />
<hr className='border-dashed py-4'/>
</div>
)}
</main>
</article>
</Link>)
)
}
export default BlogPost

View File

@@ -0,0 +1,153 @@
import { useRef, useEffect, useState } from 'react'
/**
* 可拖拽组件
*/
export const Draggable = (props) => {
const { children,stick } = props
const draggableRef = useRef(null)
const rafRef = useRef(null)
const [moving, setMoving] = useState(false)
let currentObj, offsetX, offsetY
useEffect(() => {
const draggableElements = document.getElementsByClassName('draggable')
// 标准化鼠标事件对象
function e(event) { // 定义事件对象标准化函数
if (!event) { // 兼容IE浏览器
event = window.event
event.target = event.srcElement
event.layerX = event.offsetX
event.layerY = event.offsetY
}
// 移动端
if (event.type === 'touchstart' || event.type === 'touchmove') {
event.clientX = event.touches[0].clientX
event.clientY = event.touches[0].clientY
}
event.mx = event.pageX || event.clientX + document.body.scrollLeft
// 计算鼠标指针的x轴距离
event.my = event.pageY || event.clientY + document.body.scrollTop
// 计算鼠标指针的y轴距离
return event // 返回标准化的事件对象
}
// 定义鼠标事件处理函数
// document.pointerdown = start
document.onmousedown = start
document.ontouchstart = start
function start (event) { // 按下鼠标时,初始化处理
if (!draggableElements) return
event = e(event)// 获取标准事件对象
for (const drag of draggableElements) {
// 判断鼠标点击的区域是否是拖拽框内
if (inDragBox(event, drag)) {
currentObj = drag.firstElementChild
}
}
if (currentObj) {
if (event.type === 'touchstart') {
event.preventDefault() // 阻止默认的滚动行为
document.documentElement.style.overflow = 'hidden' // 防止页面一起滚动
}
setMoving(true)
offsetX = event.mx - currentObj.offsetLeft
offsetY = event.my - currentObj.offsetTop
document.onmousemove = move// 注册鼠标移动事件处理函数
document.ontouchmove = move
document.onmouseup = stop// 注册松开鼠标事件处理函数
document.ontouchend = stop
}
}
function move(event) { // 鼠标移动处理函数
event = e(event)
rafRef.current = requestAnimationFrame(() => updatePosition(event))
}
const stop = (event) => {
event = e(event)
document.documentElement.style.overflow = 'auto' // 恢复默认的滚动行为
cancelAnimationFrame(rafRef.current)
setMoving(false)
currentObj = document.ontouchmove = document.ontouchend = document.onmousemove = document.onmouseup = null
}
const updatePosition = (event) => {
if (currentObj) {
const left = event.mx - offsetX
const top = event.my - offsetY
currentObj.style.left = left + 'px'
currentObj.style.top = top + 'px'
checkInWindow()
}
}
/**
* 鼠标是否在可拖拽区域内
* @param {*} event
* @returns
*/
function inDragBox(event, drag) {
const { clientX, clientY } = event // 鼠标位置
const { offsetHeight, offsetWidth, offsetTop, offsetLeft } = drag.firstElementChild // 窗口位置
const horizontal = clientX > offsetLeft && clientX < offsetLeft + offsetWidth
const vertical = clientY > offsetTop && clientY < offsetTop + offsetHeight
if (horizontal && vertical) {
return true
}
return false
}
/**
* 若超出窗口则吸附。
*/
function checkInWindow() {
// 检查是否悬浮在窗口内
for (const drag of draggableElements) {
// 判断鼠标点击的区域是否是拖拽框内
const { offsetHeight, offsetWidth, offsetTop, offsetLeft } = drag.firstElementChild
const { clientHeight, clientWidth } = document.documentElement
if (offsetTop < 0) {
drag.firstElementChild.style.top = 0
}
if (offsetTop > (clientHeight - offsetHeight)) {
drag.firstElementChild.style.top = clientHeight - offsetHeight + 'px'
}
if (offsetLeft < 0) {
drag.firstElementChild.style.left = 0
}
if (offsetLeft > (clientWidth - offsetWidth)) {
drag.firstElementChild.style.left = clientWidth - offsetWidth + 'px'
}
if(stick==='left'){
drag.firstElementChild.style.left = 0 + 'px'
}
}
}
window.addEventListener('resize', checkInWindow)
return () => {
return () => {
window.removeEventListener('resize', checkInWindow)
cancelAnimationFrame(rafRef.current)
}
}
}, [])
return <div className={`draggable ${moving ? 'cursor-grabbing' : 'cursor-grab'} select-none`} ref={draggableRef}>
{children}
</div>
}
Draggable.defaultProps = { left: 0, top: 0 }

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,29 @@
import DarkModeButton from '@/components/DarkModeButton'
import Vercel from '@/components/Vercel'
import { siteConfig } from '@/lib/config'
export const Footer = (props) => {
const d = new Date()
const currentYear = d.getFullYear()
const { post } = props
const fullWidth = post?.fullWidth ?? false
const since = siteConfig('SINCE')
const copyrightDate = parseInt(since) < currentYear ? since + '-' + currentYear : currentYear
return <footer
className={`z-10 relative mt-6 flex-shrink-0 m-auto w-full text-gray-500 dark:text-gray-400 transition-all ${
!fullWidth ? 'max-w-2xl px-4' : 'px-4 md:px-24'
}`}
>
<DarkModeButton className='text-center py-4'/>
<hr className="border-gray-200 dark:border-gray-600" />
<div className="my-4 text-sm leading-6">
<div className="flex align-baseline justify-between flex-wrap">
<p>
© {siteConfig('AUTHOR')} {copyrightDate}
</p>
<Vercel />
</div>
</div>
</footer>
}

View File

@@ -0,0 +1,39 @@
/* eslint-disable @next/next/no-img-element */
import Image from 'next/image'
/**
* 全屏按钮
* @returns
*/
export default function FullScreen() {
function toggleFullScreen() {
// window.scrollTo(0, 2)
document?.querySelector('#game-wrapper')?.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest'
})
// console.log(document?.getElementById('game-wrapper')?.contentWindow)
document?.getElementById('game-wrapper')?.contentWindow?.toggleFullScreen()
}
return (
<div
className="group text-white w-full justify-center items-center flex rounded-lg m-2 md:m-0 p-2 hover:bg-gray-700 bg-[#1F2030] md:rounded-none md:bg-none"
onClick={toggleFullScreen}
>
<Image
width={18}
height={18}
src="/svg/fullscreen-alt.svg"
alt="full screen"
title="full screen"
className="cursor-pointer group-hover:scale-125 transition-all duration-150 "
/>
<span className="h-full flex mx-2 md:hidden items-center select-none">
FullScreen
</span>
</div>
)
}

View File

@@ -0,0 +1,164 @@
/* eslint-disable @next/next/no-img-element */
import { AdSlot } from '@/components/GoogleAdsense'
import { deepClone } from '@/lib/utils'
import { useState } from 'react'
/**
* 游戏列表
* @returns
*/
export const GameListIndexCombine = ({ games }) => {
const gamesClone = deepClone(games)
gamesClone?.sort((a, b) => {
const orderA = a.order || 999
const orderB = b.order || 999
return orderA - orderB
})
// 构造一个List<Component>
const components = []
// 根据序号随机大小;或根据game.recommend 决定
const recommend = true
let index = 0
// 无限循环
if (recommend) {
// 4合一卡组
let groupItems = []
while (gamesClone?.length > 0) {
index++
// 广告位
if (index % 9 === 0) {
components.push(<GameAd key={index} />)
continue
}
// 试图将4合一卡组塞满
while (gamesClone?.length > 0 && groupItems.length < 4) {
const item = gamesClone.shift()
if (item.recommend) {
components.push(<GameItem key={index} item={item} isLargeCard={true} />)
break
} else {
groupItems.push(item)
}
}
if (groupItems.length === 4 || (gamesClone.length === 0 && groupItems.length > 4)) {
components.push(<GameItemGroup key={index} items={groupItems} />)
groupItems = []
} else {
while (groupItems.length > 0) {
const item = groupItems.shift()
components.push(<GameItem key={index++} item={item} isLargeCard={true} />)
}
}
}
} else {
while (gamesClone?.length > 0) {
index++
if (index % 6 === 0) {
components.push(<GameAd key={index} />)
} else if (index % 2 === 0 && gamesClone?.length >= 4) {
// 如果是偶数则从游戏列表中退出4个组成大卡牌
const groupItems = []
for (let i = 1; i <= 4; i++) {
groupItems.push(gamesClone.shift())
}
components.push(<GameItemGroup key={index} items={groupItems} />)
} else {
const item = gamesClone.shift()
components.push(<GameItem key={index} item={item} isLargeCard={true} />)
}
}
}
return (
<div className='game-list-wrapper flex justify-center w-full px-2'>
<div className='game-grid mx-auto w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2'>
{components?.map((ItemComponent, index) => {
return ItemComponent
})}
</div>
</div>
)
}
/**
* 一个广告游戏大卡
* @returns
*/
const GameAd = () => {
return (
<div className='card-group border border-gray-600 rounded game-ad h-[20rem] w-full overflow-hidden'>
<AdSlot type='flow' />
</div>
)
}
/**
* 4卡组成一个大卡
* @param {*} param0
* @returns
*/
const GameItemGroup = ({ items }) => {
return (
<div className='card-group h-[20rem] w-full grid grid-cols-2 grid-rows-2 gap-2'>
{items.map((item, index) => (
<GameItem key={index} item={item} />
))}
</div>
)
}
/**
* 游戏=单卡
* @param {*} param0
* @returns
*/
const GameItem = ({ item, isLargeCard }) => {
const { id, title, img, video } = item
const [showType, setShowType] = useState('img') // img or video
return (
<a
href={`/game/${id}`}
onMouseOver={() => {
setShowType('video')
}}
onMouseOut={() => {
setShowType('img')
}}
title={title}
className={`card-single ${
isLargeCard ? 'h-[20rem]' : 'h-full'
} w-full relative shadow rounded-md overflow-hidden flex justify-center items-center
group hover:border-purple-400`}>
<div className='text-center absolute bottom-0 invisible group-hover:bottom-2 group-hover:visible transition-all duration-200 text-white z-30'>
{title}
</div>
<div className='h-1/2 w-full absolute left-0 bottom-0 z-20 opacity-0 group-hover:opacity-75 transition-all duration-200'>
<div className='h-full w-full absolute bg-gradient-to-b from-transparent to-black'></div>
</div>
{showType === 'video' && (
<video
className={`z-10 object-cover w-full ${isLargeCard ? 'h-[20rem]' : 'h-full'} absolute overflow-hidden`}
loop='true'
autoPlay
preload='none'>
<source src={video} type='video/mp4' />
</video>
)}
<img
className='w-full h-full absolute object-cover group-hover:scale-105 duration-100 transition-all'
src={img}
alt={title}
/>
</a>
)
}

View File

@@ -0,0 +1,69 @@
/* eslint-disable @next/next/no-img-element */
import { deepClone } from '@/lib/utils'
import { useState } from 'react'
/**
* 游戏列表- 关联游戏,在详情页展示
* @returns
*/
export const GameListNormal = ({ games, maxCount = 18 }) => {
const gamesClone = deepClone(games)
// 构造一个List<Component>
const components = []
let index = 0
// 无限循环
while (gamesClone?.length > 0 && index < maxCount) {
const item = gamesClone.shift()
components.push(<GameItem key={index} item={item} isLargeCard={true} />)
index++
continue
}
return (
<div className='game-list-wrapper w-full'>
<div className='game-grid mx-auto w-full h-full grid grid-cols-3 gap-2'>
{components?.map((ItemComponent, index) => {
return ItemComponent
})}
</div>
</div>
)
}
/**
* 游戏=单卡
* @param {*} param0
* @returns
*/
const GameItem = ({ item }) => {
const { id, title, img, video } = item
const [showType, setShowType] = useState('img') // img or video
return (
<a
href={`/game/${id}`}
onMouseOver={() => {
setShowType('video')
}}
onMouseOut={() => {
setShowType('img')
}}
title={title}
className={`card-single h-28 w-28 relative shadow rounded-md overflow-hidden flex justify-center items-center
group hover:border-purple-400`}>
<div className='absolute text-sm bottom-2 transition-all duration-200 text-white z-30'>{title}</div>
<div className='h-1/2 w-full absolute left-0 bottom-0 z-20 opacity-75 transition-all duration-200'>
<div className='h-full w-full absolute bg-gradient-to-b from-transparent to-black'></div>
</div>
{showType === 'video' && (
<video className='z-10 object-cover w-auto h-28 absolute overflow-hidden' loop='true' autoPlay preload='none'>
<source src={video} type='video/mp4' />
</video>
)}
<img className='w-full h-full absolute object-cover' src={img} alt={title} />
</a>
)
}

View File

@@ -0,0 +1,76 @@
/* eslint-disable @next/next/no-img-element */
import { deepClone } from '@/lib/utils'
import { useState } from 'react'
/**
* 游戏列表- 关联游戏,在详情页展示
* @returns
*/
export const GameListRelate = ({ games }) => {
const gamesClone = deepClone(games)
// 构造一个List<Component>
const components = []
const maxCount = 24
let index = 0
// 无限循环
while (gamesClone?.length > 0 && index < maxCount) {
const item = gamesClone.shift()
components.push(<GameItem key={index} item={item} isLargeCard={true} />)
index++
continue
}
return (
<div className='game-list-wrapper w-full max-w-full overflow-x-auto'>
<div className='game-grid grid grid-flow-col gap-2'>
{components?.map((ItemComponent, index) => {
return ItemComponent
})}
</div>
</div>
)
}
/**
* 游戏=单卡
* @param {*} param0
* @returns
*/
const GameItem = ({ item, isLargeCard }) => {
const { id, title, img, video } = item
const [showType, setShowType] = useState('img') // img or video
return (
<a
href={`/game/${id}`}
onMouseOver={() => {
setShowType('video')
}}
onMouseOut={() => {
setShowType('img')
}}
title={title}
className={`card-single w-24 h-24 relative shadow rounded-md overflow-hidden flex justify-center items-center
group hover:border-purple-400`}>
<div className='text-sm text-center absolute bottom-0 invisible group-hover:bottom-2 group-hover:visible transition-all duration-200 text-white z-30'>
{title}
</div>
<div className='h-1/2 w-full absolute left-0 bottom-0 z-20 opacity-0 group-hover:opacity-75 transition-all duration-200'>
<div className='h-full w-full absolute bg-gradient-to-b from-transparent to-black'></div>
</div>
{showType === 'video' && (
<video className={`z-10 object-cover w-full h-24 absolute overflow-hidden`} loop='true' autoPlay preload='none'>
<source src={video} type='video/mp4' />
</video>
)}
<img
className='w-24 h-24 absolute object-cover group-hover:scale-105 duration-100 transition-all'
src={img}
alt={title}
/>
</a>
)
}

View File

@@ -0,0 +1,83 @@
/* eslint-disable @next/next/no-img-element */
import { useGlobal } from '@/lib/global'
import { deepClone } from '@/lib/utils'
import { useState } from 'react'
/**
* 游戏列表- 最近游戏
* @returns
*/
export const GameListRecent = ({ maxCount = 14 }) => {
const { recentGames } = useGlobal()
const gamesClone = deepClone(recentGames)
// 构造一个List<Component>
const components = []
let index = 0
// 无限循环
while (gamesClone?.length > 0 && index < maxCount) {
const item = gamesClone?.shift()
if (item) {
components.push(<GameItem key={index} item={item} isLargeCard={true} />)
index++
}
continue
}
if (components.length === 0) {
return <></>
}
return (
<>
<div className='text-white text-lg pb-1 p-2'>Recent Played</div>
<div className='game-list-recent-wrapper w-full max-w-full overflow-x-auto p-2'>
<div className='game-grid md:flex grid grid-flow-col gap-2'>
{components?.map((ItemComponent, index) => {
return ItemComponent
})}
</div>
</div>
</>
)
}
/**
* 游戏=单卡
* @param {*} param0
* @returns
*/
const GameItem = ({ item }) => {
const { id, title, img, video } = item || {}
const [showType, setShowType] = useState('img') // img or video
return (
<a
href={`/game/${id}`}
onMouseOver={() => {
setShowType('video')
}}
onMouseOut={() => {
setShowType('img')
}}
title={title}
className={`card-single h-28 w-28 relative shadow rounded-md overflow-hidden flex justify-center items-center
group hover:border-purple-400`}>
<div className='absolute text-sm bottom-2 transition-all duration-200 text-white z-30'>{title}</div>
<div className='h-1/2 w-full absolute left-0 bottom-0 z-20 opacity-75 transition-all duration-200'>
<div className='h-full w-full absolute bg-gradient-to-b from-transparent to-black'></div>
</div>
{showType === 'video' && (
<video className='z-10 object-cover w-auto h-28 absolute overflow-hidden' loop='true' autoPlay preload='none'>
<source src={video} type='video/mp4' />
</video>
)}
<img
className='w-full h-full absolute object-cover group-hover:scale-105 duration-100 transition-all'
src={img}
alt={title}
/>
</a>
)
}

View File

@@ -0,0 +1,32 @@
import Image from 'next/image'
import Logo from './Logo'
import { useGlobal } from '@/lib/global'
/**
* 顶栏
* @returns
*/
export default function Header() {
const { setSideBarVisible } = useGlobal()
return (
<header className="z-20">
<div className="w-full h-16 rounded-md bg-[#1F2030] flex justify-between items-center px-4">
<Logo />
<button
className="flex xl:hidden"
onClick={() => {
setSideBarVisible(true)
}}
>
<Image
src="/svg/search.svg"
className="mr-2"
width={20}
height={20}
/>
</button>
</div>
</header>
)
}

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,14 @@
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
/* eslint-disable @next/next/no-html-link-for-pages */
export default function Logo() {
return (
<Link passHref href='/' className='logo rounded cursor-pointer flex flex-col items-center'>
<div className='w-full'>
<h1 className='text-2xl text-white font-bold font-serif'>{siteConfig('TITLE')}</h1>
<h2 className='text-xs text-gray-400 whitespace-nowrap'>{siteConfig('BIO')}</h2>
</div>
</Link>
)
}

View File

@@ -0,0 +1,11 @@
import { siteConfig } from '@/lib/config'
import Link from 'next/link'
/* eslint-disable @next/next/no-html-link-for-pages */
export default function LogoMini() {
return (
<Link href='/' className='logo rounded cursor-pointer flex items-center text-xl text-white font-bold font-serif'>
{siteConfig('TITLE')?.charAt(0)}
</Link>
)
}

View File

@@ -0,0 +1,55 @@
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='w-full px-4 py-2 text-left dark:bg-hexo-black-gray dark:border-black' onClick={toggleShow} >
{!hasSubMenu && <Link
href={link?.to} target={link?.to?.indexOf('http') === 0 ? '_blank' : '_self'}
className="font-extralight flex justify-between pl-2 pr-4 dark:text-gray-200 no-underline tracking-widest pb-1">
<span className=' hover:text-red-400 transition-all items-center duration-200'>{link?.icon && <span className='mr-2'><i className={link.icon}/></span>}{link?.name}</span>
</Link>}
{hasSubMenu && <div
onClick={hasSubMenu ? toggleOpenSubMenu : null}
className="font-extralight flex justify-between pl-2 pr-4 cursor-pointer dark:text-gray-200 no-underline tracking-widest pb-1">
<span className=' hover:text-red-400 transition-all items-center duration-200'>{link?.icon && <span className='mr-2'><i className={link.icon}/></span>}{link?.name}</span>
<i className='px-2 fa fa-plus text-gray-400'></i>
</div>}
</div>
{/* 折叠子菜单 */}
{hasSubMenu && <Collapse isOpen={isOpen} onHeightChange={props.onHeightChange}>
{link.subMenus.map((sLink, index) => {
return <div key={index} className='font-extralight dark:bg-black text-left px-10 justify-start bg-gray-50 hover:bg-gray-50 dark:hover:bg-gray-900 tracking-widest transition-all duration-200 border-b dark:border-gray-800 py-3 pr-6'>
<Link href={sLink.to} target={link?.to?.indexOf('http') === 0 ? '_blank' : '_self'}>
<span className='text-xs'>{sLink.title}</span>
</Link>
</div>
})}
</Collapse>}
</>
}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link'
import { useState } from 'react'
export const MenuItemDrop = ({ link }) => {
const [show, changeShow] = useState(false)
// const show = true
// const changeShow = () => {}
if (!link || !link.show) {
return null
}
const hasSubMenu = link?.subMenus?.length > 0
return <li className='mx-3 my-2' >
<div className='cursor-pointer ' onMouseOver={() => changeShow(true)} onMouseOut={() => changeShow(false)}>
{!hasSubMenu &&
<div className="block text-black dark:text-gray-50 nav" >
<Link href={link?.to} target={link?.to?.indexOf('http') === 0 ? '_blank' : '_self'} >
{link?.icon && <i className={link?.icon} />} {link?.name}
</Link>
</div>
}
{hasSubMenu &&
<div className='block text-black dark:text-gray-50 nav'>
{link?.icon && <i className={link?.icon} />} {link?.name}
<i className={`px-2 fas fa-chevron-down duration-500 transition-all ${show ? ' rotate-180' : ''}`}></i>
</div>
}
{/* 子菜单 */}
{hasSubMenu && <ul className={`${show ? 'visible opacity-100 top-12 ' : 'invisible opacity-0 top-10 '} border-gray-100 bg-white dark:bg-black dark:border-gray-800 transition-all duration-300 z-20 absolute block drop-shadow-lg `}>
{link.subMenus.map((sLink, index) => {
return <div key={index} className='not:last-child:border-b-0 border-b text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-900 tracking-widest transition-all duration-200 dark:border-gray-800 py-3 pr-6 pl-3'>
<Link href={sLink.to} target={link?.to?.indexOf('http') === 0 ? '_blank' : '_self'} >
<span className='text-sm text-nowrap font-extralight'>{link?.icon && <i className={sLink?.icon} > &nbsp; </i>}{sLink.title}</span>
</Link>
</div>
})}
</ul>}
</div>
</li>
}

View File

@@ -0,0 +1,30 @@
import Link from 'next/link'
import { useGameGlobal } from '..'
export const MenuList = () => {
const { setSideBarVisible } = useGameGlobal()
return (
<div>
<ul>
<li className='text-white py-4 px-2 font-bold hover:underline'>
<Link href='/' passHref>
<span className='flex items-center gap-2'>
<i className='fas fa-home' />
Home
</span>
</Link>
</li>
<li className='text-white py-4 px-2 font-bold hover:underline'>
<button
className='flex items-center gap-2'
onClick={() => {
setSideBarVisible(true)
}}>
<i className='fas fa-search' />
<span>Search</span>
</button>
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import Collapse from '@/components/Collapse'
import LazyImage from '@/components/LazyImage'
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import CONFIG from '../config'
import { MenuItemCollapse } from './MenuItemCollapse'
import { MenuItemDrop } from './MenuItemDrop'
import RandomPostButton from './RandomPostButton'
import SearchButton from './SearchButton'
import { SvgIcon } from './SvgIcon'
const Nav = props => {
const { navBarTitle, fullWidth, siteInfo } = props
const useSticky = !JSON.parse(siteConfig('GAME_AUTO_COLLAPSE_NAV_BAR', true))
const navRef = useRef(null)
const sentinalRef = useRef([])
const handler = ([entry]) => {
if (navRef && navRef.current && useSticky) {
if (!entry.isIntersecting && entry !== undefined) {
navRef.current?.classList.add('sticky-nav-full')
} else {
navRef.current?.classList.remove('sticky-nav-full')
}
} else {
navRef.current?.classList.add('remove-sticky')
}
}
useEffect(() => {
const obvserver = new window.IntersectionObserver(handler)
obvserver.observe(sentinalRef.current)
return () => {
if (sentinalRef.current) obvserver.unobserve(sentinalRef.current)
}
}, [sentinalRef])
return (
<>
<div className='observer-element h-4 md:h-12' ref={sentinalRef}></div>
<div
className={`sticky-nav m-auto w-full h-6 flex flex-row justify-between items-center mb-2 md:mb-12 py-8 bg-opacity-60 ${
!fullWidth ? 'max-w-3xl px-4' : 'px-4 md:px-24'
}`}
id='sticky-nav'
ref={navRef}>
<div className='flex items-center'>
<Link href='/' aria-label={siteConfig('TITLE')}>
<div className='h-6 w-6'>
{/* <SvgIcon/> */}
{siteConfig('GAME_NAV_NOTION_ICON', null, CONFIG) ? (
<LazyImage src={siteInfo?.icon} width={24} height={24} alt={siteConfig('AUTHOR')} />
) : (
<SvgIcon />
)}
</div>
</Link>
{navBarTitle ? (
<p className='ml-2 font-medium text-gray-800 dark:text-gray-300 header-name'>{navBarTitle}</p>
) : (
<p className='ml-2 font-medium text-gray-800 dark:text-gray-300 header-name whitespace-nowrap'>
{siteConfig('TITLE')}
{/* ,{' '}<span className="font-normal">{siteConfig('DESCRIPTION')}</span> */}
</p>
)}
</div>
<NavBar {...props} />
</div>
</>
)
}
const NavBar = props => {
const { customMenu, customNav } = props
const [isOpen, changeOpen] = useState(false)
const toggleOpen = () => {
changeOpen(!isOpen)
}
const collapseRef = useRef(null)
const { locale } = useGlobal()
let links = [
{
id: 2,
name: locale.NAV.RSS,
to: '/feed',
show: siteConfig('ENABLE_RSS') && siteConfig('GAME_MENU_RSS', null, CONFIG),
target: '_blank'
},
{
icon: 'fas fa-search',
name: locale.NAV.SEARCH,
to: '/search',
show: siteConfig('GAME_MENU_SEARCH', null, CONFIG)
},
{
icon: 'fas fa-archive',
name: locale.NAV.ARCHIVE,
to: '/archive',
show: siteConfig('GAME_MENU_ARCHIVE', null, CONFIG)
},
{
icon: 'fas fa-folder',
name: locale.COMMON.CATEGORY,
to: '/category',
show: siteConfig('GAME_MENU_CATEGORY', null, CONFIG)
},
{ icon: 'fas fa-tag', name: locale.COMMON.TAGS, to: '/tag', show: siteConfig('GAME_MENU_TAG', null, CONFIG) }
]
if (customNav) {
links = links.concat(customNav)
}
// 如果 开启自定义菜单则覆盖Page生成的菜单
if (siteConfig('CUSTOM_MENU')) {
links = customMenu
}
if (!links || links.length === 0) {
return null
}
return (
<div className='flex-shrink-0 flex'>
<ul className='hidden md:flex flex-row'>
{links?.map((link, index) => (
<MenuItemDrop key={index} link={link} />
))}
</ul>
<div className='md:hidden'>
<Collapse collapseRef={collapseRef} isOpen={isOpen} type='vertical' className='fixed top-16 right-6'>
<div className='dark:border-black bg-white dark:bg-black rounded border p-2 text-sm'>
{links?.map((link, index) => (
<MenuItemCollapse
key={index}
link={link}
onHeightChange={param => collapseRef.current?.updateCollapseHeight(param)}
/>
))}
</div>
</Collapse>
</div>
{JSON.parse(siteConfig('GAME_MENU_RANDOM_POST', null, CONFIG)) && <RandomPostButton {...props} />}
{JSON.parse(siteConfig('GAME_MENU_SEARCH_BUTTON', null, CONFIG)) && <SearchButton {...props} />}
<i
onClick={toggleOpen}
className='fas fa-bars cursor-pointer px-5 flex justify-center items-center md:hidden'></i>
</div>
)
}
export default Nav

View File

@@ -0,0 +1,9 @@
import { MenuList } from './MenuList'
export default function NavBar({ className }) {
return (
<nav className={className}>
<MenuList />
</nav>
)
}

View File

@@ -0,0 +1,26 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { useRouter } from 'next/router'
/**
* 随机跳转到一个文章
*/
export default function RandomPostButton(props) {
const { latestPosts } = props
const router = useRouter()
const { locale } = useGlobal()
/**
* 随机跳转文章
*/
function handleClick() {
const randomIndex = Math.floor(Math.random() * latestPosts.length)
const randomPost = latestPosts[randomIndex]
router.push(`${siteConfig('SUB_PATH', '')}/${randomPost?.slug}`)
}
return (
<div title={locale.MENU.WALK_AROUND} className='cursor-pointer hover:bg-black hover:bg-opacity-10 rounded-full w-10 h-10 flex justify-center items-center duration-200 transition-all' onClick={handleClick}>
<i className="fa-solid fa-podcast"></i>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { siteConfig } from '@/lib/config'
import { useGlobal } from '@/lib/global'
import { useRouter } from 'next/router'
import { useGameGlobal } from '..'
/**
* 搜索按钮
* @returns
*/
export default function SearchButton(props) {
const { locale } = useGlobal()
const { searchModal } = useGameGlobal()
const router = useRouter()
function handleSearch() {
if (siteConfig('ALGOLIA_APP_ID')) {
searchModal.current.openSearch()
} else {
router.push('/search')
}
}
return (
<>
<div
onClick={handleSearch}
title={locale.NAV.SEARCH}
alt={locale.NAV.SEARCH}
className='cursor-pointer hover:bg-black hover:bg-opacity-10 rounded-full w-10 h-10 flex justify-center items-center duration-200 transition-all'>
<i title={locale.NAV.SEARCH} className='fa-solid fa-magnifying-glass' />
</div>
</>
)
}

View File

@@ -0,0 +1,88 @@
import { useRouter } from 'next/router'
import { useGlobal } from '@/lib/global'
import { useImperativeHandle, useRef, useState } from 'react'
let lock = false
const SearchInput = props => {
const { tag, keyword, cRef } = props
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={tag ? `${locale.SEARCH.TAGS} #${tag}` : `${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,17 @@
import SearchInput from './SearchInput'
import Tags from './Tags'
/**
* 搜索页面上方嵌入内容
* @param {*} props
* @returns
*/
export default function SearchNavBar(props) {
return (<>
<div className='pb-12'>
<SearchInput {...props} />
</div>
<Tags {...props}/>
</>)
}

View File

@@ -0,0 +1,65 @@
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'
const ExampleRecentComments = dynamic(() => import('./ExampleRecentComments'))
export const SideBar = (props) => {
const { locale } = useGlobal()
const { latestPosts, categories } = 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">
{categories?.map(category => {
return (
<Link
key={category.name}
href={`/category/${category.name}`}
passHref
legacyBehavior>
<li> <a href="#" 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="#" className="text-gray-darkest text-sm">{p.title}</a></li>
</Link>
);
})}
</ul>
</div>
</aside>
{siteConfig('COMMENT_WALINE_SERVER_URL') && JSON.parse(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,60 @@
import { useEffect, useRef } from 'react'
import { useGameGlobal } from '..'
import { GameListNormal } from './GameListNormal'
import Logo from './Logo'
/**
* 侧拉抽屉的内容
*/
export default function SideBarContent() {
const { allGames, sideBarVisible, setSideBarVisible, filterGames, setFilterGames } = useGameGlobal()
const inputRef = useRef(null) // 创建对输入框的引用
useEffect(() => {
if (sideBarVisible) {
setTimeout(() => {
inputRef.current.focus() // 在组件渲染后聚焦输入框
}, 100)
}
}, [sideBarVisible, inputRef])
const handleSearch = e => {
const search = e.target.value
if (!search || search === '') {
setFilterGames(allGames?.filter(item => item.recommend))
return
}
setFilterGames(
allGames?.filter(item => {
return (
item.title.toLowerCase().includes(search.toLowerCase()) ||
item.id.toLowerCase().includes(search.toLowerCase()) ||
item.id.toLowerCase().replace('-', '').includes(search.toLowerCase().replace('-', ''))
)
})
)
}
return (
<div className='px-3'>
<div className='py-2 flex justify-between'>
<Logo />
<button
onClick={() => {
setSideBarVisible(false)
}}>
<i className='fas fa-times' />
</button>
</div>
<input
autoFocus
id='search-input'
ref={inputRef} // 将引用绑定到输入框
className='w-full h-12 rounded px-3 text-black'
onChange={handleSearch}
placeholder='Input the name of game'></input>
<div className='py-4'>
<GameListNormal games={filterGames} />
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
/**
* 侧边栏抽屉面板,可以从侧面拉出
* @returns {JSX.Element}
* @constructor
*/
const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => {
const router = useRouter()
useEffect(() => {
const sideBarDrawerRouteListener = () => {
switchSideDrawerVisible(false)
}
router.events.on('routeChangeComplete', sideBarDrawerRouteListener)
return () => {
router.events.off('routeChangeComplete', sideBarDrawerRouteListener)
}
}, [router.events])
// 点击按钮更改侧边抽屉状态
const switchSideDrawerVisible = showStatus => {
if (showStatus) {
onOpen && onOpen()
} else {
onClose && onClose()
}
const sideBarDrawer = window.document.getElementById('sidebar-drawer')
const sideBarDrawerBackground = window.document.getElementById('sidebar-drawer-background')
if (showStatus) {
sideBarDrawer?.classList.replace('-ml-96', 'ml-0')
sideBarDrawerBackground?.classList.replace('hidden', 'block')
} else {
sideBarDrawer?.classList.replace('ml-0', '-ml-96')
sideBarDrawerBackground?.classList.replace('block', 'hidden')
}
}
return (
<div id='sidebar-wrapper' className={`top-0 ${className}`}>
<div
id='sidebar-drawer'
className={`${isOpen ? 'ml-0 visible opacity-100' : '-ml-96 invisible opacity-0'} w-96 bg-black shadow-black shadow-lg flex flex-col duration-300 fixed h-full left-0 overflow-y-scroll scroll-hidden top-0 z-30`}>
{children}
</div>
{/* 背景蒙版 */}
<div
id='sidebar-drawer-background'
onClick={() => {
switchSideDrawerVisible(false)
}}
className={`${isOpen ? 'visible opacity-100' : 'invisible opacity-0 '} animate__animated animate__fadeIn fixed top-0 duration-300 left-0 z-20 w-full h-full bg-black/70`}
/>
</div>
)
}
export default SideBarDrawer

View File

@@ -0,0 +1,29 @@
export const SvgIcon = () => {
return <svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
width="24"
height="24"
className="fill-current text-black dark:text-white"
/>
<rect width="24" height="24" fill="url(#paint0_radial)" />
<defs>
<radialGradient
id="paint0_radial"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(45) scale(39.598)"
>
<stop stopColor="#CFCFCF" stopOpacity="0.6" />
<stop offset="1" stopColor="#E9E9E9" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
}

View File

@@ -0,0 +1,11 @@
import Link from 'next/link'
const TagItem = ({ tag }) => (
(<Link href={`/tag/${encodeURIComponent(tag)}`}>
<p className="mr-1 rounded-full px-2 py-1 border leading-none text-sm dark:border-gray-600">
{tag}
</p>
</Link>)
)
export default TagItem

View File

@@ -0,0 +1,38 @@
import Link from 'next/link'
const Tags = props => {
const { tagOptions, tag } = props
const currentTag = tag
if (!tagOptions) return null
return (
<div className="tag-container">
<ul className="flex max-w-full mt-4 overflow-x-auto">
{Object.keys(tagOptions).map(key => {
const tag = tagOptions[key]
const selected = tag.name === currentTag
return (
<li
key={tag.id}
className={`mr-3 font-medium border whitespace-nowrap dark:text-gray-300 ${
selected
? 'text-white bg-black border-black dark:bg-gray-600 dark:border-gray-600'
: 'bg-gray-100 border-gray-100 text-gray-400 dark:bg-night dark:border-gray-800'
}`}
>
<Link
key={tag.id}
href={selected ? '/search' : `/tag/${encodeURIComponent(tag.name)}`}
className="px-4 py-2 block">
{`${tag.name} (${tag.count})`}
</Link>
</li>
)
})}
</ul>
</div>
)
}
export default Tags

View File

@@ -0,0 +1,19 @@
import { siteConfig } from '@/lib/config'
/**
* 标题栏
* @param {*} props
* @returns
*/
export const Title = (props) => {
const { post } = props
const title = post?.title || siteConfig('DESCRIPTION')
const description = post?.description || siteConfig('AUTHOR')
return <div className="text-center px-6 py-12 mb-6 bg-gray-100 dark:bg-hexo-black-gray dark:border-hexo-black-gray border-b">
<h1 className=" text-xl md:text-4xl pb-4">{title}</h1>
<p className="leading-loose text-gray-dark">
{description}
</p>
</div>
}

15
themes/game/config.js Normal file
View File

@@ -0,0 +1,15 @@
const CONFIG = {
GAME_NAV_NOTION_ICON: true, // 是否读取Notion图标作为站点头像 ; 否则默认显示黑色SVG方块
// 特殊菜单
GAME_MENU_RANDOM_POST: true, // 是否显示随机跳转文章按钮
GAME_MENU_SEARCH_BUTTON: true, // 是否显示搜索按钮该按钮支持Algolia搜索
// 默认菜单配置 开启自定义菜单后以下配置则失效请在Notion中自行配置菜单
GAME_MENU_CATEGORY: false, // 显示分类
GAME_MENU_TAG: true, // 显示标签
GAME_MENU_ARCHIVE: false, // 显示归档
GAME_MENU_SEARCH: true, // 显示搜索
GAME_MENU_RSS: false // 显示订阅
}
export default CONFIG

350
themes/game/index.js Normal file
View File

@@ -0,0 +1,350 @@
import Comment from '@/components/Comment'
import { AdSlot } from '@/components/GoogleAdsense'
import replaceSearchResult from '@/components/Mark'
import NotionPage from '@/components/NotionPage'
import ShareBar from '@/components/ShareBar'
import { siteConfig } from '@/lib/config'
import { deepClone, isBrowser } from '@/lib/utils'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import Announcement from './components/Announcement'
import { ArticleFooter } from './components/ArticleFooter'
import { ArticleInfo } from './components/ArticleInfo'
import { ArticleLock } from './components/ArticleLock'
import BlogArchiveItem from './components/BlogArchiveItem'
import { BlogListPage } from './components/BlogListPage'
import { BlogListScroll } from './components/BlogListScroll'
import { Footer } from './components/Footer'
import Header from './components/Header'
import NavBar from './components/NavBar'
import SearchNavBar from './components/SearchNavBar'
import SideBarContent from './components/SideBarContent'
import SideBarDrawer from './components/SideBarDrawer'
import CONFIG from './config'
import { Style } from './style'
// const AlgoliaSearchModal = dynamic(() => import('@/components/AlgoliaSearchModal'), { ssr: false })
// 主题全局状态
const ThemeGlobalGame = createContext()
export const useGameGlobal = () => useContext(ThemeGlobalGame)
/**
* 基础布局 采用左右两侧布局,移动端使用顶部导航栏
* @returns {JSX.Element}
* @constructor
*/
const LayoutBase = props => {
const { allNavPages, children } = props
// const fullWidth = post?.fullWidth ?? false
// const { onLoading } = useGlobal()
const searchModal = useRef(null)
// 在列表中进行实时过滤
const [filterKey, setFilterKey] = useState('')
const [filterGames, setFilterGames] = useState(deepClone(allNavPages?.filter(item => item.recommend)))
const [recentGames, setRecentGames] = useState([])
const [sideBarVisible, setSideBarVisible] = useState(false)
useEffect(() => {
setRecentGames(localStorage.getItem('recent_games') ? JSON.parse(localStorage.getItem('recent_games')) : [])
}, [])
return (
<ThemeGlobalGame.Provider
value={{
searchModal,
filterKey,
setFilterKey,
recentGames,
setRecentGames,
filterGames,
setFilterGames,
sideBarVisible,
setSideBarVisible
}}>
<div
id='theme-game'
className={`${siteConfig('FONT_STYLE')} w-full h-full min-h-screen justify-center dark:text-gray-300 scroll-smooth`}>
<Style />
{/* 左右布局 */}
<div id='wrapper' className={'relative flex justify-between w-full h-full mx-auto'}>
{/* 左侧 */}
<div className='w-52 hidden xl:block relative z-10'>
<div className='py-4 px-2 sticky top-0 h-screen flex flex-col justify-between'>
{/* 顶部 */}
<div className=''>
<Header />
<NavBar />
</div>
<div className='w-full'>
<AdSlot />
<AdSlot />
<AdSlot />
</div>
<div>
<Footer />
</div>
</div>
</div>
{/* 右侧 */}
<main className='flex-grow w-full overflow-x-scroll'>
{children}
<div className='ads w-full justify-center flex p-2'>
<AdSlot type='flow' />
</div>
</main>
</div>
<SideBarDrawer
isOpen={sideBarVisible}
onClose={() => {
setSideBarVisible(false)
}}>
<SideBarContent />
</SideBarDrawer>
</div>
</ThemeGlobalGame.Provider>
)
}
/**
* 首页
* 首页是个博客列表,加上顶部嵌入一个公告
* @param {*} props
* @returns
*/
const LayoutIndex = props => {
return <LayoutPostList {...props} topSlot={<Announcement {...props} />} />
}
/**
* 博客列表
* @param {*} props
* @returns
*/
const LayoutPostList = props => {
const { posts, topSlot, tag } = props
const { filterKey } = useGameGlobal()
let filteredBlogPosts = []
if (filterKey && posts) {
filteredBlogPosts = posts.filter(post => {
const tagContent = post?.tags ? post?.tags.join(' ') : ''
const searchContent = post.title + post.summary + tagContent
return searchContent.toLowerCase().includes(filterKey.toLowerCase())
})
} else {
filteredBlogPosts = deepClone(posts)
}
return (
<>
{topSlot}
{tag && <SearchNavBar {...props} />}
{siteConfig('POST_LIST_STYLE') === 'page' ? (
<BlogListPage {...props} posts={filteredBlogPosts} />
) : (
<BlogListScroll {...props} posts={filteredBlogPosts} />
)}
</>
)
}
/**
* 搜索
* 页面是博客列表,上方嵌入一个搜索引导条
* @param {*} props
* @returns
*/
const LayoutSearch = props => {
const { keyword, posts } = props
useEffect(() => {
if (isBrowser) {
replaceSearchResult({
doms: document.getElementById('posts-wrapper'),
search: keyword,
target: {
element: 'span',
className: 'text-red-500 border-b border-dashed'
}
})
}
}, [])
// 在列表中进行实时过滤
const { filterKey } = useGameGlobal()
let filteredBlogPosts = []
if (filterKey && posts) {
filteredBlogPosts = posts.filter(post => {
const tagContent = post?.tags ? post?.tags.join(' ') : ''
const searchContent = post.title + post.summary + tagContent
return searchContent.toLowerCase().includes(filterKey.toLowerCase())
})
} else {
filteredBlogPosts = deepClone(posts)
}
return (
<>
<SearchNavBar {...props} />
{siteConfig('POST_LIST_STYLE') === 'page' ? (
<BlogListPage {...props} posts={filteredBlogPosts} />
) : (
<BlogListScroll {...props} posts={filteredBlogPosts} />
)}
</>
)
}
/**
* 归档
* @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'>
{Object.keys(archivePosts).map(archiveTitle => (
<BlogArchiveItem key={archiveTitle} archiveTitle={archiveTitle} archivePosts={archivePosts} />
))}
</div>
</>
)
}
/**
* 文章详情
* @param {*} props
* @returns
*/
const LayoutSlug = props => {
const { post, lock, validPassword } = props
const router = useRouter()
useEffect(() => {
// 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
)
}
}, [post])
return (
<>
{lock && <ArticleLock validPassword={validPassword} />}
{!lock && (
<div id='article-wrapper' className='px-2'>
<>
<ArticleInfo post={post} />
<NotionPage post={post} />
<ShareBar post={post} />
<Comment frontMatter={post} />
<ArticleFooter />
</>
</div>
)}
</>
)
}
/**
* 404 页面
* @param {*} props
* @returns
*/
const Layout404 = props => {
return <>404 Not found.</>
}
/**
* 文章分类列表
* @param {*} props
* @returns
*/
const LayoutCategoryIndex = props => {
const { categoryOptions } = props
return (
<>
<div id='category-list' className='duration-200 flex flex-wrap'>
{categoryOptions?.map(category => {
return (
<Link key={category.name} href={`/category/${category.name}`} passHref legacyBehavior>
<div
className={
'hover:text-black dark:hover:text-white dark:text-gray-300 dark:hover:bg-gray-600 px-5 cursor-pointer py-2 hover:bg-gray-100'
}>
<i className='mr-4 fas fa-folder' />
{category.name}({category.count})
</div>
</Link>
)
})}
</div>
</>
)
}
/**
* 文章标签列表
* @param {*} props
* @returns
*/
const LayoutTagIndex = props => {
const { tagOptions } = props
return (
<>
<div>
<div id='tags-list' className='duration-200 flex flex-wrap'>
{tagOptions.map(tag => {
return (
<div key={tag.name} className='p-2'>
<Link
key={tag}
href={`/tag/${encodeURIComponent(tag.name)}`}
passHref
className={`cursor-pointer inline-block rounded hover:bg-gray-500 hover:text-white duration-200 mr-2 py-1 px-2 text-xs whitespace-nowrap dark:hover:text-white text-gray-600 hover:shadow-xl dark:border-gray-400 notion-${tag.color}_background dark:bg-gray-800`}>
<div className='font-light dark:text-gray-400'>
<i className='mr-1 fas fa-tag' /> {tag.name + (tag.count ? `(${tag.count})` : '')}{' '}
</div>
</Link>
</div>
)
})}
</div>
</div>
</>
)
}
export {
Layout404,
LayoutArchive,
LayoutBase,
LayoutCategoryIndex,
LayoutIndex,
LayoutPostList,
LayoutSearch,
LayoutSlug,
LayoutTagIndex,
CONFIG as THEME_CONFIG
}

18
themes/game/style.js Normal file
View File

@@ -0,0 +1,18 @@
/* eslint-disable react/no-unknown-property */
/**
* 此处样式只对当前主题生效
* 此处不支持tailwindCSS的 @apply 语法
* @returns
*/
const Style = () => {
return <style jsx global>{`
// 底色
.dark body{
background-color: black;
}
`}</style>
}
export { Style }