forked from 77media/video-flow
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Draggable } from 'react-beautiful-dnd';
|
|
import { Trash2, Copy, RefreshCw } from 'lucide-react';
|
|
import { StoryboardScene } from '../pages/storyboard-view';
|
|
import Image from 'next/image';
|
|
|
|
interface StoryboardCardProps {
|
|
scene: StoryboardScene;
|
|
index: number;
|
|
isSelected?: boolean;
|
|
onUpdate: (updates: Partial<StoryboardScene>) => void;
|
|
onDelete: () => void;
|
|
onDuplicate: () => void;
|
|
}
|
|
|
|
export function StoryboardCard({
|
|
scene,
|
|
index,
|
|
isSelected = false,
|
|
onUpdate,
|
|
onDelete,
|
|
onDuplicate
|
|
}: StoryboardCardProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [showSaveIndicator, setShowSaveIndicator] = useState(false);
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
|
|
|
// 处理自动保存
|
|
const handleContentChange = (
|
|
field: keyof StoryboardScene,
|
|
value: string,
|
|
element: HTMLElement
|
|
) => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
onUpdate({ [field]: value });
|
|
setShowSaveIndicator(true);
|
|
setTimeout(() => setShowSaveIndicator(false), 2000);
|
|
}, 500);
|
|
};
|
|
|
|
// 组件卸载时清理定时器
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 选中效果
|
|
useEffect(() => {
|
|
if (isSelected && cardRef.current) {
|
|
cardRef.current.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center',
|
|
inline: 'center'
|
|
});
|
|
}
|
|
}, [isSelected]);
|
|
|
|
return (
|
|
<Draggable draggableId={scene.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
>
|
|
<motion.div
|
|
id={`scene-card-${scene.id}`}
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
x: snapshot.isDragging ? 5 : 0,
|
|
y: snapshot.isDragging ? 5 : 0,
|
|
rotate: snapshot.isDragging ? 2 : 0
|
|
}}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 200,
|
|
damping: 20
|
|
}}
|
|
className={`
|
|
relative flex-shrink-0 w-[400px] bg-white/5 backdrop-blur-sm rounded-xl overflow-hidden h-full
|
|
flex flex-col group cursor-grab active:cursor-grabbing
|
|
${snapshot.isDragging ? 'ring-2 ring-blue-500/50 shadow-lg z-50' : ''}
|
|
${isEditing ? 'ring-2 ring-yellow-500/50' : ''}
|
|
${isSelected ? 'ring-2 ring-purple-500/50' : ''}
|
|
transition-all duration-300
|
|
`}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => {
|
|
setIsHovered(false);
|
|
setShowDeleteConfirm(false);
|
|
}}
|
|
>
|
|
{/* Scene Image */}
|
|
<div className="relative w-full h-[200px] flex-shrink-0">
|
|
<Image
|
|
src={scene.imageUrl}
|
|
alt={scene.name}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0C0E11] via-transparent to-transparent" />
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex flex-col h-full">
|
|
{/* Scrollable Content */}
|
|
<div className="flex-grow overflow-y-auto custom-scrollbar p-6 -mt-12">
|
|
{/* Scene Name */}
|
|
<motion.div
|
|
animate={{ scale: 0.8 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="text-xl font-semibold mb-3"
|
|
>
|
|
<div
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={(e) => {
|
|
setIsEditing(false);
|
|
handleContentChange('name', e.currentTarget.textContent || '', e.currentTarget);
|
|
}}
|
|
className="outline-none focus:bg-white/5 rounded px-2 py-1
|
|
transition-colors"
|
|
dangerouslySetInnerHTML={{ __html: scene.name }}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Scene Description */}
|
|
<motion.div
|
|
animate={{ opacity: 1 }}
|
|
className="text-sm text-white/60 mb-4"
|
|
>
|
|
<div
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={(e) => {
|
|
setIsEditing(false);
|
|
handleContentChange('description', e.currentTarget.textContent || '', e.currentTarget);
|
|
}}
|
|
className="outline-none focus:bg-white/5 rounded px-2 py-1
|
|
transition-colors"
|
|
dangerouslySetInnerHTML={{ __html: scene.description }}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Shot Description */}
|
|
<div className="space-y-2 mb-4">
|
|
<label className="text-sm text-white/60">Shot Description</label>
|
|
<div
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={(e) => {
|
|
setIsEditing(false);
|
|
handleContentChange('shot', e.currentTarget.textContent || '', e.currentTarget);
|
|
}}
|
|
className="outline-none focus:bg-white/5 rounded px-2 py-1
|
|
transition-colors text-white/80"
|
|
dangerouslySetInnerHTML={{ __html: scene.shot }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Frame Description */}
|
|
<div className="space-y-2 mb-4">
|
|
<label className="text-sm text-white/60">Frame Description</label>
|
|
<div
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={(e) => {
|
|
setIsEditing(false);
|
|
handleContentChange('frame', e.currentTarget.textContent || '', e.currentTarget);
|
|
}}
|
|
className="outline-none focus:bg-white/5 rounded px-2 py-1
|
|
transition-colors text-white/80"
|
|
dangerouslySetInnerHTML={{ __html: scene.frame }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Atmosphere */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm text-white/60">Atmosphere</label>
|
|
<div
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={(e) => {
|
|
setIsEditing(false);
|
|
handleContentChange('atmosphere', e.currentTarget.textContent || '', e.currentTarget);
|
|
}}
|
|
className="outline-none focus:bg-white/5 rounded px-2 py-1
|
|
transition-colors text-white/80"
|
|
dangerouslySetInnerHTML={{ __html: scene.atmosphere }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating Action Bar */}
|
|
<motion.div
|
|
initial={false}
|
|
animate={{
|
|
y: isHovered ? 0 : 100,
|
|
opacity: isHovered ? 1 : 0
|
|
}}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 300,
|
|
damping: 30
|
|
}}
|
|
className="absolute bottom-0 left-0 right-0 p-4 bg-black/30 backdrop-blur-sm
|
|
border-t border-white/10 flex items-center justify-end gap-2"
|
|
>
|
|
{/* Delete Button */}
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
className="p-2 rounded-lg hover:bg-red-500/20 text-red-500
|
|
transition-colors relative group"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowDeleteConfirm(true);
|
|
}}
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
{/* Delete Confirmation */}
|
|
{showDeleteConfirm && (
|
|
<div
|
|
className="absolute bottom-full right-0 mb-2 p-2
|
|
bg-red-500 rounded-lg whitespace-nowrap flex items-center gap-2"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<span>Confirm delete?</span>
|
|
<button
|
|
className="px-2 py-1 rounded bg-red-600 hover:bg-red-700
|
|
transition-colors text-white text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
className="px-2 py-1 rounded bg-white/20 hover:bg-white/30
|
|
transition-colors text-white text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowDeleteConfirm(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</motion.button>
|
|
|
|
{/* Duplicate Button */}
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDuplicate();
|
|
}}
|
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
|
>
|
|
<Copy className="w-5 h-5" />
|
|
</motion.button>
|
|
|
|
{/* Regenerate Button */}
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={e => e.stopPropagation()}
|
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
|
>
|
|
<RefreshCw className="w-5 h-5" />
|
|
</motion.button>
|
|
</motion.div>
|
|
|
|
{/* Save Indicator */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: showSaveIndicator ? 1 : 0, y: showSaveIndicator ? 0 : 20 }}
|
|
className="absolute bottom-20 right-4 px-3 py-1.5 rounded-full
|
|
bg-green-500/20 text-green-400 text-sm"
|
|
>
|
|
Saved
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
);
|
|
}
|