forked from 77media/video-flow
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
import React from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { X, Check, ChevronDown } from 'lucide-react';
|
||
import * as Popover from '@radix-ui/react-popover';
|
||
import { mockSceneOptions, mockCharacterOptions } from '@/app/model/enums';
|
||
|
||
interface FilterBarProps {
|
||
selectedScenes: string[];
|
||
selectedCharacters: string[];
|
||
onScenesChange: (scenes: string[]) => void;
|
||
onCharactersChange: (characters: string[]) => void;
|
||
onReset: () => void;
|
||
}
|
||
|
||
const MultiSelect: React.FC<{
|
||
label: string;
|
||
options: { id: string; name: string }[];
|
||
selected: string[];
|
||
onChange: (values: string[]) => void;
|
||
}> = ({ label, options, selected, onChange }) => {
|
||
return (
|
||
<div className="flex items-center min-w-[200px]">
|
||
<label className="block text-sm text-gray-400 mr-2 flex-shrink-0 w-[2rem]">{label}</label>
|
||
<Popover.Root>
|
||
<Popover.Trigger asChild>
|
||
<button
|
||
className="flex-1 h-10 px-3 rounded-lg bg-black/30 border border-white/10
|
||
flex items-center justify-between text-sm text-gray-200
|
||
focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||
>
|
||
<span>
|
||
{selected.length
|
||
? `已选择 ${selected.length} 个${label}`
|
||
: `选择${label}`}
|
||
</span>
|
||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||
</button>
|
||
</Popover.Trigger>
|
||
|
||
<Popover.Portal>
|
||
<Popover.Content
|
||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden bg-black/90 backdrop-blur-xl
|
||
rounded-lg border border-white/10 shadow-xl animate-in fade-in-80 z-50"
|
||
align="start"
|
||
sideOffset={5}
|
||
>
|
||
<div className="p-2">
|
||
{options.map((option) => (
|
||
<motion.button
|
||
key={option.id}
|
||
onClick={() => {
|
||
const newSelected = selected.includes(option.id)
|
||
? selected.filter(id => id !== option.id)
|
||
: [...selected, option.id];
|
||
onChange(newSelected);
|
||
}}
|
||
className="relative w-full flex items-center px-8 py-2 rounded-md text-sm text-gray-200
|
||
hover:bg-white/5 cursor-pointer outline-none"
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
<span className="absolute left-2">
|
||
{selected.includes(option.id) && (
|
||
<motion.div
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="w-4 h-4 text-blue-400"
|
||
>
|
||
<Check className="w-4 h-4" />
|
||
</motion.div>
|
||
)}
|
||
</span>
|
||
{option.name}
|
||
</motion.button>
|
||
))}
|
||
</div>
|
||
</Popover.Content>
|
||
</Popover.Portal>
|
||
</Popover.Root>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FilterBar: React.FC<FilterBarProps> = ({
|
||
selectedScenes,
|
||
selectedCharacters,
|
||
onScenesChange,
|
||
onCharactersChange,
|
||
onReset,
|
||
}) => {
|
||
const sceneOptions = mockSceneOptions.map(scene => ({
|
||
id: scene.sceneId,
|
||
name: scene.name,
|
||
}));
|
||
|
||
const characterOptions = mockCharacterOptions.map(char => ({
|
||
id: char.characterId,
|
||
name: char.name,
|
||
}));
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="w-full bg-black/20 backdrop-blur-sm rounded-xl p-4 border border-white/10"
|
||
>
|
||
<div className="flex flex-wrap gap-4 items-center">
|
||
<MultiSelect
|
||
label="场景"
|
||
options={sceneOptions}
|
||
selected={selectedScenes}
|
||
onChange={onScenesChange}
|
||
/>
|
||
|
||
<MultiSelect
|
||
label="角色"
|
||
options={characterOptions}
|
||
selected={selectedCharacters}
|
||
onChange={onCharactersChange}
|
||
/>
|
||
|
||
<button
|
||
onClick={onReset}
|
||
className="h-10 px-4 rounded-lg bg-white/5 hover:bg-white/10
|
||
text-sm text-gray-300 transition-colors flex items-center gap-2"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
重置筛选
|
||
</button>
|
||
</div>
|
||
|
||
{/* 当前筛选条件 */}
|
||
<AnimatePresence>
|
||
{(selectedScenes.length > 0 || selectedCharacters.length > 0) && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: 'auto' }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
className="mt-4 flex flex-wrap gap-2"
|
||
>
|
||
{selectedScenes.map((sceneId) => {
|
||
const scene = mockSceneOptions.find(s => s.sceneId === sceneId);
|
||
return scene && (
|
||
<motion.span
|
||
key={sceneId}
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
exit={{ scale: 0 }}
|
||
className="px-2 py-1 rounded-md bg-blue-500/20 text-blue-400 text-sm"
|
||
>
|
||
场景:{scene.name}
|
||
</motion.span>
|
||
);
|
||
})}
|
||
{selectedCharacters.map((charId) => {
|
||
const char = mockCharacterOptions.find(c => c.characterId === charId);
|
||
return char && (
|
||
<motion.span
|
||
key={charId}
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
exit={{ scale: 0 }}
|
||
className="px-2 py-1 rounded-md bg-yellow-500/20 text-yellow-400 text-sm"
|
||
>
|
||
角色:{char.name}
|
||
</motion.span>
|
||
);
|
||
})}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
);
|
||
};
|
||
|
||
export default FilterBar;
|