From 21b8ef5cddab2c6a0ee768d07e1ffe1bd92fc875 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 5 Dec 2025 13:53:30 -0800 Subject: [PATCH] feat: add drag & drop to React aggregator UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/aggregator/web/app.tsx | 102 ++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/src/aggregator/web/app.tsx b/src/aggregator/web/app.tsx index 0f80af4..b9ac9db 100644 --- a/src/aggregator/web/app.tsx +++ b/src/aggregator/web/app.tsx @@ -37,6 +37,8 @@ function App() { const [filter, setFilter] = useState(""); const [selectedProject, setSelectedProject] = useState(null); const [actionError, setActionError] = useState(null); + const [draggedTask, setDraggedTask] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | 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 (
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) && (
- No tasks + {draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"}
)}
- ))} + ); + })} ); @@ -362,9 +427,12 @@ interface TaskCardProps { onDelete: (task: AggregatedTask) => void; onUpdate: (task: AggregatedTask, updates: Partial) => 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(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) => { e.stopPropagation(); onUpdate(task, { priority: e.target.value }); @@ -451,6 +533,9 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro return (
!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 */}