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:
parent
6c3563c7e4
commit
21b8ef5cdd
|
|
@ -37,6 +37,8 @@ function App() {
|
||||||
const [filter, setFilter] = useState<string>("");
|
const [filter, setFilter] = useState<string>("");
|
||||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = 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 wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -51,6 +53,8 @@ function App() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || "Failed to archive task");
|
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);
|
setActionError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setActionError(error instanceof Error ? error.message : "Failed to archive task");
|
setActionError(error instanceof Error ? error.message : "Failed to archive task");
|
||||||
|
|
@ -72,6 +76,8 @@ function App() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || "Failed to delete task");
|
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);
|
setActionError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setActionError(error instanceof Error ? error.message : "Failed to delete task");
|
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 connectWebSocket = useCallback(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||||
|
|
@ -296,15 +350,22 @@ function App() {
|
||||||
minHeight: "calc(100vh - 180px)",
|
minHeight: "calc(100vh - 180px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => {
|
||||||
|
const isDropTarget = dragOverColumn === status && draggedTask?.status !== status;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={status}
|
key={status}
|
||||||
|
onDragOver={(e) => handleColumnDragOver(e, status)}
|
||||||
|
onDragLeave={handleColumnDragLeave}
|
||||||
|
onDrop={(e) => handleColumnDrop(e, status)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#1e293b",
|
backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
|
|
@ -341,16 +402,20 @@ function App() {
|
||||||
onDelete={handleDeleteTask}
|
onDelete={handleDeleteTask}
|
||||||
onUpdate={handleUpdateTask}
|
onUpdate={handleUpdateTask}
|
||||||
statuses={statuses}
|
statuses={statuses}
|
||||||
|
onDragStart={handleTaskDragStart}
|
||||||
|
onDragEnd={handleTaskDragEnd}
|
||||||
|
isDragging={draggedTask?.id === task.id && draggedTask?.projectPath === task.projectPath}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
|
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
|
||||||
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
|
<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>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -362,9 +427,12 @@ interface TaskCardProps {
|
||||||
onDelete: (task: AggregatedTask) => void;
|
onDelete: (task: AggregatedTask) => void;
|
||||||
onUpdate: (task: AggregatedTask, updates: Partial<AggregatedTask>) => void;
|
onUpdate: (task: AggregatedTask, updates: Partial<AggregatedTask>) => void;
|
||||||
statuses: string[];
|
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 [expanded, setExpanded] = useState(false);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const [editingField, setEditingField] = useState<string | null>(null);
|
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 });
|
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>) => {
|
const handlePriorityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onUpdate(task, { priority: e.target.value });
|
onUpdate(task, { priority: e.target.value });
|
||||||
|
|
@ -451,6 +533,9 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
draggable={!editingField}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
onClick={() => !editingField && setExpanded(!expanded)}
|
onClick={() => !editingField && setExpanded(!expanded)}
|
||||||
onMouseEnter={() => setIsHovering(true)}
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
onMouseLeave={() => setIsHovering(false)}
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
|
@ -458,12 +543,13 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardPro
|
||||||
backgroundColor: "#0f172a",
|
backgroundColor: "#0f172a",
|
||||||
borderRadius: "0.375rem",
|
borderRadius: "0.375rem",
|
||||||
padding: "0.75rem",
|
padding: "0.75rem",
|
||||||
cursor: editingField ? "default" : "pointer",
|
cursor: editingField ? "default" : isDragging ? "grabbing" : "grab",
|
||||||
borderLeft: `4px solid ${task.projectColor}`,
|
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",
|
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",
|
boxShadow: isHovering && !editingField ? "0 4px 6px -1px rgba(0, 0, 0, 0.3)" : "none",
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Action buttons - shown on hover */}
|
{/* Action buttons - shown on hover */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue