forked from 77media/video-flow
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { motion } from 'framer-motion';
|
|
import { GripVertical, Trash2, Edit2, ChevronDown } from 'lucide-react';
|
|
import { DialogueItem as DialogueItemType, CharacterOption } from '@/app/model/enums';
|
|
import KeywordText from './keyword-text';
|
|
import * as Popover from '@radix-ui/react-popover';
|
|
import { mockCharacterOptions } from '@/app/model/enums';
|
|
import './style/dialogue-item.css';
|
|
|
|
interface DialogueItemProps {
|
|
dialogue: DialogueItemType;
|
|
onUpdate: (updates: Partial<DialogueItemType>) => void;
|
|
onDelete: () => void;
|
|
onCharacterChange?: (character: CharacterOption) => void;
|
|
}
|
|
|
|
const DialogueItem: React.FC<DialogueItemProps> = ({
|
|
dialogue,
|
|
onUpdate,
|
|
onDelete,
|
|
onCharacterChange,
|
|
}) => {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editText, setEditText] = useState(dialogue.text);
|
|
const [isCharacterSelectOpen, setIsCharacterSelectOpen] = useState(false);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: dialogue.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
const handleSave = () => {
|
|
onUpdate({ text: editText });
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const handleCharacterSelect = (character: CharacterOption) => {
|
|
onUpdate({ speaker: character.name });
|
|
onCharacterChange?.(character);
|
|
setIsCharacterSelectOpen(false);
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className={`
|
|
group relative flex items-start gap-3 p-3 rounded-lg
|
|
${isDragging ? 'bg-white/10' : 'hover:bg-white/5'}
|
|
transition-colors duration-200 dialogue-item
|
|
`}
|
|
>
|
|
{/* Drag Handle */}
|
|
<div
|
|
{...attributes}
|
|
{...listeners}
|
|
className="mt-1 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity"
|
|
>
|
|
<GripVertical className="w-4 h-4" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-grow">
|
|
{/* Character Selector */}
|
|
<Popover.Root open={isCharacterSelectOpen} onOpenChange={setIsCharacterSelectOpen}>
|
|
<Popover.Trigger asChild>
|
|
<button
|
|
className="mb-1 px-2 py-0.5 text-sm font-medium text-gray-400 rounded
|
|
hover:bg-white/5 flex items-center gap-1 transition-colors group/select"
|
|
>
|
|
{dialogue.speaker}
|
|
<ChevronDown className="w-3 h-3 opacity-50 group-hover/select:opacity-100 transition-opacity" />
|
|
</button>
|
|
</Popover.Trigger>
|
|
<Popover.Portal>
|
|
<Popover.Content
|
|
className="w-[200px] bg-black/90 backdrop-blur-xl rounded-lg border border-white/10
|
|
shadow-xl animate-in fade-in-80 z-50"
|
|
sideOffset={5}
|
|
>
|
|
<div className="p-1">
|
|
{mockCharacterOptions.map((char) => (
|
|
<button
|
|
key={char.characterId}
|
|
onClick={() => handleCharacterSelect(char)}
|
|
className="w-full px-3 py-2 text-sm text-left text-gray-200 rounded-md
|
|
hover:bg-white/5 transition-colors flex items-center gap-2"
|
|
>
|
|
<img
|
|
src={char.image}
|
|
alt={char.name}
|
|
className="w-6 h-6 rounded-full object-cover"
|
|
/>
|
|
{char.name}
|
|
</button>
|
|
))}
|
|
<div className="h-[1px] bg-white/10 my-1" />
|
|
<button
|
|
onClick={() => {
|
|
onUpdate({ speaker: '旁白' });
|
|
setIsCharacterSelectOpen(false);
|
|
}}
|
|
className="w-full px-3 py-2 text-sm text-left text-gray-400 rounded-md
|
|
hover:bg-white/5 transition-colors"
|
|
>
|
|
旁白
|
|
</button>
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover.Portal>
|
|
</Popover.Root>
|
|
|
|
{isEditing ? (
|
|
<div className="flex flex-col gap-2">
|
|
<textarea
|
|
value={editText}
|
|
onChange={(e) => setEditText(e.target.value)}
|
|
className="w-full bg-black/20 rounded-lg p-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
rows={3}
|
|
onBlur={() => {
|
|
handleSave();
|
|
setIsEditing(false);
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-200" onClick={() => setIsEditing(true)}>
|
|
<KeywordText text={dialogue.text} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 opacity-0 transition-opacity opt-btn-group">
|
|
<button
|
|
onClick={() => setIsEditing(true)}
|
|
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
|
>
|
|
<Edit2 className="w-4 h-4 text-gray-400" />
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-400" />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default DialogueItem;
|