feat: dark mode

This commit is contained in:
shinya
2025-06-26 21:00:11 +08:00
parent 3652bf3e6b
commit d677ca9877
23 changed files with 234 additions and 78 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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'>

View File

@@ -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>

View File

@@ -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>
{/* 内容展示区域 */}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>
))
: // 显示真实数据

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>
))
: // 显示真实数据

View File

@@ -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>

View File

@@ -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>
{/* 信息层骨架 */}

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'>

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

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

View File

@@ -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>

View File

@@ -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}',