feat: add local settings

This commit is contained in:
shinya
2025-07-15 00:35:28 +08:00
parent 76eacd97f9
commit 61cd291574
21 changed files with 741 additions and 126 deletions

View File

@@ -10,7 +10,7 @@ import React, {
} from 'react';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型
interface VideoInfo {
@@ -448,7 +448,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.episodes && source.episodes.length > 0 && (
<img
src={source.poster}
src={processImageUrl(source.poster)}
alt={source.title}
className='w-full h-full object-cover'
onError={(e) => {

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton';
import { SettingsButton } from './SettingsButton';
import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle';
@@ -15,15 +16,22 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite();
return (
<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'>
{/* 返回按钮 */}
{showBackButton && (
<div className='absolute top-1/2 left-4 -translate-y-1/2'>
<BackButton />
<div className='h-12 flex items-center justify-between px-4'>
{/* 左侧:返回按钮和设置按钮 */}
<div className='flex items-center gap-2'>
{showBackButton && <BackButton />}
<SettingsButton />
</div>
)}
{/* 站点名称 */}
<div className='h-12 flex items-center justify-center'>
{/* 右侧按钮 */}
<div className='flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</div>
{/* 中间Logo绝对居中 */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link
href='/'
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
@@ -31,12 +39,6 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{siteName}
</Link>
</div>
{/* 右侧按钮 */}
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</header>
);
};

View File

@@ -2,6 +2,7 @@ import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton';
import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader';
import { SettingsButton } from './SettingsButton';
import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle';
@@ -34,6 +35,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<SettingsButton />
<LogoutButton />
<ThemeToggle />
</div>

View File

@@ -0,0 +1,185 @@
'use client';
import { Settings, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export const SettingsButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [imageProxyUrl, setImageProxyUrl] = useState('');
const [mounted, setMounted] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
}
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
if (savedImageProxyUrl !== null) {
setImageProxyUrl(savedImageProxyUrl);
}
}
}, []);
// 保存设置到 localStorage
const handleAggregateToggle = (value: boolean) => {
setDefaultAggregateSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
}
};
const handleDoubanProxyUrlChange = (value: string) => {
setDoubanProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrl', value);
}
};
const handleImageProxyUrlChange = (value: string) => {
setImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('imageProxyUrl', value);
}
};
const handleSettingsClick = () => {
setIsOpen(!isOpen);
};
const handleClosePanel = () => {
setIsOpen(false);
};
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-40'
onClick={handleClosePanel}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-50 p-6'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleClosePanel}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 设置项 */}
<div className='space-y-6'>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)}
/>
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 豆瓣代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
URL以绕过豆瓣访问限制使API
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
{/* 图片代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
URL以加速图片加载
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://imageproxy.example.com/?url='
value={imageProxyUrl}
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
/>
</div>
</div>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
</div>
</>
);
return (
<>
<button
onClick={handleSettingsClick}
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='Settings'
>
<Settings className='w-full h-full' />
</button>
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isOpen && mounted && createPortal(settingsPanel, document.body)}
</>
);
};

View File

@@ -13,6 +13,7 @@ import {
toggleFavorite,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { processImageUrl } from '@/lib/utils';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
@@ -265,7 +266,7 @@ export default function VideoCard({
{/* 图片加载动画 - 改进淡入和锐化效果 */}
<Image
src={actualPoster}
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className={`object-cover transition-all duration-700 ease-out ${