2025-07-04 17:06:26 +08:00

329 lines
11 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Trash2, Copy, RefreshCw, GripVertical } from 'lucide-react';
import { Scene } from '../pages/script-overview';
import Image from 'next/image';
interface SceneCardProps {
scene: Scene;
index: number;
isSelected?: boolean;
onUpdate: (updates: Partial<Scene>) => void;
onDelete: () => void;
onDuplicate: () => void;
}
export function SceneCard({
scene,
index,
isSelected = false,
onUpdate,
onDelete,
onDuplicate
}: SceneCardProps) {
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 {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: scene.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// 处理自动保存
const handleContentChange = (
field: keyof Scene,
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 (
<div
ref={setNodeRef}
style={style}
{...attributes}
>
<motion.div
id={`scene-card-${scene.id}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1,
x: isDragging ? 5 : 0,
y: isDragging ? 5 : 0,
rotate: 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
${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);
}}
>
{/* Drag Handle */}
<div
{...listeners}
className="absolute top-2 right-2 z-10 p-2 rounded-lg bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-white/60" />
</div>
{/* 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>
{/* Plot Description */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Plot Description</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('plot', 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.plot }}
/>
</div>
{/* Character Dialogue */}
<div className="space-y-2 mb-4">
<label className="text-sm text-white/60">Character Dialogue</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('dialogue', 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.dialogue }}
/>
</div>
{/* Narration */}
<div className="space-y-2">
<label className="text-sm text-white/60">Narration</label>
<div
contentEditable
suppressContentEditableWarning
onFocus={() => setIsEditing(true)}
onBlur={(e) => {
setIsEditing(false);
handleContentChange('narration', 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.narration }}
/>
</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>
);
}