first commit

This commit is contained in:
JohnsonRan
2025-08-12 21:50:58 +08:00
commit 8b9be4bb19
121 changed files with 36497 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { ArrowLeft } from 'lucide-react';
export function BackButton() {
return (
<button
onClick={() => window.history.back()}
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='Back'
>
<ArrowLeft className='w-full h-full' />
</button>
);
}

View File

@@ -0,0 +1,103 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useRef, useState } from 'react';
interface CapsuleSwitchProps {
options: { label: string; value: string }[];
active: string;
onChange: (value: string) => void;
className?: string;
}
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
options,
active,
onChange,
className,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const activeIndex = options.findIndex((opt) => opt.value === active);
// 更新指示器位置
const updateIndicatorPosition = () => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, []);
// 监听选中项变化
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
return (
<div
ref={containerRef}
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
className || ''
}`}
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((opt, index) => {
const isActive = active === opt.value;
return (
<button
key={opt.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(opt.value)}
className={`relative z-10 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 cursor-pointer ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
);
})}
</div>
);
};
export default CapsuleSwitch;

View File

@@ -0,0 +1,154 @@
/* eslint-disable no-console */
'use client';
import { useEffect, useState } from 'react';
import type { PlayRecord } from '@/lib/db.client';
import {
clearAllPlayRecords,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard';
interface ContinueWatchingProps {
className?: string;
}
export default function ContinueWatching({ className }: ContinueWatchingProps) {
const [playRecords, setPlayRecords] = useState<
(PlayRecord & { key: string })[]
>([]);
const [loading, setLoading] = useState(true);
// 处理播放记录数据更新的函数
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
// 将记录转换为数组并根据 save_time 由近到远排序
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
...record,
key,
}));
// 按 save_time 降序排序(最新的在前面)
const sortedRecords = recordsArray.sort(
(a, b) => b.save_time - a.save_time
);
setPlayRecords(sortedRecords);
};
useEffect(() => {
const fetchPlayRecords = async () => {
try {
setLoading(true);
// 从缓存或API获取所有播放记录
const allRecords = await getAllPlayRecords();
updatePlayRecords(allRecords);
} catch (error) {
console.error('获取播放记录失败:', error);
setPlayRecords([]);
} finally {
setLoading(false);
}
};
fetchPlayRecords();
// 监听播放记录更新事件
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
updatePlayRecords(newRecords);
}
);
return unsubscribe;
}, []);
// 如果没有播放记录,则不渲染组件
if (!loading && playRecords.length === 0) {
return null;
}
// 计算播放进度百分比
const getProgress = (record: PlayRecord) => {
if (record.total_time === 0) return 0;
return (record.play_time / record.total_time) * 100;
};
// 从 key 中解析 source 和 id
const parseKey = (key: string) => {
const [source, id] = key.split('+');
return { source, id };
};
return (
<section className={`mb-8 ${className || ''}`}>
<div className='mb-4 flex items-center justify-between'>
<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 dark:text-gray-400 dark:hover:text-gray-200'
onClick={async () => {
await clearAllPlayRecords();
setPlayRecords([]);
}}
>
</button>
)}
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 6 }).map((_, index) => (
<div
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 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 dark:bg-gray-800'></div>
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((r) => r.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
/>
</div>
);
})}
</ScrollableRow>
</section>
);
}

View File

@@ -0,0 +1,21 @@
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
const DoubanCardSkeleton = () => {
return (
<div className='w-full'>
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
{/* 图片占位符 - 骨架屏效果 */}
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
{/* 信息层骨架 */}
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<div className='h-4 w-24 sm:w-32 bg-gray-200 rounded animate-pulse mb-2'></div>
</div>
</div>
</div>
</div>
);
};
export default DoubanCardSkeleton;

View File

@@ -0,0 +1,318 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface CustomCategory {
name: string;
type: 'movie' | 'tv';
query: string;
}
interface DoubanCustomSelectorProps {
customCategories: CustomCategory[];
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
}
const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
customCategories,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 二级选择器滚动容器的ref
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
const primaryOptions = React.useMemo(() => {
const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
// 确保电影类型排在前面
const sortedTypes = types.sort((a, b) => {
if (a === 'movie' && b !== 'movie') return -1;
if (a !== 'movie' && b === 'movie') return 1;
return 0;
});
return sortedTypes.map((type) => ({
label: type === 'movie' ? '电影' : '剧集',
value: type,
}));
}, [customCategories]);
// 根据选中的一级选项生成二级选择器选项
const secondaryOptions = React.useMemo(() => {
if (!primarySelection) return [];
return customCategories
.filter((cat) => cat.type === primarySelection)
.map((cat) => ({
label: cat.name || cat.query,
value: cat.query,
}));
}, [customCategories, primarySelection]);
// 处理二级选择器的鼠标滚轮事件(原生 DOM 事件)
const handleSecondaryWheel = React.useCallback((e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const container = secondaryScrollContainerRef.current;
if (container) {
const scrollAmount = e.deltaY * 2;
container.scrollLeft += scrollAmount;
}
}, []);
// 添加二级选择器的鼠标滚轮事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer) {
// 同时监听滚动容器和胶囊容器的滚轮事件
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel]);
// 当二级选项变化时重新添加事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer && secondaryOptions.length > 0) {
// 重新添加事件监听器
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel, secondaryOptions]);
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === (primarySelection || primaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === (secondarySelection || secondaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection, primaryOptions]);
// 监听副选择器变化
useEffect(() => {
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection, secondaryOptions]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: { label: string; value: string }[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
// 如果没有自定义分类,则不渲染任何内容
if (!customCategories || customCategories.length === 0) {
return null;
}
return (
<div className='space-y-4 sm:space-y-6'>
{/* 两级选择器包装 */}
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
primaryOptions,
primarySelection || primaryOptions[0]?.value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
{secondaryOptions.length > 0 && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div ref={secondaryScrollContainerRef} className='overflow-x-auto'>
{renderCapsuleSelector(
secondaryOptions,
secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
</div>
);
};
export default DoubanCustomSelector;

View File

@@ -0,0 +1,567 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
import MultiLevelSelector from './MultiLevelSelector';
import WeekdaySelector from './WeekdaySelector';
interface SelectorOption {
label: string;
value: string;
}
interface DoubanSelectorProps {
type: 'movie' | 'tv' | 'show' | 'anime';
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
onMultiLevelChange?: (values: Record<string, string>) => void;
onWeekdayChange: (weekday: string) => void;
}
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
onMultiLevelChange,
onWeekdayChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 电影的一级选择器选项
const moviePrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '热门电影', value: '热门' },
{ label: '最新电影', value: '最新' },
{ label: '豆瓣高分', value: '豆瓣高分' },
{ label: '冷门佳片', value: '冷门佳片' },
];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '华语', value: '华语' },
{ label: '欧美', value: '欧美' },
{ label: '韩国', value: '韩国' },
{ label: '日本', value: '日本' },
];
// 电视剧一级选择器选项
const tvPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' },
];
// 电视剧二级选择器选项
const tvSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: 'tv' },
{ label: '国产', value: 'tv_domestic' },
{ label: '欧美', value: 'tv_american' },
{ label: '日本', value: 'tv_japanese' },
{ label: '韩国', value: 'tv_korean' },
{ label: '动漫', value: 'tv_animation' },
{ label: '纪录片', value: 'tv_documentary' },
];
// 综艺一级选择器选项
const showPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' },
];
// 综艺二级选择器选项
const showSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: 'show' },
{ label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' },
];
// 动漫一级选择器选项
const animePrimaryOptions: SelectorOption[] = [
{ label: '每日放送', value: '每日放送' },
{ label: '番剧', value: '番剧' },
{ label: '剧场版', value: '剧场版' },
];
// 处理多级选择器变化
const handleMultiLevelChange = (values: Record<string, string>) => {
onMultiLevelChange?.(values);
};
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || moviePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || tvPrimaryOptions[1].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || animePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || showPrimaryOptions[1].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
let secondaryActiveIndex = -1;
if (type === 'movie') {
secondaryActiveIndex = movieSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
);
} else if (type === 'tv') {
secondaryActiveIndex = tvSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || tvSecondaryOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || showSecondaryOptions[0].value)
);
}
if (secondaryActiveIndex >= 0) {
updateIndicatorPosition(
secondaryActiveIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [type]); // 只在type变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection]);
// 监听副选择器变化
useEffect(() => {
let activeIndex = -1;
let options: SelectorOption[] = [];
if (type === 'movie') {
activeIndex = movieSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = movieSecondaryOptions;
} else if (type === 'tv') {
activeIndex = tvSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvSecondaryOptions;
} else if (type === 'show') {
activeIndex = showSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showSecondaryOptions;
}
if (options.length > 0) {
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
return (
<div className='space-y-4 sm:space-y-6'>
{/* 电影类型 - 显示两级选择器 */}
{type === 'movie' && (
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 - 只在非"全部"时显示 */}
{primarySelection !== '全部' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
) : (
/* 多级选择器 - 只在选中"全部"时显示 */
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
)}
</div>
)}
{/* 电视剧类型 - 显示两级选择器 */}
{type === 'tv' && (
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvPrimaryOptions,
primarySelection || tvPrimaryOptions[1].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvSecondaryOptions,
secondarySelection || tvSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
) : null}
</div>
)}
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
{type === 'anime' && (
<div className='space-y-3 sm:space-y-4'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
animePrimaryOptions,
primarySelection || animePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
// 每日放送分类下显示星期选择器
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
</div>
</div>
) : (
// 其他分类下显示原有的筛选功能
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{(primarySelection || animePrimaryOptions[0].value) ===
'番剧' ? (
<MultiLevelSelector
key={`anime-tv-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType='anime-tv'
/>
) : (
<MultiLevelSelector
key={`anime-movie-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType='anime-movie'
/>
)}
</div>
</div>
)}
</div>
)}
{/* 综艺类型 - 显示两级选择器 */}
{type === 'show' && (
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showPrimaryOptions,
primarySelection || showPrimaryOptions[1].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showSecondaryOptions,
secondarySelection || showSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
) : null}
</div>
)}
</div>
);
};
export default DoubanSelector;

View File

@@ -0,0 +1,640 @@
/* eslint-disable @next/next/no-img-element */
import { useRouter } from 'next/navigation';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型
interface VideoInfo {
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean; // 添加错误状态标识
}
interface EpisodeSelectorProps {
/** 总集数 */
totalEpisodes: number;
/** 剧集标题 */
episodes_titles: string[];
/** 每页显示多少集,默认 50 */
episodesPerPage?: number;
/** 当前选中的集数1 开始) */
value?: number;
/** 用户点击选集后的回调 */
onChange?: (episodeNumber: number) => void;
/** 换源相关 */
onSourceChange?: (source: string, id: string, title: string) => void;
currentSource?: string;
currentId?: string;
videoTitle?: string;
videoYear?: string;
availableSources?: SearchResult[];
sourceSearchLoading?: boolean;
sourceSearchError?: string | null;
/** 预计算的测速结果,避免重复测速 */
precomputedVideoInfo?: Map<string, VideoInfo>;
}
/**
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
*/
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
totalEpisodes,
episodes_titles,
episodesPerPage = 50,
value = 1,
onChange,
onSourceChange,
currentSource,
currentId,
videoTitle,
availableSources = [],
sourceSearchLoading = false,
sourceSearchError = null,
precomputedVideoInfo,
}) => {
const router = useRouter();
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
// 存储每个源的视频信息
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
new Map()
);
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
new Set()
);
// 使用 ref 来避免闭包问题
const attemptedSourcesRef = useRef<Set<string>>(new Set());
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(new Map());
// 同步状态到 ref
useEffect(() => {
attemptedSourcesRef.current = attemptedSources;
}, [attemptedSources]);
useEffect(() => {
videoInfoMapRef.current = videoInfoMap;
}, [videoInfoMap]);
// 主要的 tab 状态:'episodes' 或 'sources'
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
totalEpisodes > 1 ? 'episodes' : 'sources'
);
// 当前分页索引0 开始)
const initialPage = Math.floor((value - 1) / episodesPerPage);
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// 是否倒序显示
const [descending, setDescending] = useState<boolean>(false);
// 根据 descending 状态计算实际显示的分页索引
const displayPage = useMemo(() => {
if (descending) {
return pageCount - 1 - currentPage;
}
return currentPage;
}, [currentPage, descending, pageCount]);
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
const getVideoInfo = useCallback(async (source: SearchResult) => {
const sourceKey = `${source.source}-${source.id}`;
// 使用 ref 获取最新的状态,避免闭包问题
if (attemptedSourcesRef.current.has(sourceKey)) {
return;
}
// 获取第一集的URL
if (!source.episodes || source.episodes.length === 0) {
return;
}
const episodeUrl =
source.episodes.length > 1 ? source.episodes[1] : source.episodes[0];
// 标记为已尝试
setAttemptedSources((prev) => new Set(prev).add(sourceKey));
try {
const info = await getVideoResolutionFromM3u8(episodeUrl);
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
} catch (error) {
// 失败时保存错误状态
setVideoInfoMap((prev) =>
new Map(prev).set(sourceKey, {
quality: '错误',
loadSpeed: '未知',
pingTime: 0,
hasError: true,
})
);
}
}, []);
// 当有预计算结果时先合并到videoInfoMap中
useEffect(() => {
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
// 原子性地更新两个状态,避免时序问题
setVideoInfoMap((prev) => {
const newMap = new Map(prev);
precomputedVideoInfo.forEach((value, key) => {
newMap.set(key, value);
});
return newMap;
});
setAttemptedSources((prev) => {
const newSet = new Set(prev);
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
newSet.add(key);
}
});
return newSet;
});
// 同步更新 ref确保 getVideoInfo 能立即看到更新
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
attemptedSourcesRef.current.add(key);
}
});
}
}, [precomputedVideoInfo]);
// 读取本地“优选和测速”开关,默认开启
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 当切换到换源tab并且有源数据时异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
useEffect(() => {
const fetchVideoInfosInBatches = async () => {
if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0
)
return;
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
return !attemptedSourcesRef.current.has(sourceKey);
});
if (pendingSources.length === 0) return;
const batchSize = Math.ceil(pendingSources.length / 2);
for (let start = 0; start < pendingSources.length; start += batchSize) {
const batch = pendingSources.slice(start, start + batchSize);
await Promise.all(batch.map(getVideoInfo));
}
};
fetchVideoInfosInBatches();
// 依赖项保持与之前一致
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
// 升序分页标签
const categoriesAsc = useMemo(() => {
return Array.from({ length: pageCount }, (_, i) => {
const start = i * episodesPerPage + 1;
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
return { start, end };
});
}, [pageCount, episodesPerPage, totalEpisodes]);
// 根据 descending 状态决定分页标签的排序和内容
const categories = useMemo(() => {
if (descending) {
// 倒序时label 也倒序显示
return [...categoriesAsc]
.reverse()
.map(({ start, end }) => `${end}-${start}`);
}
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
}, [categoriesAsc, descending]);
const categoryContainerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
// 当分页切换时,将激活的分页标签滚动到视口中间
useEffect(() => {
const btn = buttonRefs.current[displayPage];
const container = categoryContainerRef.current;
if (btn && container) {
// 手动计算滚动位置,只滚动分页标签容器
const containerRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const scrollLeft = container.scrollLeft;
// 计算按钮相对于容器的位置
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
const btnWidth = btnRect.width;
const containerWidth = containerRect.width;
// 计算目标滚动位置,使按钮居中
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
// 平滑滚动到目标位置
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
}
}, [displayPage, pageCount]);
// 处理换源tab点击只在点击时才搜索
const handleSourceTabClick = () => {
setActiveTab('sources');
};
const handleCategoryClick = useCallback(
(index: number) => {
if (descending) {
// 在倒序时,需要将显示索引转换为实际索引
setCurrentPage(pageCount - 1 - index);
} else {
setCurrentPage(index);
}
},
[descending, pageCount]
);
const handleEpisodeClick = useCallback(
(episodeNumber: number) => {
onChange?.(episodeNumber);
},
[onChange]
);
const handleSourceClick = useCallback(
(source: SearchResult) => {
onSourceChange?.(source.source, source.id, source.title);
},
[onSourceChange]
);
const currentStart = currentPage * episodesPerPage + 1;
const currentEnd = Math.min(
currentStart + episodesPerPage - 1,
totalEpisodes
);
return (
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
{/* 主要的 Tab 切换 - 无缝融入设计 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
{totalEpisodes > 1 && (
<div
onClick={() => setActiveTab('episodes')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'episodes'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
)}
<div
onClick={handleSourceTabClick}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'sources'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
</div>
{/* 选集 Tab 内容 */}
{activeTab === 'episodes' && (
<>
{/* 分类标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
<div className='flex gap-2 min-w-max'>
{categories.map((label, idx) => {
const isActive = idx === displayPage;
return (
<button
key={label}
ref={(el) => {
buttonRefs.current[idx] = el;
}}
onClick={() => handleCategoryClick(idx)}
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
${
isActive
? 'text-green-500 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
}
`.trim()}
>
{label}
{isActive && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
)}
</button>
);
})}
</div>
</div>
{/* 向上/向下按钮 */}
<button
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
onClick={() => {
// 切换集数排序(正序/倒序)
setDescending((prev) => !prev);
}}
>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
/>
</svg>
</button>
</div>
{/* 集数网格 */}
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
{(() => {
const len = currentEnd - currentStart + 1;
const episodes = Array.from({ length: len }, (_, i) =>
descending ? currentEnd - i : currentStart + i
);
return episodes;
})().map((episodeNumber) => {
const isActive = episodeNumber === value;
return (
<button
key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono
${
isActive
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20'
}`.trim()}
>
{(() => {
const title = episodes_titles?.[episodeNumber - 1];
if (!title) {
return episodeNumber;
}
// 如果匹配"第X集"格式,提取中间的数字
const match = title.match(/第(\d+)集/);
if (match) {
return match[1];
}
return title;
})()}
</button>
);
})}
</div>
</>
)}
{/* 换源 Tab 内容 */}
{activeTab === 'sources' && (
<div className='flex flex-col h-full mt-4'>
{sourceSearchLoading && (
<div className='flex items-center justify-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
...
</span>
</div>
)}
{sourceSearchError && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-red-500 text-2xl mb-2'></div>
<p className='text-sm text-red-600 dark:text-red-400'>
{sourceSearchError}
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length === 0 && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-gray-400 text-2xl mb-2'>📺</div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length > 0 && (
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
{availableSources
.sort((a, b) => {
const aIsCurrent =
a.source?.toString() === currentSource?.toString() &&
a.id?.toString() === currentId?.toString();
const bIsCurrent =
b.source?.toString() === currentSource?.toString() &&
b.id?.toString() === currentId?.toString();
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return 0;
})
.map((source, index) => {
const isCurrentSource =
source.source?.toString() === currentSource?.toString() &&
source.id?.toString() === currentId?.toString();
return (
<div
key={`${source.source}-${source.id}`}
onClick={() =>
!isCurrentSource && handleSourceClick(source)
}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${
isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
>
{/* 封面 */}
<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={processImageUrl(source.poster)}
alt={source.title}
className='w-full h-full object-cover'
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
</div>
{/* 信息区域 */}
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
{/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-3 h-6'>
<div className='flex-1 min-w-0 relative group/title'>
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
{source.title}
</h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */}
{index !== 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
{source.title}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
)}
</div>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) {
return (
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
</div>
);
} else {
// 根据分辨率设置不同颜色2K、4K为紫色1080p、720p为绿色其他为黄色
const isUltraHigh = ['4K', '2K'].includes(
videoInfo.quality
);
const isHigh = ['1080p', '720p'].includes(
videoInfo.quality
);
const textColorClasses = isUltraHigh
? 'text-purple-600 dark:text-purple-400'
: isHigh
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400';
return (
<div
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
>
{videoInfo.quality}
</div>
);
}
}
return null;
})()}
</div>
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
{source.source_name}
</span>
{source.episodes.length > 1 && (
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
{source.episodes.length}
</span>
)}
</div>
{/* 网络信息 - 底部 */}
<div className='flex items-end h-6'>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo) {
if (!videoInfo.hasError) {
return (
<div className='flex items-end gap-3 text-xs'>
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
{videoInfo.loadSpeed}
</div>
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
{videoInfo.pingTime}ms
</div>
</div>
);
} else {
return (
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
</div>
); // 占位div
}
}
})()}
</div>
</div>
</div>
);
})}
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
<button
onClick={() => {
if (videoTitle) {
router.push(
`/search?q=${encodeURIComponent(videoTitle)}`
);
}
}}
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
>
</button>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default EpisodeSelector;

View File

@@ -0,0 +1,105 @@
'use client';
import { useEffect, useState } from 'react';
interface ErrorInfo {
id: string;
message: string;
timestamp: number;
}
export function GlobalErrorIndicator() {
const [currentError, setCurrentError] = useState<ErrorInfo | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isReplacing, setIsReplacing] = useState(false);
useEffect(() => {
// 监听自定义错误事件
const handleError = (event: CustomEvent) => {
const { message } = event.detail;
const newError: ErrorInfo = {
id: Date.now().toString(),
message,
timestamp: Date.now(),
};
// 如果已有错误,开始替换动画
if (currentError) {
setCurrentError(newError);
setIsReplacing(true);
// 动画完成后恢复正常
setTimeout(() => {
setIsReplacing(false);
}, 200);
} else {
// 第一次显示错误
setCurrentError(newError);
}
setIsVisible(true);
};
// 监听错误事件
window.addEventListener('globalError', handleError as EventListener);
return () => {
window.removeEventListener('globalError', handleError as EventListener);
};
}, [currentError]);
const handleClose = () => {
setIsVisible(false);
setCurrentError(null);
setIsReplacing(false);
};
if (!isVisible || !currentError) {
return null;
}
return (
<div className='fixed top-4 right-4 z-[2000]'>
{/* 错误卡片 */}
<div
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
} animate-fade-in`}
>
<span className='text-sm font-medium flex-1 mr-3'>
{currentError.message}
</span>
<button
onClick={handleClose}
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
aria-label='关闭错误提示'
>
<svg
className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
</div>
);
}
// 全局错误触发函数
export function triggerGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}

View File

@@ -0,0 +1,40 @@
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
<div
className={`w-full ${aspectRatio} rounded-lg`}
style={{
background:
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
backgroundSize: '200% 100%',
animation: 'shine 1.5s infinite',
}}
>
<style>{`
@keyframes shine {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 亮色模式变量 */
:root {
--skeleton-color: #f0f0f0;
--skeleton-highlight: #e0e0e0;
}
/* 暗色模式变量 */
@media (prefers-color-scheme: dark) {
:root {
--skeleton-color: #2d2d2d;
--skeleton-highlight: #3d3d3d;
}
}
.dark {
--skeleton-color: #2d2d2d;
--skeleton-highlight: #3d3d3d;
}
`}</style>
</div>
);
export { ImagePlaceholder };

View File

@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Cat, Clover, Film, Home, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
interface MobileBottomNavProps {
/**
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
*/
activePath?: string;
}
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
const pathname = usePathname();
// 当前激活路径:优先使用传入的 activePath否则回退到浏览器地址
const currentActive = activePath ?? pathname;
const [navItems, setNavItems] = useState([
{ icon: Home, label: '首页', href: '/' },
{ icon: Search, label: '搜索', href: '/search' },
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
]);
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setNavItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
}, []);
const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive);
const decodedItemHref = decodeURIComponent(href);
return (
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`))
);
};
return (
<nav
className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50'
style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0,
paddingBottom: 'env(safe-area-inset-bottom)',
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
{navItems.map((item) => {
const active = isActive(item.href);
return (
<li
key={item.href}
className='flex-shrink-0'
style={{ width: '20vw', minWidth: '20vw' }}
>
<Link
href={item.href}
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'
>
<item.icon
className={`h-6 w-6 ${
active
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
/>
<span
className={
active
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-gray-300'
}
>
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
};
export default MobileBottomNav;

View File

@@ -0,0 +1,44 @@
'use client';
import Link from 'next/link';
import { BackButton } from './BackButton';
import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
interface MobileHeaderProps {
showBackButton?: boolean;
}
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'>
<div className='h-12 flex items-center justify-between px-4'>
{/* 左侧:返回按钮和设置按钮 */}
<div className='flex items-center gap-2'>
{showBackButton && <BackButton />}
</div>
{/* 右侧按钮 */}
<div className='flex items-center gap-2'>
<ThemeToggle />
<UserMenu />
</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'
>
{siteName}
</Link>
</div>
</header>
);
};
export default MobileHeader;

View File

@@ -0,0 +1,592 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface MultiLevelOption {
label: string;
value: string;
}
interface MultiLevelCategory {
key: string;
label: string;
options: MultiLevelOption[];
multiSelect?: boolean;
}
interface MultiLevelSelectorProps {
onChange: (values: Record<string, string>) => void;
contentType?: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie';
}
const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
onChange,
contentType = 'movie',
}) => {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{
x: number;
y: number;
width: number;
}>({ x: 0, y: 0, width: 0 });
const [values, setValues] = useState<Record<string, string>>({});
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
const dropdownRef = useRef<HTMLDivElement>(null);
// 根据内容类型获取对应的类型选项
const getTypeOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
return [
...baseOptions,
{ label: '喜剧', value: 'comedy' },
{ label: '爱情', value: 'romance' },
{ label: '动作', value: 'action' },
{ label: '科幻', value: 'sci-fi' },
{ label: '悬疑', value: 'suspense' },
{ label: '犯罪', value: 'crime' },
{ label: '惊悚', value: 'thriller' },
{ label: '冒险', value: 'adventure' },
{ label: '音乐', value: 'music' },
{ label: '历史', value: 'history' },
{ label: '奇幻', value: 'fantasy' },
{ label: '恐怖', value: 'horror' },
{ label: '战争', value: 'war' },
{ label: '传记', value: 'biography' },
{ label: '歌舞', value: 'musical' },
{ label: '武侠', value: 'wuxia' },
{ label: '情色', value: 'erotic' },
{ label: '灾难', value: 'disaster' },
{ label: '西部', value: 'western' },
{ label: '纪录片', value: 'documentary' },
{ label: '短片', value: 'short' },
];
case 'tv':
return [
...baseOptions,
{ label: '喜剧', value: 'comedy' },
{ label: '爱情', value: 'romance' },
{ label: '悬疑', value: 'suspense' },
{ label: '武侠', value: 'wuxia' },
{ label: '古装', value: 'costume' },
{ label: '家庭', value: 'family' },
{ label: '犯罪', value: 'crime' },
{ label: '科幻', value: 'sci-fi' },
{ label: '恐怖', value: 'horror' },
{ label: '历史', value: 'history' },
{ label: '战争', value: 'war' },
{ label: '动作', value: 'action' },
{ label: '冒险', value: 'adventure' },
{ label: '传记', value: 'biography' },
{ label: '剧情', value: 'drama' },
{ label: '奇幻', value: 'fantasy' },
{ label: '惊悚', value: 'thriller' },
{ label: '灾难', value: 'disaster' },
{ label: '歌舞', value: 'musical' },
{ label: '音乐', value: 'music' },
];
case 'show':
return [
...baseOptions,
{ label: '真人秀', value: 'reality' },
{ label: '脱口秀', value: 'talkshow' },
{ label: '音乐', value: 'music' },
{ label: '歌舞', value: 'musical' },
];
case 'anime-tv':
case 'anime-movie':
default:
return baseOptions;
}
};
// 根据内容类型获取对应的地区选项
const getRegionOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
case 'anime-movie':
return [
...baseOptions,
{ label: '华语', value: 'chinese' },
{ label: '欧美', value: 'western' },
{ label: '韩国', value: 'korean' },
{ label: '日本', value: 'japanese' },
{ label: '中国大陆', value: 'mainland_china' },
{ label: '美国', value: 'usa' },
{ label: '中国香港', value: 'hong_kong' },
{ label: '中国台湾', value: 'taiwan' },
{ label: '英国', value: 'uk' },
{ label: '法国', value: 'france' },
{ label: '德国', value: 'germany' },
{ label: '意大利', value: 'italy' },
{ label: '西班牙', value: 'spain' },
{ label: '印度', value: 'india' },
{ label: '泰国', value: 'thailand' },
{ label: '俄罗斯', value: 'russia' },
{ label: '加拿大', value: 'canada' },
{ label: '澳大利亚', value: 'australia' },
{ label: '爱尔兰', value: 'ireland' },
{ label: '瑞典', value: 'sweden' },
{ label: '巴西', value: 'brazil' },
{ label: '丹麦', value: 'denmark' },
];
case 'tv':
case 'anime-tv':
case 'show':
return [
...baseOptions,
{ label: '华语', value: 'chinese' },
{ label: '欧美', value: 'western' },
{ label: '国外', value: 'foreign' },
{ label: '韩国', value: 'korean' },
{ label: '日本', value: 'japanese' },
{ label: '中国大陆', value: 'mainland_china' },
{ label: '中国香港', value: 'hong_kong' },
{ label: '美国', value: 'usa' },
{ label: '英国', value: 'uk' },
{ label: '泰国', value: 'thailand' },
{ label: '中国台湾', value: 'taiwan' },
{ label: '意大利', value: 'italy' },
{ label: '法国', value: 'france' },
{ label: '德国', value: 'germany' },
{ label: '西班牙', value: 'spain' },
{ label: '俄罗斯', value: 'russia' },
{ label: '瑞典', value: 'sweden' },
{ label: '巴西', value: 'brazil' },
{ label: '丹麦', value: 'denmark' },
{ label: '印度', value: 'india' },
{ label: '加拿大', value: 'canada' },
{ label: '爱尔兰', value: 'ireland' },
{ label: '澳大利亚', value: 'australia' },
];
default:
return baseOptions;
}
};
const getLabelOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'anime-movie':
return [
...baseOptions,
{ label: '定格动画', value: 'stop_motion' },
{ label: '传记', value: 'biography' },
{ label: '美国动画', value: 'us_animation' },
{ label: '爱情', value: 'romance' },
{ label: '黑色幽默', value: 'dark_humor' },
{ label: '歌舞', value: 'musical' },
{ label: '儿童', value: 'children' },
{ label: '二次元', value: 'anime' },
{ label: '动物', value: 'animal' },
{ label: '青春', value: 'youth' },
{ label: '历史', value: 'history' },
{ label: '励志', value: 'inspirational' },
{ label: '恶搞', value: 'parody' },
{ label: '治愈', value: 'healing' },
{ label: '运动', value: 'sports' },
{ label: '后宫', value: 'harem' },
{ label: '情色', value: 'erotic' },
{ label: '人性', value: 'human_nature' },
{ label: '悬疑', value: 'suspense' },
{ label: '恋爱', value: 'love' },
{ label: '魔幻', value: 'fantasy' },
{ label: '科幻', value: 'sci_fi' },
];
case 'anime-tv':
return [
...baseOptions,
{ label: '黑色幽默', value: 'dark_humor' },
{ label: '历史', value: 'history' },
{ label: '歌舞', value: 'musical' },
{ label: '励志', value: 'inspirational' },
{ label: '恶搞', value: 'parody' },
{ label: '治愈', value: 'healing' },
{ label: '运动', value: 'sports' },
{ label: '后宫', value: 'harem' },
{ label: '情色', value: 'erotic' },
{ label: '国漫', value: 'chinese_anime' },
{ label: '人性', value: 'human_nature' },
{ label: '悬疑', value: 'suspense' },
{ label: '恋爱', value: 'love' },
{ label: '魔幻', value: 'fantasy' },
{ label: '科幻', value: 'sci_fi' },
];
default:
return baseOptions;
}
};
// 根据内容类型获取对应的平台选项
const getPlatformOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
return baseOptions; // 电影不需要平台选项
case 'tv':
case 'anime-tv':
case 'show':
return [
...baseOptions,
{ label: '腾讯视频', value: 'tencent' },
{ label: '爱奇艺', value: 'iqiyi' },
{ label: '优酷', value: 'youku' },
{ label: '湖南卫视', value: 'hunan_tv' },
{ label: 'Netflix', value: 'netflix' },
{ label: 'HBO', value: 'hbo' },
{ label: 'BBC', value: 'bbc' },
{ label: 'NHK', value: 'nhk' },
{ label: 'CBS', value: 'cbs' },
{ label: 'NBC', value: 'nbc' },
{ label: 'tvN', value: 'tvn' },
];
default:
return baseOptions;
}
};
// 分类配置
const categories: MultiLevelCategory[] = [
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
? [
{
key: 'type',
label: '类型',
options: getTypeOptions(contentType),
},
]
: [
{
key: 'label',
label: '类型',
options: getLabelOptions(contentType),
},
]),
{
key: 'region',
label: '地区',
options: getRegionOptions(contentType),
},
{
key: 'year',
label: '年代',
options: [
{ label: '全部', value: 'all' },
{ label: '2020年代', value: '2020s' },
{ label: '2025', value: '2025' },
{ label: '2024', value: '2024' },
{ label: '2023', value: '2023' },
{ label: '2022', value: '2022' },
{ label: '2021', value: '2021' },
{ label: '2020', value: '2020' },
{ label: '2019', value: '2019' },
{ label: '2010年代', value: '2010s' },
{ label: '2000年代', value: '2000s' },
{ label: '90年代', value: '1990s' },
{ label: '80年代', value: '1980s' },
{ label: '70年代', value: '1970s' },
{ label: '60年代', value: '1960s' },
{ label: '更早', value: 'earlier' },
],
},
// 只在电视剧和综艺时显示平台选项
...(contentType === 'tv' ||
contentType === 'show' ||
contentType === 'anime-tv'
? [
{
key: 'platform',
label: '平台',
options: getPlatformOptions(contentType),
},
]
: []),
{
key: 'sort',
label: '排序',
options: [
{ label: '综合排序', value: 'T' },
{ label: '近期热度', value: 'U' },
{
label:
contentType === 'tv' || contentType === 'show'
? '首播时间'
: '首映时间',
value: 'R',
},
{ label: '高分优先', value: 'S' },
],
},
];
// 计算下拉框位置
const calculateDropdownPosition = (categoryKey: string) => {
const element = categoryRefs.current[categoryKey];
if (element) {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth < 768; // md breakpoint
let x = rect.left;
let dropdownWidth = Math.max(rect.width, 300);
let useFixedWidth = false; // 标记是否使用固定宽度
// 移动端优化:防止下拉框被右侧视口截断
if (isMobile) {
const padding = 16; // 左右各留16px的边距
const maxWidth = viewportWidth - padding * 2;
dropdownWidth = Math.min(dropdownWidth, maxWidth);
useFixedWidth = true; // 移动端使用固定宽度
// 如果右侧超出视口则调整x位置
if (x + dropdownWidth > viewportWidth - padding) {
x = viewportWidth - dropdownWidth - padding;
}
// 如果左侧超出视口,则贴左边
if (x < padding) {
x = padding;
}
}
setDropdownPosition({
x,
y: rect.bottom,
width: useFixedWidth ? dropdownWidth : rect.width, // PC端保持原有逻辑
});
}
};
// 处理分类点击
const handleCategoryClick = (categoryKey: string) => {
if (activeCategory === categoryKey) {
setActiveCategory(null);
} else {
setActiveCategory(categoryKey);
calculateDropdownPosition(categoryKey);
}
};
// 处理选项选择
const handleOptionSelect = (categoryKey: string, optionValue: string) => {
// 更新本地状态
const newValues = {
...values,
[categoryKey]: optionValue,
};
// 更新内部状态
setValues(newValues);
// 构建传递给父组件的值,排序传递 value其他传递 label
const selectionsForParent: Record<string, string> = {
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
};
Object.entries(newValues).forEach(([key, value]) => {
if (value && value !== 'all' && (key !== 'sort' || value !== 'T')) {
const category = categories.find((cat) => cat.key === key);
if (category) {
const option = category.options.find((opt) => opt.value === value);
if (option) {
// 排序传递 value其他传递 label
selectionsForParent[key] =
key === 'sort' ? option.value : option.label;
}
}
}
});
// 调用父组件的回调,传递处理后的选择值
onChange(selectionsForParent);
setActiveCategory(null);
};
// 获取显示文本
const getDisplayText = (categoryKey: string) => {
const category = categories.find((cat) => cat.key === categoryKey);
if (!category) return '';
const value = values[categoryKey];
if (
!value ||
value === 'all' ||
(categoryKey === 'sort' && value === 'T')
) {
return category.label;
}
const option = category.options.find((opt) => opt.value === value);
return option?.label || category.label;
};
// 检查是否为默认值
const isDefaultValue = (categoryKey: string) => {
const value = values[categoryKey];
return (
!value || value === 'all' || (categoryKey === 'sort' && value === 'T')
);
};
// 检查选项是否被选中
const isOptionSelected = (categoryKey: string, optionValue: string) => {
let value = values[categoryKey];
if (value === undefined) {
value = 'all';
if (categoryKey === 'sort') {
value = 'T';
}
}
return value === optionValue;
};
// 监听滚动和窗口大小变化事件,重新计算位置
useEffect(() => {
const handleScroll = () => {
if (activeCategory) {
calculateDropdownPosition(activeCategory);
}
};
const handleResize = () => {
if (activeCategory) {
calculateDropdownPosition(activeCategory);
}
};
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [activeCategory]);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!Object.values(categoryRefs.current).some(
(ref) => ref && ref.contains(event.target as Node)
)
) {
setActiveCategory(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<>
{/* 胶囊样式筛选栏 */}
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
{categories.map((category) => (
<div
key={category.key}
ref={(el) => {
categoryRefs.current[category.key] = el;
}}
className='relative'
>
<button
onClick={() => handleCategoryClick(category.key)}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
activeCategory === category.key
? isDefaultValue(category.key)
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-green-600 dark:text-green-400 cursor-default'
: isDefaultValue(category.key)
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
}`}
>
<span>{getDisplayText(category.key)}</span>
<svg
className={`inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200 ${
activeCategory === category.key ? 'rotate-180' : ''
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</button>
</div>
))}
</div>
{/* 展开的筛选选项 - 悬浮显示 */}
{activeCategory &&
createPortal(
<div
ref={dropdownRef}
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm'
style={{
left: `${dropdownPosition.x}px`,
top: `${dropdownPosition.y}px`,
...(window.innerWidth < 768
? { width: `${dropdownPosition.width}px` } // 移动端使用固定宽度
: { minWidth: `${Math.max(dropdownPosition.width, 300)}px` }), // PC端使用最小宽度
maxWidth: '600px',
position: 'fixed',
}}
>
<div className='p-2 sm:p-4'>
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1 sm:gap-2'>
{categories
.find((cat) => cat.key === activeCategory)
?.options.map((option) => (
<button
key={option.value}
onClick={() =>
handleOptionSelect(activeCategory, option.value)
}
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${
isOptionSelected(activeCategory, option.value)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>,
document.body
)}
</>
);
};
export default MultiLevelSelector;

View File

@@ -0,0 +1,61 @@
import { BackButton } from './BackButton';
import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader';
import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
interface PageLayoutProps {
children: React.ReactNode;
activePath?: string;
}
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
return (
<div className='w-full min-h-screen'>
{/* 移动端头部 */}
<MobileHeader showBackButton={['/play'].includes(activePath)} />
{/* 主要布局容器 */}
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
{/* 侧边栏 - 桌面端显示,移动端隐藏 */}
<div className='hidden md:block'>
<Sidebar activePath={activePath} />
</div>
{/* 主内容区域 */}
<div className='relative min-w-0 flex-1 transition-all duration-300'>
{/* 桌面端左上角返回按钮 */}
{['/play'].includes(activePath) && (
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
<BackButton />
</div>
)}
{/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<ThemeToggle />
<UserMenu />
</div>
{/* 主内容 */}
<main
className='flex-1 md:min-h-0 mb-14 md:mb-0'
style={{
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
{children}
</main>
</div>
</div>
{/* 移动端底部导航 */}
<div className='md:hidden'>
<MobileBottomNav activePath={activePath} />
</div>
</div>
);
};
export default PageLayout;

View File

@@ -0,0 +1,169 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
interface ScrollableRowProps {
children: React.ReactNode;
scrollDistance?: number;
}
export default function ScrollableRow({
children,
scrollDistance = 1000,
}: ScrollableRowProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [showLeftScroll, setShowLeftScroll] = useState(false);
const [showRightScroll, setShowRightScroll] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const checkScroll = () => {
if (containerRef.current) {
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
// 计算是否需要左右滚动按钮
const threshold = 1; // 容差值,避免浮点误差
const canScrollRight =
scrollWidth - (scrollLeft + clientWidth) > threshold;
const canScrollLeft = scrollLeft > threshold;
setShowRightScroll(canScrollRight);
setShowLeftScroll(canScrollLeft);
}
};
useEffect(() => {
// 多次延迟检查,确保内容已完全渲染
checkScroll();
// 监听窗口大小变化
window.addEventListener('resize', checkScroll);
// 创建一个 ResizeObserver 来监听容器大小变化
const resizeObserver = new ResizeObserver(() => {
// 延迟执行检查
checkScroll();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
window.removeEventListener('resize', checkScroll);
resizeObserver.disconnect();
};
}, [children]); // 依赖 children当子组件变化时重新检查
// 添加一个额外的效果来监听子组件的变化
useEffect(() => {
if (containerRef.current) {
// 监听 DOM 变化
const observer = new MutationObserver(() => {
setTimeout(checkScroll, 100);
});
observer.observe(containerRef.current, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});
return () => observer.disconnect();
}
}, []);
const handleScrollRightClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({
left: scrollDistance,
behavior: 'smooth',
});
}
};
const handleScrollLeftClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({
left: -scrollDistance,
behavior: 'smooth',
});
}
};
return (
<div
className='relative'
onMouseEnter={() => {
setIsHovered(true);
// 当鼠标进入时重新检查一次
checkScroll();
}}
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6'
onScroll={checkScroll}
>
{children}
</div>
{showLeftScroll && (
<div
className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}}
>
<div
className='absolute inset-0 flex items-center justify-center'
style={{
top: '40%',
bottom: '60%',
left: '-4.5rem',
pointerEvents: 'auto',
}}
>
<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 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
>
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' />
</button>
</div>
</div>
)}
{showRightScroll && (
<div
className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}}
>
<div
className='absolute inset-0 flex items-center justify-center'
style={{
top: '40%',
bottom: '60%',
right: '-4.5rem',
pointerEvents: 'auto',
}}
>
<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 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
>
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' />
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SearchSuggestionsProps {
query: string;
isVisible: boolean;
onSelect: (suggestion: string) => void;
onClose: () => void;
}
interface SuggestionItem {
text: string;
type: 'related';
icon?: React.ReactNode;
}
export default function SearchSuggestions({
query,
isVisible,
onSelect,
onClose,
}: SearchSuggestionsProps) {
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
// 防抖定时器
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 用于中止旧请求
const abortControllerRef = useRef<AbortController | null>(null);
const fetchSuggestionsFromAPI = useCallback(async (searchQuery: string) => {
// 每次请求前取消上一次的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const response = await fetch(
`/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`,
{
signal: controller.signal,
}
);
if (response.ok) {
const data = await response.json();
const apiSuggestions = data.suggestions.map(
(item: { text: string }) => ({
text: item.text,
type: 'related' as const,
})
);
setSuggestions(apiSuggestions);
setSelectedIndex(-1);
}
} catch (err: unknown) {
// 类型保护判断 err 是否是 Error 类型
if (err instanceof Error) {
if (err.name !== 'AbortError') {
// 不是取消请求导致的错误才清空
setSuggestions([]);
setSelectedIndex(-1);
}
} else {
// 如果 err 不是 Error 类型,也清空提示
setSuggestions([]);
setSelectedIndex(-1);
}
}
}, []);
// 防抖触发
const debouncedFetchSuggestions = useCallback(
(searchQuery: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
if (searchQuery.trim() && isVisible) {
fetchSuggestionsFromAPI(searchQuery);
} else {
setSuggestions([]);
setSelectedIndex(-1);
}
}, 300); //300ms
},
[isVisible, fetchSuggestionsFromAPI]
);
useEffect(() => {
if (!query.trim() || !isVisible) {
setSuggestions([]);
setSelectedIndex(-1);
return;
}
debouncedFetchSuggestions(query);
// 清理定时器
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [query, isVisible, debouncedFetchSuggestions]);
// 键盘导航
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisible || suggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
onSelect(suggestions[selectedIndex].text);
} else {
onSelect(query);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isVisible, onClose]);
if (!isVisible || suggestions.length === 0) {
return null;
}
return (
<div
ref={containerRef}
className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
>
{suggestions.map((suggestion, index) => (
<button
key={`related-${suggestion.text}`}
onClick={() => onSelect(suggestion.text)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3 ${
selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
>
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
{suggestion.text}
</span>
</button>
))}
</div>
);
}

293
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,293 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Cat, Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { useSite } from './SiteProvider';
interface SidebarContextType {
isCollapsed: boolean;
}
const SidebarContext = createContext<SidebarContextType>({
isCollapsed: false,
});
export const useSidebar = () => useContext(SidebarContext);
// 可替换为你自己的 logo 图片
const Logo = () => {
const { siteName } = useSite();
return (
<Link
href='/'
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
>
<span className='text-2xl font-bold text-green-600 tracking-tight'>
{siteName}
</span>
</Link>
);
};
interface SidebarProps {
onToggle?: (collapsed: boolean) => void;
activePath?: string;
}
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
declare global {
interface Window {
__sidebarCollapsed?: boolean;
}
}
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (
typeof window !== 'undefined' &&
typeof window.__sidebarCollapsed === 'boolean'
) {
return window.__sidebarCollapsed;
}
return false; // 默认展开
});
// 首次挂载时读取 localStorage以便刷新后仍保持上次的折叠状态
useLayoutEffect(() => {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
const val = JSON.parse(saved);
setIsCollapsed(val);
window.__sidebarCollapsed = val;
}
}, []);
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
useLayoutEffect(() => {
if (typeof document !== 'undefined') {
if (isCollapsed) {
document.documentElement.dataset.sidebarCollapsed = 'true';
} else {
delete document.documentElement.dataset.sidebarCollapsed;
}
}
}, [isCollapsed]);
const [active, setActive] = useState(activePath);
useEffect(() => {
// 优先使用传入的 activePath
if (activePath) {
setActive(activePath);
} else {
// 否则使用当前路径
const getCurrentFullPath = () => {
const queryString = searchParams.toString();
return queryString ? `${pathname}?${queryString}` : pathname;
};
const fullPath = getCurrentFullPath();
setActive(fullPath);
}
}, [activePath, pathname, searchParams]);
const handleToggle = useCallback(() => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
if (typeof window !== 'undefined') {
window.__sidebarCollapsed = newState;
}
onToggle?.(newState);
}, [isCollapsed, onToggle]);
const handleSearchClick = useCallback(() => {
router.push('/search');
}, [router]);
const contextValue = {
isCollapsed,
};
const [menuItems, setMenuItems] = useState([
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
]);
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setMenuItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
}, []);
return (
<SidebarContext.Provider value={contextValue}>
{/* 在移动端隐藏侧边栏 */}
<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 dark:bg-gray-900/70 dark:border-gray-700/50 ${
isCollapsed ? 'w-16' : 'w-64'
}`}
style={{
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16'>
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<div className='w-[calc(100%-4rem)] flex justify-center'>
{!isCollapsed && <Logo />}
</div>
</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 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'
}`}
>
<Menu className='h-4 w-4' />
</button>
</div>
{/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'>
<Link
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] 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 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'>
</span>
)}
</Link>
<Link
href='/search'
onClick={(e) => {
e.preventDefault();
handleSearchClick();
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] 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 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'>
</span>
)}
</Link>
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'>
<div className='space-y-1'>
{menuItems.map((item) => {
// 检查当前路径是否匹配这个菜单项
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(active);
const decodedItemHref = decodeURIComponent(item.href);
const isActive =
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`));
const Icon = item.icon;
return (
<Link
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-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'>
<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'>
{item.label}
</span>
)}
</Link>
);
})}
</div>
</div>
</div>
</aside>
<div
className={`transition-all duration-300 sidebar-offset ${
isCollapsed ? 'w-16' : 'w-64'
}`}
></div>
</div>
</SidebarContext.Provider>
);
};
export default Sidebar;

View File

@@ -0,0 +1,28 @@
'use client';
import { createContext, ReactNode, useContext } from 'react';
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
// 默认值
siteName: 'MoonTV',
announcement:
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
});
export const useSite = () => useContext(SiteContext);
export function SiteProvider({
children,
siteName,
announcement,
}: {
children: ReactNode;
siteName: string;
announcement?: string;
}) {
return (
<SiteContext.Provider value={{ siteName, announcement }}>
{children}
</SiteContext.Provider>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import type { ThemeProviderProps } from 'next-themes';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import * as React from 'react';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
enableSystem
{...props}
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,70 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
'use client';
import { Moon, Sun } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { setTheme, resolvedTheme } = useTheme();
const pathname = usePathname();
const setThemeColor = (theme?: string) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
const meta = document.createElement('meta');
meta.name = 'theme-color';
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe';
document.head.appendChild(meta);
} else {
meta.setAttribute('content', theme === 'dark' ? '#0c111c' : '#f9fbfe');
}
};
useEffect(() => {
setMounted(true);
}, []);
// 监听主题变化和路由变化,确保主题色始终同步
useEffect(() => {
if (mounted) {
setThemeColor(resolvedTheme);
}
}, [mounted, resolvedTheme, pathname]);
if (!mounted) {
// 渲染一个占位符以避免布局偏移
return <div className='w-10 h-10' />;
}
const toggleTheme = () => {
// 检查浏览器是否支持 View Transitions API
const targetTheme = resolvedTheme === 'dark' ? 'light' : 'dark';
setThemeColor(targetTheme);
if (!(document as any).startViewTransition) {
setTheme(targetTheme);
return;
}
(document as any).startViewTransition(() => {
setTheme(targetTheme);
});
};
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'
>
{resolvedTheme === 'dark' ? (
<Sun className='w-full h-full' />
) : (
<Moon className='w-full h-full' />
)}
</button>
);
}

972
src/components/UserMenu.tsx Normal file
View File

@@ -0,0 +1,972 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
'use client';
import {
Check,
ChevronDown,
ExternalLink,
KeyRound,
LogOut,
Settings,
Shield,
User,
X,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
import { VersionPanel } from './VersionPanel';
interface AuthInfo {
username?: string;
role?: 'owner' | 'admin' | 'user';
}
export const UserMenu: React.FC = () => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [doubanDataSource, setDoubanDataSource] = useState('direct');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('direct');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
// 豆瓣数据源选项
const doubanDataSourceOptions = [
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss腾讯云',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss阿里云' },
{ value: 'cors-anywhere', label: 'Cors Anywhere20 qpm' },
{ value: 'custom', label: '自定义代理' },
];
// 豆瓣图片代理选项
const doubanImageProxyTypeOptions = [
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
{ value: 'img3', label: '豆瓣精品 CDN阿里云' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss腾讯云',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss阿里云' },
{ value: 'custom', label: '自定义代理' },
];
// 修改密码相关状态
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
// 版本检查相关状态
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 获取认证信息和存储类型
useEffect(() => {
if (typeof window !== 'undefined') {
const auth = getAuthInfoFromBrowserCookie();
setAuthInfo(auth);
const type =
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
setStorageType(type);
}
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanDataSource = localStorage.getItem('doubanDataSource');
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
if (savedDoubanDataSource !== null) {
setDoubanDataSource(savedDoubanDataSource);
} else if (defaultDoubanProxyType) {
setDoubanDataSource(defaultDoubanProxyType);
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
} else if (defaultDoubanProxy) {
setDoubanProxyUrl(defaultDoubanProxy);
}
const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType'
);
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
if (savedDoubanImageProxyType !== null) {
setDoubanImageProxyType(savedDoubanImageProxyType);
} else if (defaultDoubanImageProxyType) {
setDoubanImageProxyType(defaultDoubanImageProxyType);
}
const savedDoubanImageProxyUrl = localStorage.getItem(
'doubanImageProxyUrl'
);
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
if (savedDoubanImageProxyUrl !== null) {
setDoubanImageProxyUrl(savedDoubanImageProxyUrl);
} else if (defaultDoubanImageProxyUrl) {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
}
}, []);
// 版本检查
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (error) {
console.warn('版本检查失败:', error);
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource"]')) {
setIsDoubanDropdownOpen(false);
}
}
};
if (isDoubanDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
setIsDoubanImageProxyDropdownOpen(false);
}
}
};
if (isDoubanImageProxyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyDropdownOpen]);
const handleMenuClick = () => {
setIsOpen(!isOpen);
};
const handleCloseMenu = () => {
setIsOpen(false);
};
const handleLogout = async () => {
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
window.location.href = '/';
};
const handleAdminPanel = () => {
router.push('/admin');
};
const handleChangePassword = () => {
setIsOpen(false);
setIsChangePasswordOpen(true);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handleCloseChangePassword = () => {
setIsChangePasswordOpen(false);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handleSubmitChangePassword = async () => {
setPasswordError('');
// 验证密码
if (!newPassword) {
setPasswordError('新密码不得为空');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的密码不一致');
return;
}
setPasswordLoading(true);
try {
const response = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
setPasswordError(data.error || '修改密码失败');
return;
}
// 修改成功,关闭弹窗并登出
setIsChangePasswordOpen(false);
await handleLogout();
} catch (error) {
setPasswordError('网络错误,请稍后重试');
} finally {
setPasswordLoading(false);
}
};
const handleSettings = () => {
setIsOpen(false);
setIsSettingsOpen(true);
};
const handleCloseSettings = () => {
setIsSettingsOpen(false);
};
// 设置相关的处理函数
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 handleOptimizationToggle = (value: boolean) => {
setEnableOptimization(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableOptimization', JSON.stringify(value));
}
};
const handleDoubanDataSourceChange = (value: string) => {
setDoubanDataSource(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSource', value);
}
};
const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyType', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrl', value);
}
};
// 获取感谢信息
const getThanksInfo = (dataSource: string) => {
switch (dataSource) {
case 'cors-proxy-zwei':
return {
text: 'Thanks to @Zwei',
url: 'https://github.com/bestzwei',
};
case 'cmliussss-cdn-tencent':
case 'cmliussss-cdn-ali':
return {
text: 'Thanks to @CMLiussss',
url: 'https://github.com/cmliu',
};
default:
return null;
}
};
const handleResetSettings = () => {
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
}
};
// 检查是否显示管理面板按钮
const showAdminPanel =
authInfo?.role === 'owner' || authInfo?.role === 'admin';
// 检查是否显示修改密码按钮
const showChangePassword =
authInfo?.role !== 'owner' && storageType !== 'localstorage';
// 角色中文映射
const getRoleText = (role?: string) => {
switch (role) {
case 'owner':
return '站长';
case 'admin':
return '管理员';
case 'user':
return '用户';
default:
return '';
}
};
// 菜单面板内容
const menuPanel = (
<>
{/* 背景遮罩 - 普通菜单无需模糊 */}
<div
className='fixed inset-0 bg-transparent z-[1000]'
onClick={handleCloseMenu}
/>
{/* 菜单面板 */}
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
{/* 用户信息区域 */}
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: (authInfo?.role || 'user') === 'admin'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{getRoleText(authInfo?.role || 'user')}
</span>
</div>
<div className='flex items-center justify-between'>
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
{authInfo?.username || 'default'}
</div>
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
{storageType === 'localstorage' ? '本地' : storageType}
</div>
</div>
</div>
</div>
{/* 菜单项 */}
<div className='py-1'>
{/* 设置按钮 */}
<button
onClick={handleSettings}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
{/* 管理面板按钮 */}
{showAdminPanel && (
<button
onClick={handleAdminPanel}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
)}
{/* 修改密码按钮 */}
{showChangePassword && (
<button
onClick={handleChangePassword}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
)}
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 登出按钮 */}
<button
onClick={handleLogout}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
>
<LogOut className='w-4 h-4' />
<span className='font-medium'></span>
</button>
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 版本信息 */}
<button
onClick={() => {
setIsVersionPanelOpen(true);
handleCloseMenu();
}}
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs'
>
<div className='flex items-center gap-1'>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking &&
updateStatus &&
updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`w-2 h-2 rounded-full -translate-y-2 ${
updateStatus === UpdateStatus.HAS_UPDATE
? 'bg-yellow-500'
: updateStatus === UpdateStatus.NO_UPDATE
? 'bg-green-400'
: ''
}`}
></div>
)}
</div>
</button>
</div>
</div>
</>
);
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseSettings}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6 overflow-y-auto'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
</button>
</div>
<button
onClick={handleCloseSettings}
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='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'>
</p>
</div>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSource
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanDataSource === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanDataSource) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanDataSource === 'custom' && (
<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'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></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'>
</p>
</div>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanImageProxyType === 'custom' && (
<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'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl}
onChange={(e) =>
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 默认聚合搜索结果 */}
<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='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={enableOptimization}
onChange={(e) => handleOptimizationToggle(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>
{/* 底部说明 */}
<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>
</>
);
// 修改密码面板内容
const changePasswordPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseChangePassword}
/>
{/* 修改密码面板 */}
<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-[1001] 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={handleCloseChangePassword}
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-4'>
{/* 新密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请输入新密码'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 确认密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请再次输入新密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 错误信息 */}
{passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
{passwordError}
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
disabled={passwordLoading}
>
</button>
<button
onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
disabled={passwordLoading || !newPassword || !confirmPassword}
>
{passwordLoading ? '修改中...' : '确认修改'}
</button>
</div>
{/* 底部说明 */}
<div className='mt-4 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 (
<>
<div className='relative'>
<button
onClick={handleMenuClick}
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='User Menu'
>
<User className='w-full h-full' />
</button>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
)}
</div>
{/* 使用 Portal 将菜单面板渲染到 document.body */}
{isOpen && mounted && createPortal(menuPanel, document.body)}
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
{isChangePasswordOpen &&
mounted &&
createPortal(changePasswordPanel, document.body)}
{/* 版本面板 */}
<VersionPanel
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
/>
</>
);
};

View File

@@ -0,0 +1,520 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps */
'use client';
import {
Bug,
CheckCircle,
ChevronDown,
ChevronUp,
Download,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { changelog, ChangelogEntry } from '@/lib/changelog';
import { compareVersions, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
interface VersionPanelProps {
isOpen: boolean;
onClose: () => void;
}
interface RemoteChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const VersionPanel: React.FC<VersionPanelProps> = ({
isOpen,
onClose,
}) => {
const [mounted, setMounted] = useState(false);
const [remoteChangelog, setRemoteChangelog] = useState<ChangelogEntry[]>([]);
const [hasUpdate, setIsHasUpdate] = useState(false);
const [latestVersion, setLatestVersion] = useState<string>('');
const [showRemoteContent, setShowRemoteContent] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// 获取远程变更日志
useEffect(() => {
if (isOpen) {
fetchRemoteChangelog();
}
}, [isOpen]);
// 获取远程变更日志
const fetchRemoteChangelog = async () => {
try {
const response = await fetch(
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/CHANGELOG'
);
if (response.ok) {
const content = await response.text();
const parsed = parseChangelog(content);
setRemoteChangelog(parsed);
// 检查是否有更新
if (parsed.length > 0) {
const latest = parsed[0];
setLatestVersion(latest.version);
setIsHasUpdate(
compareVersions(latest.version) === UpdateStatus.HAS_UPDATE
);
}
} else {
console.error(
'获取远程变更日志失败:',
response.status,
response.statusText
);
}
} catch (error) {
console.error('获取远程变更日志失败:', error);
}
};
// 解析变更日志格式
const parseChangelog = (content: string): RemoteChangelogEntry[] => {
const lines = content.split('\n');
const versions: RemoteChangelogEntry[] = [];
let currentVersion: RemoteChangelogEntry | null = null;
let currentSection: string | null = null;
let inVersionContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = trimmedLine.match(
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
);
if (versionMatch) {
if (currentVersion) {
versions.push(currentVersion);
}
currentVersion = {
version: versionMatch[1],
date: versionMatch[2],
added: [],
changed: [],
fixed: [],
};
currentSection = null;
inVersionContent = true;
continue;
}
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
if (inVersionContent && currentVersion) {
// 匹配章节标题
if (trimmedLine === '### Added') {
currentSection = 'added';
continue;
} else if (trimmedLine === '### Changed') {
currentSection = 'changed';
continue;
} else if (trimmedLine === '### Fixed') {
currentSection = 'fixed';
continue;
}
// 匹配条目: - 内容
if (trimmedLine.startsWith('- ') && currentSection) {
const entry = trimmedLine.substring(2);
if (currentSection === 'added') {
currentVersion.added.push(entry);
} else if (currentSection === 'changed') {
currentVersion.changed.push(entry);
} else if (currentSection === 'fixed') {
currentVersion.fixed.push(entry);
}
}
}
}
// 添加最后一个版本
if (currentVersion) {
versions.push(currentVersion);
}
return versions;
};
// 渲染变更日志条目
const renderChangelogEntry = (
entry: ChangelogEntry | RemoteChangelogEntry,
isCurrentVersion = false,
isRemote = false
) => {
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
return (
<div
key={entry.version}
className={`p-4 rounded-lg border ${
isCurrentVersion
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: isUpdate
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
>
{/* 版本标题 */}
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
v{entry.version}
</h4>
{isCurrentVersion && (
<span className='px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
</span>
)}
{isUpdate && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Download className='w-3 h-3' />
</span>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
{entry.date}
</div>
</div>
{/* 变更内容 */}
<div className='space-y-3'>
{entry.added.length > 0 && (
<div>
<h5 className='text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1'>
<Plus className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.added.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.changed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-blue-700 dark:text-blue-400 mb-2 flex items-center gap-1'>
<RefreshCw className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.changed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<Bug className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.fixed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
// 版本面板内容
const versionPanelContent = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
/>
{/* 版本面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
{/* 标题栏 */}
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2 sm:gap-3'>
<h3 className='text-lg sm:text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<div className='flex flex-wrap items-center gap-1 sm:gap-2'>
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full'>
v{CURRENT_VERSION}
</span>
{hasUpdate && (
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
<span className='hidden sm:inline'></span>
<span className='sm:hidden'></span>
</span>
)}
</div>
</div>
<button
onClick={onClose}
className='w-6 h-6 sm:w-8 sm: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='关闭'
>
<X className='w-full h-full' />
</button>
</div>
{/* 内容区域 */}
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
<div className='space-y-3 sm:space-y-6'>
{/* 远程更新信息 */}
{hasUpdate && (
<div className='bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
</h4>
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
v{CURRENT_VERSION} v{latestVersion}
</p>
</div>
</div>
<a
href='https://github.com/LunaTechLab/MoonTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
{/* 当前为最新版本信息 */}
{!hasUpdate && (
<div className='bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-green-100 dark:bg-green-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<CheckCircle className='w-4 h-4 sm:w-5 sm:h-5 text-green-600 dark:text-green-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-green-800 dark:text-green-200'>
</h4>
<p className='text-xs sm:text-sm text-green-700 dark:text-green-300 break-all'>
v{CURRENT_VERSION}
</p>
</div>
</div>
<a
href='https://github.com/LunaTechLab/MoonTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<CheckCircle className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
{/* 远程可更新内容 */}
{hasUpdate && (
<div className='space-y-4'>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
<Download className='w-5 h-5 text-yellow-500' />
</h4>
<button
onClick={() => setShowRemoteContent(!showRemoteContent)}
className='inline-flex items-center justify-center gap-2 px-3 py-1.5 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/50 dark:text-yellow-200 rounded-lg transition-colors text-sm w-full sm:w-auto'
>
{showRemoteContent ? (
<>
<ChevronUp className='w-4 h-4' />
</>
) : (
<>
<ChevronDown className='w-4 h-4' />
</>
)}
</button>
</div>
{showRemoteContent && remoteChangelog.length > 0 && (
<div className='space-y-4'>
{remoteChangelog
.filter((entry) => {
// 找到第一个本地版本,过滤掉本地已有的版本
const localVersions = changelog.map(
(local) => local.version
);
return !localVersions.includes(entry.version);
})
.map((entry, index) => (
<div
key={index}
className={`p-4 rounded-lg border ${
entry.version === latestVersion
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
v{entry.version}
</h4>
{entry.version === latestVersion && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
</span>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
{entry.date}
</div>
</div>
{entry.added && entry.added.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-green-600 dark:text-green-400 mb-2 flex items-center gap-1'>
<Plus className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.added.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-green-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.changed && entry.changed.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-blue-600 dark:text-blue-400 mb-2 flex items-center gap-1'>
<RefreshCw className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.changed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.fixed && entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<Bug className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.fixed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* 变更日志标题 */}
<div className='border-b border-gray-200 dark:border-gray-700 pb-4'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 pb-3 sm:pb-4'>
</h4>
<div className='space-y-4'>
{/* 本地变更日志 */}
{changelog.map((entry) =>
renderChangelogEntry(
entry,
entry.version === CURRENT_VERSION,
false
)
)}
</div>
</div>
</div>
</div>
</div>
</>
);
// 使用 Portal 渲染到 document.body
if (!mounted || !isOpen) return null;
return createPortal(versionPanelContent, document.body);
};

View File

@@ -0,0 +1,403 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
deleteFavorite,
deletePlayRecord,
generateStorageKey,
isFavorited,
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { processImageUrl } from '@/lib/utils';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
interface VideoCardProps {
id?: string;
source?: string;
title?: string;
query?: string;
poster?: string;
episodes?: number;
source_name?: string;
progress?: number;
year?: string;
from: 'playrecord' | 'favorite' | 'search' | 'douban';
currentEpisode?: number;
douban_id?: number;
onDelete?: () => void;
rate?: string;
items?: SearchResult[];
type?: string;
isBangumi?: boolean;
}
export default function VideoCard({
id,
title = '',
query = '',
poster = '',
episodes,
source,
source_name,
progress = 0,
year,
from,
currentEpisode,
douban_id,
onDelete,
rate,
items,
type = '',
isBangumi = false,
}: VideoCardProps) {
const router = useRouter();
const [favorited, setFavorited] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isAggregate = from === 'search' && !!items?.length;
const aggregateData = useMemo(() => {
if (!isAggregate || !items) return null;
const countMap = new Map<number, number>();
const episodeCountMap = new Map<number, number>();
items.forEach((item) => {
if (item.douban_id && item.douban_id !== 0) {
countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1);
}
const len = item.episodes?.length || 0;
if (len > 0) {
episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1);
}
});
const getMostFrequent = (map: Map<number, number>) => {
let maxCount = 0;
let result: number | undefined;
map.forEach((cnt, key) => {
if (cnt > maxCount) {
maxCount = cnt;
result = key;
}
});
return result;
};
return {
first: items[0],
mostFrequentDoubanId: getMostFrequent(countMap),
mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
};
}, [isAggregate, items]);
const actualTitle = aggregateData?.first.title ?? title;
const actualPoster = aggregateData?.first.poster ?? poster;
const actualSource = aggregateData?.first.source ?? source;
const actualId = aggregateData?.first.id ?? id;
const actualDoubanId = aggregateData?.mostFrequentDoubanId ?? douban_id;
const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
const actualYear = aggregateData?.first.year ?? year;
const actualQuery = query || '';
const actualSearchType = isAggregate
? aggregateData?.first.episodes?.length === 1
? 'movie'
: 'tv'
: type;
// 获取收藏状态
useEffect(() => {
if (from === 'douban' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => {
try {
const fav = await isFavorited(actualSource, actualId);
setFavorited(fav);
} catch (err) {
throw new Error('检查收藏状态失败');
}
};
fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
);
return unsubscribe;
}, [from, actualSource, actualId]);
const handleToggleFavorite = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(actualSource, actualId);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(actualSource, actualId, {
title: actualTitle,
source_name: source_name || '',
year: actualYear || '',
cover: actualPoster,
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
setFavorited(true);
}
} catch (err) {
throw new Error('切换收藏状态失败');
}
},
[
from,
actualSource,
actualId,
actualTitle,
source_name,
actualYear,
actualPoster,
actualEpisodes,
favorited,
]
);
const handleDeleteRecord = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from !== 'playrecord' || !actualSource || !actualId) return;
try {
await deletePlayRecord(actualSource, actualId);
onDelete?.();
} catch (err) {
throw new Error('删除播放记录失败');
}
},
[from, actualSource, actualId, onDelete]
);
const handleClick = useCallback(() => {
if (from === 'douban') {
router.push(
`/play?title=${encodeURIComponent(actualTitle.trim())}${
actualYear ? `&year=${actualYear}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
);
} else if (actualSource && actualId) {
router.push(
`/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
);
}
}, [
from,
actualSource,
actualId,
router,
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
]);
const config = useMemo(() => {
const configs = {
playrecord: {
showSourceName: true,
showProgress: true,
showPlayButton: true,
showHeart: true,
showCheckCircle: true,
showDoubanLink: false,
showRating: false,
},
favorite: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: false,
showRating: false,
},
search: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: !isAggregate,
showCheckCircle: false,
showDoubanLink: !!actualDoubanId,
showRating: false,
},
douban: {
showSourceName: false,
showProgress: false,
showPlayButton: true,
showHeart: false,
showCheckCircle: false,
showDoubanLink: true,
showRating: !!rate,
},
};
return configs[from] || configs.search;
}, [from, isAggregate, actualDoubanId, rate]);
return (
<div
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
onClick={handleClick}
>
{/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 */}
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
{/* 图片 */}
<Image
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className='object-cover'
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onError={(e) => {
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
img.src = processImageUrl(actualPoster);
}, 2000);
}
}}
/>
{/* 悬浮遮罩 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
{/* 播放按钮 */}
{config.showPlayButton && (
<div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
<PlayCircleIcon
size={50}
strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
/>
</div>
)}
{/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && (
<div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
{config.showCheckCircle && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
/>
)}
{config.showHeart && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${
favorited
? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400'
} hover:scale-[1.1]`}
/>
)}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{rate}
</div>
)}
{actualEpisodes && actualEpisodes > 1 && (
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</div>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
>
<div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Link size={16} />
</div>
</a>
)}
</div>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* 标题与来源 */}
<div className='mt-2 text-center'>
<div className='relative'>
<span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
{actualTitle}
</span>
{/* 自定义 tooltip */}
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
{source_name}
</span>
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useState } from 'react';
interface WeekdaySelectorProps {
onWeekdayChange: (weekday: string) => void;
className?: string;
}
const weekdays = [
{ value: 'Mon', label: '周一', shortLabel: '周一' },
{ value: 'Tue', label: '周二', shortLabel: '周二' },
{ value: 'Wed', label: '周三', shortLabel: '周三' },
{ value: 'Thu', label: '周四', shortLabel: '周四' },
{ value: 'Fri', label: '周五', shortLabel: '周五' },
{ value: 'Sat', label: '周六', shortLabel: '周六' },
{ value: 'Sun', label: '周日', shortLabel: '周日' },
];
const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
onWeekdayChange,
className = '',
}) => {
// 获取今天的星期数,默认选中今天
const getTodayWeekday = (): string => {
const today = new Date().getDay();
// getDay() 返回 0-60 是周日1-6 是周一到周六
const weekdayMap = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return weekdayMap[today];
};
const [selectedWeekday, setSelectedWeekday] = useState<string>(
getTodayWeekday()
);
// 组件初始化时通知父组件默认选中的星期
useEffect(() => {
onWeekdayChange(getTodayWeekday());
}, []); // 只在组件挂载时执行一次
return (
<div
className={`relative inline-flex rounded-full p-0.5 sm:p-1 ${className}`}
>
{weekdays.map((weekday) => {
const isActive = selectedWeekday === weekday.value;
return (
<button
key={weekday.value}
onClick={() => {
setSelectedWeekday(weekday.value);
onWeekdayChange(weekday.value);
}}
className={`
relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap
${
isActive
? 'text-green-600 dark:text-green-400 font-semibold'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer'
}
`}
title={weekday.label}
>
{weekday.shortLabel}
</button>
);
})}
</div>
);
};
export default WeekdaySelector;