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 [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 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue