Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { TaskSegment, Interaction, InteractionType } from '../types'; | |
| import { MousePointerClick, Type, Pencil } from './Icons'; | |
| type HighlightPoint = { x: number; y: number; isEditing: boolean } | null; | |
| type CoordinatePickerCallback = ((coords: { x: number; y: number }) => void) | null; | |
| const interactionIcons: Record<InteractionType, React.ReactNode> = { | |
| click: <MousePointerClick className="w-4 h-4 text-sky-400" />, | |
| type: <Type className="w-4 h-4 text-lime-400" />, | |
| }; | |
| interface TaskSegmentCardProps { | |
| task: TaskSegment; | |
| videoDuration: number; | |
| totalFrames: number; | |
| onSeekToTime?: (time: number) => void; | |
| onUpdateInteraction: (taskId: number, interactionIndex: number, updatedInteraction: Interaction) => void; | |
| onHighlightPoint: (point: HighlightPoint) => void; | |
| onSetCoordinatePicker: (callback: CoordinatePickerCallback) => void; | |
| } | |
| const formatTime = (seconds: number) => { | |
| if (isNaN(seconds) || seconds < 0) return '0.0s'; | |
| return `${seconds.toFixed(1)}s`; | |
| }; | |
| const formatCoords = (x?: number, y?: number) => { | |
| if (x === undefined || y === undefined) return ''; | |
| return `(x: ${x.toFixed(2)}, y: ${y.toFixed(2)})`; | |
| } | |
| export const TaskSegmentCard: React.FC<TaskSegmentCardProps> = ({ task, videoDuration, totalFrames, onSeekToTime, onUpdateInteraction, onHighlightPoint, onSetCoordinatePicker }) => { | |
| const [editingIndex, setEditingIndex] = useState<number | null>(null); | |
| const [editingInteractionType, setEditingInteractionType] = useState<InteractionType | null>(null); | |
| const [editDetails, setEditDetails] = useState(''); | |
| const [editTime, setEditTime] = useState(''); | |
| const [editX, setEditX] = useState(''); | |
| const [editY, setEditY] = useState(''); | |
| // Live seek video when editing timestamp | |
| useEffect(() => { | |
| if (editingIndex !== null && onSeekToTime) { | |
| const timeInSeconds = parseFloat(editTime); | |
| if (!isNaN(timeInSeconds) && timeInSeconds >= 0 && timeInSeconds <= videoDuration) { | |
| onSeekToTime(timeInSeconds); | |
| } | |
| } | |
| }, [editTime, editingIndex, onSeekToTime, videoDuration]); | |
| // Live update highlight marker when editing coordinates | |
| useEffect(() => { | |
| if (editingInteractionType === 'click' && editingIndex !== null) { | |
| const x = parseFloat(editX); | |
| const y = parseFloat(editY); | |
| if (!isNaN(x) && !isNaN(y)) { | |
| onHighlightPoint({ x, y, isEditing: true }); | |
| } | |
| } | |
| }, [editX, editY, editingInteractionType, editingIndex, onHighlightPoint]); | |
| const calculateTime = (frameIndex: number): number | null => { | |
| if (!videoDuration || !totalFrames || videoDuration === 0 || totalFrames === 0) return null; | |
| return (frameIndex / totalFrames) * videoDuration; | |
| }; | |
| const handleInteractionClick = (interaction: Interaction) => { | |
| const time = calculateTime(interaction.frameIndex); | |
| if (time !== null && onSeekToTime) { | |
| onSeekToTime(time); | |
| } | |
| }; | |
| const handleEditClick = (interaction: Interaction, index: number) => { | |
| setEditingIndex(index); | |
| setEditingInteractionType(interaction.type); | |
| setEditDetails(interaction.details); | |
| const time = calculateTime(interaction.frameIndex); | |
| setEditTime(time !== null ? time.toFixed(1) : ''); | |
| if (interaction.type === 'click') { | |
| setEditX(interaction.x?.toFixed(4) || '0.5'); | |
| setEditY(interaction.y?.toFixed(4) || '0.5'); | |
| onSetCoordinatePicker((coords) => { | |
| setEditX(coords.x.toFixed(4)); | |
| setEditY(coords.y.toFixed(4)); | |
| }); | |
| } | |
| }; | |
| const handleCancelEdit = () => { | |
| setEditingIndex(null); | |
| setEditingInteractionType(null); | |
| setEditDetails(''); | |
| setEditTime(''); | |
| setEditX(''); | |
| setEditY(''); | |
| onHighlightPoint(null); | |
| onSetCoordinatePicker(null); | |
| }; | |
| const handleSaveEdit = () => { | |
| if (editingIndex === null) return; | |
| const timeInSeconds = parseFloat(editTime); | |
| if (isNaN(timeInSeconds) || !videoDuration || !totalFrames) { | |
| console.error("Cannot save edit due to invalid time or video data."); | |
| return; | |
| } | |
| const newFrameIndex = Math.round((timeInSeconds / videoDuration) * totalFrames); | |
| const originalInteraction = task.interactions[editingIndex]; | |
| const updatedInteraction: Interaction = { | |
| ...originalInteraction, | |
| details: editDetails, | |
| frameIndex: newFrameIndex, | |
| }; | |
| if (updatedInteraction.type === 'click') { | |
| updatedInteraction.x = parseFloat(editX) || 0; | |
| updatedInteraction.y = parseFloat(editY) || 0; | |
| } | |
| onUpdateInteraction(task.id, editingIndex, updatedInteraction); | |
| handleCancelEdit(); | |
| }; | |
| const handleInteractionMouseEnter = (interaction: Interaction) => { | |
| if (interaction.type === 'click' && interaction.x !== undefined && interaction.y !== undefined) { | |
| onHighlightPoint({ x: interaction.x, y: interaction.y, isEditing: false }); | |
| } | |
| }; | |
| const handleInteractionMouseLeave = () => { | |
| onHighlightPoint(null); | |
| }; | |
| const segmentStartTime = formatTime(calculateTime(task.startFrame) ?? 0); | |
| const segmentEndTime = formatTime(calculateTime(task.endFrame) ?? 0); | |
| return ( | |
| <div className="bg-slate-900/70 border border-slate-700 rounded-xl p-4 transition-all hover:border-slate-600"> | |
| <div className="flex items-start gap-4"> | |
| <div className="flex-shrink-0 bg-slate-700 text-indigo-300 font-bold text-sm w-8 h-8 flex items-center justify-center rounded-full"> | |
| {task.id} | |
| </div> | |
| <div className="flex-grow"> | |
| <div className="flex justify-between items-start gap-2"> | |
| <p className="font-semibold text-slate-200 pr-2">{task.description}</p> | |
| {(calculateTime(task.startFrame) !== null) && ( | |
| <span className="text-xs font-mono text-slate-400 bg-slate-800 px-2 py-1 rounded-md whitespace-nowrap"> | |
| {segmentStartTime} - {segmentEndTime} | |
| </span> | |
| )} | |
| </div> | |
| {task.interactions && task.interactions.length > 0 && ( | |
| <div className="mt-3 space-y-1"> | |
| {task.interactions.map((interaction, index) => { | |
| if (editingIndex === index) { | |
| // EDITING VIEW | |
| return ( | |
| <div key={index} className="bg-slate-800 p-3 -mx-3 rounded-lg ring-2 ring-indigo-500"> | |
| <div className="flex items-start gap-3"> | |
| <div className="flex-shrink-0 pt-1"> | |
| {interactionIcons[interaction.type] || <div className="w-4 h-4" />} | |
| </div> | |
| <div className="flex-grow space-y-3"> | |
| <div> | |
| <label className="text-xs text-slate-400">Description</label> | |
| <input | |
| type="text" | |
| value={editDetails} | |
| onChange={(e) => setEditDetails(e.target.value)} | |
| className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-2"> | |
| <div> | |
| <label className="text-xs text-slate-400">Timestamp (s)</label> | |
| <input | |
| type="number" | |
| step="0.1" | |
| value={editTime} | |
| onChange={(e) => setEditTime(e.target.value)} | |
| className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400" | |
| /> | |
| </div> | |
| {interaction.type === 'click' && ( | |
| <> | |
| <div> | |
| <label className="text-xs text-slate-400">Coord. X</label> | |
| <input | |
| type="number" | |
| step="0.01" | |
| value={editX} | |
| onChange={(e) => setEditX(e.target.value)} | |
| className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs text-slate-400">Coord. Y</label> | |
| <input | |
| type="number" | |
| step="0.01" | |
| value={editY} | |
| onChange={(e) => setEditY(e.target.value)} | |
| className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400" | |
| /> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {interaction.type === 'click' && ( | |
| <p className="text-xs text-slate-400 italic">Click on the video player to update coordinates.</p> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex justify-end items-center gap-2 mt-3"> | |
| <button onClick={handleCancelEdit} className="px-3 py-1 text-sm font-semibold text-slate-300 hover:bg-slate-700 rounded-md">Cancel</button> | |
| <button onClick={handleSaveEdit} className="px-3 py-1 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-md">Save</button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // DISPLAY VIEW | |
| const interactionTime = calculateTime(interaction.frameIndex); | |
| const interactionCoords = formatCoords(interaction.x, interaction.y); | |
| return ( | |
| <div | |
| key={index} | |
| onMouseEnter={() => handleInteractionMouseEnter(interaction)} | |
| onMouseLeave={handleInteractionMouseLeave} | |
| className="group flex items-start gap-3 p-1.5 -mx-1.5 rounded-md transition-colors hover:bg-slate-800/60" | |
| > | |
| <div | |
| className="flex-shrink-0 pt-1 cursor-pointer" | |
| onClick={() => handleInteractionClick(interaction)} | |
| role="button" | |
| tabIndex={0} | |
| onKeyPress={(e) => (e.key === 'Enter' || e.key === ' ') && handleInteractionClick(interaction)} | |
| > | |
| {interactionIcons[interaction.type] || <div className="w-4 h-4" />} | |
| </div> | |
| <div | |
| className="flex-grow text-sm text-slate-400 cursor-pointer" | |
| onClick={() => handleInteractionClick(interaction)} | |
| role="button" | |
| > | |
| <span>{interaction.details}</span> | |
| {interaction.type === 'click' && interactionCoords && ( | |
| <span className="ml-2 font-mono text-xs text-cyan-400">{interactionCoords}</span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {interactionTime !== null && ( | |
| <span className="text-xs font-mono text-slate-500 whitespace-nowrap hidden group-hover:inline"> | |
| (~{formatTime(interactionTime)}) | |
| </span> | |
| )} | |
| <button | |
| onClick={() => handleEditClick(interaction, index)} | |
| className="hidden group-hover:block p-1 text-slate-500 hover:text-slate-300 transition-colors" | |
| aria-label="Edit interaction" | |
| > | |
| <Pencil className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |