Update
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,330 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SelectorOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DoubanSelectorProps {
|
||||
type: 'movie' | 'tv' | 'show';
|
||||
primarySelection?: string;
|
||||
secondarySelection?: string;
|
||||
onPrimaryChange: (value: string) => void;
|
||||
onSecondaryChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||
type,
|
||||
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 });
|
||||
|
||||
// 电影的一级选择器选项
|
||||
const moviePrimaryOptions: SelectorOption[] = [
|
||||
{ label: '热门电影', value: '热门' },
|
||||
{ label: '最新电影', value: '最新' },
|
||||
{ label: '豆瓣高分', value: '豆瓣高分' },
|
||||
{ label: '冷门佳片', value: '冷门佳片' },
|
||||
];
|
||||
|
||||
// 电影的二级选择器选项
|
||||
const movieSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '华语', value: '华语' },
|
||||
{ label: '欧美', value: '欧美' },
|
||||
{ label: '韩国', value: '韩国' },
|
||||
{ label: '日本', value: '日本' },
|
||||
];
|
||||
|
||||
// 电视剧选择器选项
|
||||
const tvOptions: 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 showOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: 'show' },
|
||||
{ label: '国内', value: 'show_domestic' },
|
||||
{ label: '国外', value: 'show_foreign' },
|
||||
];
|
||||
|
||||
// 更新指示器位置的通用函数
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// 副选择器初始位置
|
||||
let secondaryActiveIndex = -1;
|
||||
if (type === 'movie') {
|
||||
secondaryActiveIndex = movieSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
|
||||
);
|
||||
} else if (type === 'tv') {
|
||||
secondaryActiveIndex = tvOptions.findIndex(
|
||||
(opt) => opt.value === (secondarySelection || tvOptions[0].value)
|
||||
);
|
||||
} else if (type === 'show') {
|
||||
secondaryActiveIndex = showOptions.findIndex(
|
||||
(opt) => opt.value === (secondarySelection || showOptions[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;
|
||||
}
|
||||
}, [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 = tvOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = tvOptions;
|
||||
} else if (type === 'show') {
|
||||
activeIndex = showOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = showOptions;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 二级选择器 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 电视剧类型 - 只显示一级选择器 */}
|
||||
{type === 'tv' && (
|
||||
<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(
|
||||
tvOptions,
|
||||
secondarySelection || tvOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 综艺类型 - 只显示一级选择器 */}
|
||||
{type === 'show' && (
|
||||
<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(
|
||||
showOptions,
|
||||
secondarySelection || showOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanSelector;
|
||||
@@ -0,0 +1,602 @@
|
||||
/* 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;
|
||||
/** 每页显示多少集,默认 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,
|
||||
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);
|
||||
|
||||
// 获取视频信息的函数 - 移除 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]);
|
||||
|
||||
// 分页标签始终保持升序
|
||||
const categories = categoriesAsc;
|
||||
|
||||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||
useEffect(() => {
|
||||
const btn = buttonRefs.current[currentPage];
|
||||
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',
|
||||
});
|
||||
}
|
||||
}, [currentPage, pageCount]);
|
||||
|
||||
// 处理换源tab点击,只在点击时才搜索
|
||||
const handleSourceTabClick = () => {
|
||||
setActiveTab('sources');
|
||||
};
|
||||
|
||||
const handleCategoryClick = useCallback((index: number) => {
|
||||
setCurrentPage(index);
|
||||
}, []);
|
||||
|
||||
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 === currentPage;
|
||||
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='grid grid-cols-[repeat(auto-fill,minmax(40px,1fr))] auto-rows-[40px] gap-x-3 gap-y-3 overflow-y-auto h-full 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 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||||
${
|
||||
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()}
|
||||
>
|
||||
{episodeNumber}
|
||||
</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;
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { Clover, Film, Home, Search, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
/**
|
||||
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
|
||||
*/
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
||||
const currentActive = activePath ?? pathname;
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{ icon: Search, label: '搜索', href: '/search' },
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
];
|
||||
|
||||
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-purple-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-purple-700/50 shadow-lg'
|
||||
style={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* 顶部装饰线 */}
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-purple-500/50 to-transparent"></div>
|
||||
|
||||
<ul className='flex items-center'>
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className='flex-shrink-0 w-1/5'>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center justify-center w-full h-14 gap-1 text-xs transition-all duration-200 relative ${
|
||||
active
|
||||
? 'transform -translate-y-1'
|
||||
: 'hover:transform hover:-translate-y-0.5'
|
||||
}`}
|
||||
>
|
||||
{/* 激活状态的背景光晕 */}
|
||||
{active && (
|
||||
<div className="absolute inset-0 bg-purple-500/10 rounded-lg mx-2 my-1 border border-purple-300/20"></div>
|
||||
)}
|
||||
|
||||
<item.icon
|
||||
className={`h-6 w-6 transition-all duration-200 ${
|
||||
active
|
||||
? 'text-purple-600 dark:text-purple-400 scale-110'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-300'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`transition-all duration-200 font-medium ${
|
||||
active
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-purple-500 dark:hover:text-purple-300'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileBottomNav;
|
||||
@@ -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-purple-200/50 shadow-sm dark:bg-gray-900/70 dark:border-purple-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 katelya-logo tracking-tight hover:opacity-80 transition-opacity'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileHeader;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Clover, Film, Home, Search, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { BackButton } from './BackButton';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { useSite } from './SiteProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
// 内联顶部导航栏组件
|
||||
const TopNavbar = ({ activePath = '/' }: { activePath?: string }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { siteName } = useSite();
|
||||
|
||||
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 handleSearchClick = useCallback(() => {
|
||||
router.push('/search');
|
||||
}, [router]);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: Home,
|
||||
label: '首页',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
label: '搜索',
|
||||
href: '/search',
|
||||
},
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className='w-full bg-white/40 backdrop-blur-xl border-b border-purple-200/50 shadow-lg dark:bg-gray-900/70 dark:border-purple-700/50 sticky top-0 z-50'>
|
||||
<div className='w-full px-8 lg:px-12 xl:px-16'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
{/* Logo区域 - 调整为更靠左 */}
|
||||
<div className='flex-shrink-0 -ml-2'>
|
||||
<Link
|
||||
href='/'
|
||||
className='flex items-center select-none hover:opacity-80 transition-opacity duration-200'
|
||||
>
|
||||
<span className='text-2xl font-bold katelya-logo tracking-tight'>
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<div className='hidden md:block'>
|
||||
<div className='ml-10 flex items-baseline space-x-4'>
|
||||
{menuItems.map((item) => {
|
||||
// 检查当前路径是否匹配这个菜单项
|
||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||
const tagMatch = item.href.match(/tag=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(active);
|
||||
const decodedItemHref = decodeURIComponent(item.href);
|
||||
|
||||
const isActive =
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`) &&
|
||||
tagMatch &&
|
||||
decodedActive.includes(`tag=${tagMatch}`));
|
||||
|
||||
const Icon = item.icon;
|
||||
|
||||
if (item.href === '/search') {
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearchClick();
|
||||
setActive('/search');
|
||||
}}
|
||||
data-active={isActive}
|
||||
className={`group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-purple-500/20 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400'
|
||||
: 'text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 dark:hover:bg-purple-500/10'
|
||||
}`}
|
||||
>
|
||||
<Icon className='h-4 w-4 mr-2' />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={isActive}
|
||||
className={`group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-purple-500/20 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400'
|
||||
: 'text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 dark:hover:bg-purple-500/10'
|
||||
}`}
|
||||
>
|
||||
<Icon className='h-4 w-4 mr-2' />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮 - 调整为更靠右,增加间距实现对称效果 */}
|
||||
<div className='flex items-center gap-3 -mr-2'>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
return (
|
||||
<div className='w-full min-h-screen'>
|
||||
{/* 移动端头部 */}
|
||||
<MobileHeader showBackButton={['/play'].includes(activePath)} />
|
||||
|
||||
{/* 桌面端顶部导航栏 */}
|
||||
<div className='hidden md:block'>
|
||||
<TopNavbar activePath={activePath} />
|
||||
</div>
|
||||
|
||||
{/* 主要布局容器 */}
|
||||
<div className='w-full min-h-screen md:min-h-auto'>
|
||||
{/* 主内容区域 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 主内容容器 - 修改布局实现完全居中:左右各留白1/6,主内容区占2/3 */}
|
||||
<main className='flex-1 md:min-h-0 mb-14 md:mb-0 md:p-6 lg:p-8'>
|
||||
{/* 使用flex布局实现三等分 */}
|
||||
<div className='flex w-full min-h-screen md:min-h-[calc(100vh-10rem)]'>
|
||||
{/* 左侧留白区域 - 占1/6 */}
|
||||
<div className='hidden md:block flex-shrink-0' style={{ width: '16.67%' }}></div>
|
||||
|
||||
{/* 主内容区 - 占2/3 */}
|
||||
<div className='flex-1 md:flex-none rounded-container w-full' style={{ width: '66.67%' }}>
|
||||
<div
|
||||
className='p-4 md:p-8 lg:p-10'
|
||||
style={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧留白区域 - 占1/6 */}
|
||||
<div className='hidden md:block flex-shrink-0' style={{ width: '16.67%' }}></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
<div className='md:hidden'>
|
||||
<MobileBottomNav activePath={activePath} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { Clover, Film, Home, Menu, Search, 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 katelya-logo 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 = [
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
href: '/douban?type=show',
|
||||
},
|
||||
];
|
||||
|
||||
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-purple-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-purple-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-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-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-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-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-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-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-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-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];
|
||||
const tagMatch = item.href.match(/tag=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(active);
|
||||
const decodedItemHref = decodeURIComponent(item.href);
|
||||
|
||||
const isActive =
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`) &&
|
||||
tagMatch &&
|
||||
decodedActive.includes(`tag=${tagMatch}`));
|
||||
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-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-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-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
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);
|
||||
setThemeColor(resolvedTheme);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { 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';
|
||||
|
||||
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 [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 [imageProxyUrl, setImageProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [enableImageProxy, setEnableImageProxy] = useState(false);
|
||||
const [enableDoubanProxy, setEnableDoubanProxy] = useState(false);
|
||||
|
||||
// 修改密码相关状态
|
||||
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 savedEnableDoubanProxy = localStorage.getItem('enableDoubanProxy');
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
if (savedEnableDoubanProxy !== null) {
|
||||
setEnableDoubanProxy(JSON.parse(savedEnableDoubanProxy));
|
||||
} else if (defaultDoubanProxy) {
|
||||
setEnableDoubanProxy(true);
|
||||
}
|
||||
|
||||
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
|
||||
if (savedDoubanProxyUrl !== null) {
|
||||
setDoubanProxyUrl(savedDoubanProxyUrl);
|
||||
} else if (defaultDoubanProxy) {
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
}
|
||||
|
||||
const savedEnableImageProxy = localStorage.getItem('enableImageProxy');
|
||||
const defaultImageProxy =
|
||||
(window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
|
||||
if (savedEnableImageProxy !== null) {
|
||||
setEnableImageProxy(JSON.parse(savedEnableImageProxy));
|
||||
} else if (defaultImageProxy) {
|
||||
setEnableImageProxy(true);
|
||||
}
|
||||
|
||||
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
|
||||
if (savedImageProxyUrl !== null) {
|
||||
setImageProxyUrl(savedImageProxyUrl);
|
||||
} else if (defaultImageProxy) {
|
||||
setImageProxyUrl(defaultImageProxy);
|
||||
}
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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 handleImageProxyUrlChange = (value: string) => {
|
||||
setImageProxyUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('imageProxyUrl', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizationToggle = (value: boolean) => {
|
||||
setEnableOptimization(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageProxyToggle = (value: boolean) => {
|
||||
setEnableImageProxy(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('enableImageProxy', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanProxyToggle = (value: boolean) => {
|
||||
setEnableDoubanProxy(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('enableDoubanProxy', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = () => {
|
||||
const defaultImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
|
||||
const defaultDoubanProxy =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setEnableDoubanProxy(!!defaultDoubanProxy);
|
||||
setEnableImageProxy(!!defaultImageProxy);
|
||||
setImageProxyUrl(defaultImageProxy);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem(
|
||||
'enableDoubanProxy',
|
||||
JSON.stringify(!!defaultDoubanProxy)
|
||||
);
|
||||
localStorage.setItem(
|
||||
'enableImageProxy',
|
||||
JSON.stringify(!!defaultImageProxy)
|
||||
);
|
||||
localStorage.setItem('imageProxyUrl', defaultImageProxy);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否显示管理面板按钮
|
||||
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={() =>
|
||||
window.open('https://github.com/senshinya/MoonTV', '_blank')
|
||||
}
|
||||
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-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||
{/* 标题栏 */}
|
||||
<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='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 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={enableDoubanProxy}
|
||||
onChange={(e) => handleDoubanProxyToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 豆瓣代理地址设置 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
豆瓣代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
仅在启用豆瓣代理时生效,留空则使用服务器 API
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
enableDoubanProxy
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||
value={doubanProxyUrl}
|
||||
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||
disabled={!enableDoubanProxy}
|
||||
/>
|
||||
</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={enableImageProxy}
|
||||
onChange={(e) => handleImageProxyToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 图片代理地址设置 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
图片代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
仅在启用图片代理时生效
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
enableImageProxy
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
placeholder='例如: https://imageproxy.example.com/?url='
|
||||
value={imageProxyUrl}
|
||||
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
|
||||
disabled={!enableImageProxy}
|
||||
/>
|
||||
</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)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,390 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { CheckCircle, Heart, Link, PlayCircleIcon } 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?: string;
|
||||
onDelete?: () => void;
|
||||
rate?: string;
|
||||
items?: SearchResult[];
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export default function VideoCard({
|
||||
id,
|
||||
title = '',
|
||||
query = '',
|
||||
poster = '',
|
||||
episodes,
|
||||
source,
|
||||
source_name,
|
||||
progress = 0,
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
onDelete,
|
||||
rate,
|
||||
items,
|
||||
type = '',
|
||||
}: 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<string | 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 = <T extends string | number>(
|
||||
map: Map<T, number>
|
||||
) => {
|
||||
let maxCount = 0;
|
||||
let result: T | 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 = String(
|
||||
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'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
/>
|
||||
|
||||
{/* 悬浮遮罩 */}
|
||||
<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 && (
|
||||
<CheckCircle
|
||||
onClick={handleDeleteRecord}
|
||||
size={20}
|
||||
className='text-white transition-all duration-300 ease-out hover:stroke-green-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 && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${actualDoubanId}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user