mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 00:44:41 +08:00
feat: dark mode
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.23",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
next-pwa:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(@babel/core@7.27.4)(@types/babel__core@7.20.5)(next@14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.99.9)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
@@ -3656,6 +3659,12 @@ packages:
|
||||
peerDependencies:
|
||||
next: '*'
|
||||
|
||||
next-themes@0.4.6:
|
||||
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@14.2.30:
|
||||
resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==}
|
||||
engines: {node: '>=18.17.0'}
|
||||
@@ -9311,6 +9320,11 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
next: 14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
next@14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 14.2.30
|
||||
|
||||
@@ -143,7 +143,7 @@ function AggregatePageClient() {
|
||||
className='absolute top-0 left-0 -translate-x-[40%] -translate-y-[30%] sm:-translate-x-[180%] sm:-translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 transition-colors'
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-500 transition-colors'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -216,7 +216,7 @@ function AggregatePageClient() {
|
||||
}&title=${encodeURIComponent(src.title)}${
|
||||
src.year ? `&year=${src.year}` : ''
|
||||
}&from=aggregate`}
|
||||
className='relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 rounded-lg transition-colors'
|
||||
className='relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 dark:bg-gray-700/80 dark:hover:bg-green-600 rounded-lg transition-colors'
|
||||
>
|
||||
{/* 名称 */}
|
||||
<span className='px-1 text-white text-sm font-medium truncate whitespace-nowrap'>
|
||||
|
||||
@@ -137,7 +137,7 @@ function DetailPageClient() {
|
||||
className='absolute top-0 left-0 -translate-x-[40%] -translate-y-[30%] sm:-translate-x-[180%] sm:-translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 transition-colors'
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-500 transition-colors'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -221,7 +221,7 @@ function DetailPageClient() {
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='hidden sm:flex items-center justify-center gap-2 px-6 py-2 bg-gray-500 hover:bg-gray-600 rounded-lg transition-colors text-white'
|
||||
className='hidden sm:flex items-center justify-center gap-2 px-6 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors text-white'
|
||||
>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>从头开始</span>
|
||||
@@ -252,10 +252,10 @@ function DetailPageClient() {
|
||||
{/* 爱心按钮 */}
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-colors ${
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-colors ${
|
||||
favorited
|
||||
? 'bg-gray-300 hover:bg-gray-400'
|
||||
: 'bg-gray-400 hover:bg-gray-500'
|
||||
? 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'
|
||||
: 'bg-gray-400 hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
@@ -281,7 +281,7 @@ function DetailPageClient() {
|
||||
></div>
|
||||
</div>
|
||||
{/* 剩余时间 */}
|
||||
<span className='text-gray-600/60 text-xs whitespace-nowrap'>
|
||||
<span className='text-gray-600/60 dark:text-gray-400/60 text-xs whitespace-nowrap'>
|
||||
{playRecord.total_episodes > 1
|
||||
? `第${playRecord.index}集 剩余 `
|
||||
: '剩余 '}
|
||||
@@ -318,12 +318,12 @@ function DetailPageClient() {
|
||||
'source'
|
||||
)}&id=${searchParams.get('id')}&index=${
|
||||
idx + 1
|
||||
}&title=${encodeURIComponent(detail.title)}${
|
||||
}&position=0&title=${encodeURIComponent(detail.title)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='bg-gray-500/80 hover:bg-green-500 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
|
||||
className='bg-gray-500/80 hover:bg-green-500 dark:bg-gray-700/80 dark:hover:bg-green-600 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
第{idx + 1}集
|
||||
</a>
|
||||
|
||||
@@ -183,10 +183,10 @@ function DoubanPageClient() {
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 页面标题 */}
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold text-gray-800 mb-2'>
|
||||
<h1 className='text-3xl font-bold text-gray-800 mb-2 dark:text-gray-200'>
|
||||
{getPageTitle()}
|
||||
</h1>
|
||||
<p className='text-gray-600'>来自豆瓣的精选内容</p>
|
||||
<p className='text-gray-600 dark:text-gray-400'>来自豆瓣的精选内容</p>
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
|
||||
@@ -27,6 +27,9 @@ body {
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#e6f3fb 0%,
|
||||
@@ -99,3 +102,47 @@ body {
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* View Transitions API 动画 */
|
||||
@keyframes slide-from-top {
|
||||
from {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-from-bottom {
|
||||
from {
|
||||
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.8s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/*
|
||||
切换时,旧的视图不应该有动画,它应该在下面,等待被新的视图覆盖。
|
||||
这可以防止在动画完成前,页面底部提前变色。
|
||||
*/
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 从浅色到深色:新内容(深色)从顶部滑入 */
|
||||
html.dark::view-transition-new(root) {
|
||||
animation-name: slide-from-top;
|
||||
}
|
||||
|
||||
/* 从深色到浅色:新内容(浅色)从底部滑入 */
|
||||
html:not(.dark)::view-transition-new(root) {
|
||||
animation-name: slide-from-bottom;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
import AuthProvider from '../components/AuthProvider';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@@ -19,12 +20,19 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang='zh-CN'>
|
||||
<head>
|
||||
<meta name='theme-color' content='#f9fbfe' />
|
||||
</head>
|
||||
<body className={`${inter.className} min-h-screen text-gray-900`}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -119,10 +119,12 @@ function HomeClient() {
|
||||
// 收藏夹视图
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>我的收藏</h2>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
我的收藏
|
||||
</h2>
|
||||
{favoriteItems.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700'
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={async () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
@@ -139,7 +141,7 @@ function HomeClient() {
|
||||
</div>
|
||||
))}
|
||||
{favoriteItems.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
暂无收藏内容
|
||||
</div>
|
||||
)}
|
||||
@@ -154,10 +156,12 @@ function HomeClient() {
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>热门电影</h2>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门电影
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=movie&tag=热门&title=热门电影'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
@@ -171,10 +175,10 @@ function HomeClient() {
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
@@ -196,10 +200,12 @@ function HomeClient() {
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>热门剧集</h2>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=tv&tag=热门&title=热门剧集'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
@@ -213,10 +219,10 @@ function HomeClient() {
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
||||
@@ -135,14 +135,14 @@ function SearchPageClient() {
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400' />
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm'
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -158,10 +158,14 @@ function SearchPageClient() {
|
||||
<section className='mb-12'>
|
||||
{/* 标题 + 聚合开关 */}
|
||||
<div className='mb-8 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>搜索结果</h2>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
</h2>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none'>
|
||||
<span className='text-sm text-gray-700'>聚合</span>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
聚合
|
||||
</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -171,7 +175,7 @@ function SearchPageClient() {
|
||||
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
|
||||
}
|
||||
/>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors'></div>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -209,7 +213,7 @@ function SearchPageClient() {
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
@@ -218,7 +222,7 @@ function SearchPageClient() {
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
|
||||
搜索历史
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
@@ -226,7 +230,7 @@ function SearchPageClient() {
|
||||
await clearSearchHistory();
|
||||
setSearchHistory([]);
|
||||
}}
|
||||
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors'
|
||||
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
@@ -240,7 +244,7 @@ function SearchPageClient() {
|
||||
setSearchQuery(item);
|
||||
router.push(`/search?q=${encodeURIComponent(item)}`);
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200'
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
|
||||
@@ -114,7 +114,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
{/* 标题 */}
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
|
||||
{first.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex bg-gray-300/80 rounded-full p-1 ${
|
||||
className={`inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
|
||||
className || ''
|
||||
}`}
|
||||
>
|
||||
@@ -25,8 +25,8 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 ${
|
||||
active === opt.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-700 hover:text-gray-900'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-500 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -72,10 +72,12 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
return (
|
||||
<section className={`mb-8 ${className || ''}`}>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>继续观看</h2>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
继续观看
|
||||
</h2>
|
||||
{!loading && playRecords.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700'
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={async () => {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
@@ -93,11 +95,11 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
||||
@@ -102,7 +102,7 @@ const DemoCard = ({ title, poster, rate }: DemoCardProps) => {
|
||||
{/* 信息层 */}
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@ const DoubanCardSkeleton = () => {
|
||||
<div className='w-full'>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报骨架 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
|
||||
{/* 信息层骨架 */}
|
||||
|
||||
@@ -79,7 +79,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
|
||||
return (
|
||||
<nav
|
||||
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide'
|
||||
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide dark:bg-gray-900/80 dark:border-gray-700/50'
|
||||
style={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
@@ -97,10 +97,18 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
>
|
||||
<item.icon
|
||||
className={`h-6 w-6 ${
|
||||
active ? 'text-green-600' : 'text-gray-500'
|
||||
active
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className={active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
const MobileHeader = () => {
|
||||
return (
|
||||
<header className='md:hidden w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm'>
|
||||
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||
<div className='h-12 flex items-center justify-center'>
|
||||
<Link
|
||||
href='/'
|
||||
@@ -11,6 +15,9 @@ const MobileHeader = () => {
|
||||
MoonTV
|
||||
</Link>
|
||||
</div>
|
||||
<div className='absolute top-1/2 right-4 -translate-y-1/2'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { useSidebar } from './Sidebar';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -17,10 +18,13 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
<div className='hidden md:grid md:grid-cols-[auto_1fr] w-full'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`min-w-0 transition-all duration-300 ${
|
||||
className={`relative min-w-0 transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:block'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,9 +126,9 @@ export default function ScrollableRow({
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollLeftClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105'
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||
>
|
||||
<ChevronLeft className='w-6 h-6 text-gray-600' />
|
||||
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,9 +153,9 @@ export default function ScrollableRow({
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollRightClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105'
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||
>
|
||||
<ChevronRight className='w-6 h-6 text-gray-600' />
|
||||
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,11 +164,10 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
data-sidebar
|
||||
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg ${
|
||||
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
@@ -187,7 +186,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 ${
|
||||
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${
|
||||
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||
}`}
|
||||
>
|
||||
@@ -201,12 +200,12 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
href='/'
|
||||
onClick={() => setActive('/')}
|
||||
data-active={active === '/'}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
@@ -222,12 +221,12 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
setActive('/search');
|
||||
}}
|
||||
data-active={active === '/search'}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
@@ -253,20 +252,21 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`) &&
|
||||
tagMatch &&
|
||||
decodedActive.includes(`tag=${tagMatch}`));
|
||||
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={isActive}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
|
||||
<Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
|
||||
9
src/components/ThemeProvider.tsx
Normal file
9
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
import * as React from 'react';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
45
src/components/ThemeToggle.tsx
Normal file
45
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
// 渲染一个占位符以避免布局偏移
|
||||
return <div className='w-10 h-10' />;
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
// 检查浏览器是否支持 View Transitions API
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className='w-full h-full' />
|
||||
) : (
|
||||
<Moon className='w-full h-full' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export default function VideoCard({
|
||||
|
||||
{/* 播放进度条 */}
|
||||
{progress !== undefined && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300'>
|
||||
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300 dark:bg-gray-600'>
|
||||
<div
|
||||
className='h-full bg-blue-500 transition-all duration-300'
|
||||
style={{ width: `${progress}%` }}
|
||||
@@ -246,14 +246,14 @@ export default function VideoCard({
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
|
||||
{title}
|
||||
</span>
|
||||
{source && (
|
||||
<span className='text-gray-500 text-[0.5rem] sm:text-xs w-full text-center mt-1'>
|
||||
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px]'>
|
||||
<span className='text-gray-500 text-[0.5rem] sm:text-xs w-full text-center mt-1 dark:text-gray-400'>
|
||||
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
|
||||
{source_name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Config } from 'tailwindcss';
|
||||
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
|
||||
Reference in New Issue
Block a user