forked from 77media/video-flow
195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { Check, X, CircleAlert } from 'lucide-react';
|
||
import { cn } from '@/public/lib/utils';
|
||
|
||
// 定义类型
|
||
interface Shot {
|
||
id: string;
|
||
videoUrl?: string;
|
||
thumbnailUrl: string;
|
||
isGenerating: boolean;
|
||
isSelected: boolean;
|
||
}
|
||
|
||
interface Item {
|
||
id: string;
|
||
name: string;
|
||
avatarUrl: string;
|
||
}
|
||
|
||
interface ReplacePanelProps {
|
||
title: string;
|
||
shots: Shot[];
|
||
item: Item;
|
||
showAddToLibrary?: boolean;
|
||
addToLibraryText?: string;
|
||
onClose: () => void;
|
||
onConfirm: (selectedShots: string[], addToLibrary: boolean) => void;
|
||
}
|
||
|
||
export function ReplacePanel({
|
||
title,
|
||
shots,
|
||
item,
|
||
showAddToLibrary = false,
|
||
addToLibraryText = "同步添加至库",
|
||
onClose,
|
||
onConfirm,
|
||
}: ReplacePanelProps) {
|
||
const [selectedShots, setSelectedShots] = useState(
|
||
shots.filter(shot => shot.isSelected).map(shot => shot.id)
|
||
);
|
||
const [addToLibrary, setAddToLibrary] = useState(false);
|
||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
|
||
|
||
const handleShotToggle = (shotId: string) => {
|
||
setSelectedShots(prev =>
|
||
prev.includes(shotId)
|
||
? prev.filter(id => id !== shotId)
|
||
: [...prev, shotId]
|
||
);
|
||
};
|
||
|
||
const handleSelectAllShots = (checked: boolean) => {
|
||
setSelectedShots(checked ? shots.map(shot => shot.id) : []);
|
||
};
|
||
|
||
const handleMouseEnter = (shotId: string) => {
|
||
setHoveredVideoId(shotId);
|
||
if (videoRefs.current[shotId]) {
|
||
videoRefs.current[shotId].play();
|
||
}
|
||
};
|
||
|
||
const handleMouseLeave = (shotId: string) => {
|
||
setHoveredVideoId(null);
|
||
if (videoRefs.current[shotId]) {
|
||
videoRefs.current[shotId].pause();
|
||
videoRefs.current[shotId].currentTime = 0;
|
||
}
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
onConfirm(selectedShots, addToLibrary);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2 w-full max-w-5xl">
|
||
{/* 标题 */}
|
||
<div className="text-2xl font-semibold text-white">{title}</div>
|
||
|
||
{/* 提示信息 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-sm text-red-400">
|
||
<CircleAlert className="w-4 h-4" />
|
||
该内容出现在 <span className="text-blue-500">{shots.length}</span> 个分镜中,替换后将影响如下分镜
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedShots.length === shots.length}
|
||
onChange={(e) => handleSelectAllShots(e.target.checked)}
|
||
className="w-4 h-4 rounded border-white/20"
|
||
/>
|
||
<label className="text-white/80">全选</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分镜展示区 */}
|
||
<div className="space-y-2">
|
||
<div className="text-white/80 text-sm">选择需要替换的分镜:</div>
|
||
<div className="flex gap-4 overflow-x-auto pb-4 hide-scrollbar h-64">
|
||
{shots.map((shot) => (
|
||
<motion.div
|
||
key={shot.id}
|
||
className={cn(
|
||
'relative flex-shrink-0 rounded-lg overflow-hidden cursor-pointer',
|
||
'aspect-video border-2',
|
||
hoveredVideoId === shot.id ? 'w-auto' : 'w-32',
|
||
selectedShots.includes(shot.id)
|
||
? 'border-blue-500'
|
||
: 'border-transparent hover:border-blue-500/50'
|
||
)}
|
||
onClick={() => handleShotToggle(shot.id)}
|
||
onMouseEnter={() => handleMouseEnter(shot.id)}
|
||
onMouseLeave={() => handleMouseLeave(shot.id)}
|
||
whileHover={{ scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
>
|
||
{shot.videoUrl && (
|
||
<video
|
||
ref={el => {
|
||
if (el) videoRefs.current[shot.id] = el;
|
||
}}
|
||
src={shot.videoUrl}
|
||
className="w-full h-full object-cover"
|
||
loop
|
||
muted
|
||
playsInline
|
||
/>
|
||
)}
|
||
{!shot.videoUrl && (
|
||
<img
|
||
src={shot.thumbnailUrl}
|
||
alt={`Shot ${shot.id}`}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
)}
|
||
{shot.isGenerating && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||
<div className="text-white text-sm">生成中...</div>
|
||
</div>
|
||
)}
|
||
{selectedShots.includes(shot.id) && (
|
||
<div className="absolute top-2 right-2">
|
||
<Check className="w-4 h-4 text-blue-500" />
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 预览信息 */}
|
||
<div className="flex items-center gap-4 bg-white/5 rounded-lg p-4">
|
||
<img
|
||
src={item.avatarUrl}
|
||
alt={item.name}
|
||
className="w-12 h-12 rounded-full object-cover"
|
||
/>
|
||
<div className="text-white">{item.name}</div>
|
||
</div>
|
||
|
||
{/* 同步到库选项 */}
|
||
{showAddToLibrary && (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={addToLibrary}
|
||
onChange={(e) => setAddToLibrary(e.target.checked)}
|
||
className="w-4 h-4 rounded border-white/20"
|
||
/>
|
||
<label className="text-white/80">{addToLibraryText}</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex justify-end gap-4">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleConfirm}
|
||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||
>
|
||
替换
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|