mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-02 09:24:42 +08:00
feat: new douban page and selector
This commit is contained in:
330
src/components/DoubanSelector.tsx
Normal file
330
src/components/DoubanSelector.tsx
Normal file
@@ -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-1 dark:bg-gray-700/60 backdrop-blur-sm'
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{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((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-4 py-2 text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 电影类型 - 显示两级选择器 */}
|
||||
{type === 'movie' && (
|
||||
<div className='space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='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-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-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-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;
|
||||
Reference in New Issue
Block a user