feat: add drag & drop to React aggregator UI

- Add drag state management for tasks and columns
- Implement column drop handlers with visual feedback
- Add task card drag handlers with data transfer
- Highlight drop targets with dashed border
- Show "Drop here to move task" placeholder in empty columns
- Add optimistic UI updates for archive/delete actions
- Reduce opacity on dragged task for visual feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-05 13:53:30 -08:00
parent 6c3563c7e4
commit 21b8ef5cdd
1 changed files with 94 additions and 8 deletions

View File

@ -37,6 +37,8 @@ function App() {
const [filter, setFilter] = useState<string>("");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<AggregatedTask | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -51,6 +53,8 @@ function App() {
const data = await response.json();
throw new Error(data.error || "Failed to archive task");
}
// Optimistically remove from UI immediately
setTasks((prev) => prev.filter((t) => !(t.projectPath === task.projectPath && t.id === task.id)));
setActionError(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to archive task");
@ -72,6 +76,8 @@ function App() {
const data = await response.json();
throw new Error(data.error || "Failed to delete task");
}
// Optimistically remove from UI immediately
setTasks((prev) => prev.filter((t) => !(t.projectPath === task.projectPath && t.id === task.id)));
setActionError(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to delete task");
@ -101,6 +107,54 @@ function App() {
}
};
const handleColumnDragOver = (e: React.DragEvent, status: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverColumn(status);
};
const handleColumnDragLeave = (e: React.DragEvent) => {
// Only clear if leaving the column entirely
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOverColumn(null);
}
};
const handleColumnDrop = async (e: React.DragEvent, targetStatus: string) => {
e.preventDefault();
setDragOverColumn(null);
setDraggedTask(null);
try {
const data = e.dataTransfer.getData("text/plain");
if (!data) return;
const { projectPath, taskId, sourceStatus } = JSON.parse(data);
// Don't update if dropped on same status
if (sourceStatus === targetStatus) return;
// Find the task and update it
const task = tasks.find(t => t.projectPath === projectPath && t.id === taskId);
if (task) {
await handleUpdateTask(task, { status: targetStatus });
}
} catch (error) {
console.error("Drop error:", error);
setActionError("Failed to move task");
setTimeout(() => setActionError(null), 3000);
}
};
const handleTaskDragStart = (task: AggregatedTask) => {
setDraggedTask(task);
};
const handleTaskDragEnd = () => {
setDraggedTask(null);
setDragOverColumn(null);
};
const connectWebSocket = useCallback(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}`);
@ -296,15 +350,22 @@ function App() {
minHeight: "calc(100vh - 180px)",
}}
>
{statuses.map((status) => (
{statuses.map((status) => {
const isDropTarget = dragOverColumn === status && draggedTask?.status !== status;
return (
<div
key={status}
onDragOver={(e) => handleColumnDragOver(e, status)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, status)}
style={{
backgroundColor: "#1e293b",
backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b",
borderRadius: "0.5rem",
padding: "1rem",
display: "flex",
flexDirection: "column",
border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent",
transition: "all 0.2s ease",
}}
>
{/* Column Header */}
@ -341,16 +402,20 @@ function App() {
onDelete={handleDeleteTask}
onUpdate={handleUpdateTask}
statuses={statuses}
onDragStart={handleTaskDragStart}
onDragEnd={handleTaskDragEnd}
isDragging={draggedTask?.id === task.id && draggedTask?.projectPath === task.projectPath}
/>
))}
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
No tasks
{draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"}
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>
);
@ -362,9 +427,12 @@ interface TaskCardProps {
onDelete: (task: AggregatedTask) => void;
onUpdate: (task: AggregatedTask, updates: Partial<AggregatedTask>) => void;
statuses: string[];
onDragStart?: (task: AggregatedTask) => void;
onDragEnd?: () => void;
isDragging?: boolean;
}
function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardProps) {
function TaskCard({ task, onArchive, onDelete, onUpdate, statuses, onDragStart, onDragEnd, isDragging }: TaskCardProps) {
const [expanded, setExpanded] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [editingField, setEditingField] = useState<string | null>(null);
@ -422,6 +490,20 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro
onUpdate(task, { status: e.target.value });
};
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", JSON.stringify({
projectPath: task.projectPath,
taskId: task.id,
sourceStatus: task.status,
}));
e.dataTransfer.effectAllowed = "move";
onDragStart?.(task);
};
const handleDragEnd = () => {
onDragEnd?.();
};
const handlePriorityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation();
onUpdate(task, { priority: e.target.value });
@ -451,6 +533,9 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro
return (
<div
draggable={!editingField}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={() => !editingField && setExpanded(!expanded)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
@ -458,12 +543,13 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro
backgroundColor: "#0f172a",
borderRadius: "0.375rem",
padding: "0.75rem",
cursor: editingField ? "default" : "pointer",
cursor: editingField ? "default" : isDragging ? "grabbing" : "grab",
borderLeft: `4px solid ${task.projectColor}`,
transition: "transform 0.1s, box-shadow 0.1s",
transition: "transform 0.1s, box-shadow 0.1s, opacity 0.1s",
position: "relative",
transform: isHovering && !editingField ? "translateY(-1px)" : "translateY(0)",
transform: isHovering && !editingField && !isDragging ? "translateY(-1px)" : "translateY(0)",
boxShadow: isHovering && !editingField ? "0 4px 6px -1px rgba(0, 0, 0, 0.3)" : "none",
opacity: isDragging ? 0.5 : 1,
}}
>
{/* Action buttons - shown on hover */}