Spaces:
Running
Running
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> | |
); | |
}; | |