feat: add drag-and-drop for tasks, workspace bullets, and list items
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
c0661cddae
commit
35d4657a25
9 files changed
+814
-51
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -65,6 +66,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Drag and Drop state
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [isDragOverTopZone, setIsDragOverTopZone] = useState<boolean>(false);
|
||||
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
||||
|
||||
// Quick Task Add state
|
||||
const [quickTitle, setQuickTitle] = useState('');
|
||||
const [quickDueDate, setQuickDueDate] = useState('');
|
||||
@@ -189,6 +196,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} else if (msg.event === 'TASK_REORDERED') {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -536,6 +545,180 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-task-id', task.id);
|
||||
e.dataTransfer.setData('text/plain', task.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingTaskId(task.id);
|
||||
};
|
||||
|
||||
const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingTaskId || draggingTaskId === targetTaskId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
||||
|
||||
if (
|
||||
!dragOverTaskTarget ||
|
||||
dragOverTaskTarget.taskId !== targetTaskId ||
|
||||
dragOverTaskTarget.position !== position
|
||||
) {
|
||||
setDragOverTaskTarget({ taskId: targetTaskId, position });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => {
|
||||
e.stopPropagation();
|
||||
if (dragOverTaskTarget?.taskId === targetTaskId) {
|
||||
setDragOverTaskTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingTaskId;
|
||||
|
||||
const position = dragOverTaskTarget?.position || 'after';
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setIsDragOverTopZone(false);
|
||||
setDragOverKanbanCol(null);
|
||||
|
||||
if (!sourceId || sourceId === targetTask.id) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||||
if (!sourceTask) return;
|
||||
|
||||
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
||||
const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id);
|
||||
if (targetIdx === -1) return;
|
||||
|
||||
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
|
||||
const updatedSourceTask: Thought = {
|
||||
...sourceTask,
|
||||
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
|
||||
isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
|
||||
};
|
||||
|
||||
const newTasks = [...remainingTasks];
|
||||
newTasks.splice(insertIdx, 0, updatedSourceTask);
|
||||
|
||||
const reorderPayload = newTasks.map((t, idx) => ({
|
||||
id: t.id,
|
||||
sortOrder: idx,
|
||||
kanbanStatus: t.kanbanStatus,
|
||||
}));
|
||||
|
||||
setTasks(newTasks);
|
||||
|
||||
try {
|
||||
await api.reorderTasks(
|
||||
reorderPayload,
|
||||
selectedListId !== 'all' ? selectedListId : undefined
|
||||
);
|
||||
} catch {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropAtTop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingTaskId;
|
||||
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setIsDragOverTopZone(false);
|
||||
setDragOverKanbanCol(null);
|
||||
|
||||
if (!sourceId) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||||
if (!sourceTask) return;
|
||||
|
||||
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
||||
const newTasks = [sourceTask, ...remainingTasks];
|
||||
|
||||
const reorderPayload = newTasks.map((t, idx) => ({
|
||||
id: t.id,
|
||||
sortOrder: idx,
|
||||
kanbanStatus: t.kanbanStatus,
|
||||
}));
|
||||
|
||||
setTasks(newTasks);
|
||||
|
||||
try {
|
||||
await api.reorderTasks(
|
||||
reorderPayload,
|
||||
selectedListId !== 'all' ? selectedListId : undefined
|
||||
);
|
||||
} catch {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingTaskId;
|
||||
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setIsDragOverTopZone(false);
|
||||
setDragOverKanbanCol(null);
|
||||
|
||||
if (!sourceId) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||||
if (!sourceTask) return;
|
||||
|
||||
const isDone = colId === 'done';
|
||||
const updatedSourceTask: Thought = {
|
||||
...sourceTask,
|
||||
kanbanStatus: colId,
|
||||
isCompleted: isDone,
|
||||
};
|
||||
|
||||
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
||||
const newTasks = [updatedSourceTask, ...remainingTasks];
|
||||
|
||||
const reorderPayload = newTasks.map((t, idx) => ({
|
||||
id: t.id,
|
||||
sortOrder: idx,
|
||||
kanbanStatus: t.kanbanStatus,
|
||||
}));
|
||||
|
||||
setTasks(newTasks);
|
||||
|
||||
try {
|
||||
await api.reorderTasks(
|
||||
reorderPayload,
|
||||
selectedListId !== 'all' ? selectedListId : undefined
|
||||
);
|
||||
} catch {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newListTitle.trim();
|
||||
@@ -704,15 +887,45 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Clean Task Row in List View
|
||||
const renderTaskRow = (task: Thought) => {
|
||||
const isCompleted = task.isCompleted;
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`px-3 py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 ${
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task)}
|
||||
className={`px-3 py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative ${
|
||||
isCompleted ? 'opacity-65' : ''
|
||||
}`}
|
||||
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Drop insertion line indicator */}
|
||||
{isTarget && dragOverTaskTarget.position === 'before' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
{isTarget && dragOverTaskTarget.position === 'after' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{/* Grip handle for dragging */}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setIsDragOverTopZone(false);
|
||||
setDragOverKanbanCol(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="opacity-0 group-hover:opacity-70 hover:opacity-100 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 transition-opacity"
|
||||
title="Aufgabe ziehen zum Sortieren"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1341,7 +1554,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={col.id} className="bg-canvas border border-border flex flex-col min-h-[420px]">
|
||||
<div
|
||||
key={col.id}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingTaskId) setDragOverKanbanCol(col.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${
|
||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
@@ -1363,15 +1589,39 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* Tasks List in Column */}
|
||||
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
||||
{colTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => {
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-pointer shadow-sm group"
|
||||
>
|
||||
{colTasks.map((task) => {
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setIsDragOverTopZone(false);
|
||||
setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||
onClick={() => {
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Drop insertion line indicator */}
|
||||
{isTarget && dragOverTaskTarget.position === 'before' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
{isTarget && dragOverTaskTarget.position === 'after' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||||
@@ -1481,7 +1731,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{colTasks.length === 0 && (
|
||||
<div className="p-4 text-center text-faint text-xs italic">
|
||||
@@ -1584,6 +1835,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Dedicated Top Drop Zone when dragging a task */}
|
||||
{draggingTaskId && (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOverTopZone(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOverTopZone(false)}
|
||||
onDrop={(e) => handleDropAtTop(e)}
|
||||
className={`p-3 text-center text-xs font-semibold rounded-card border-2 border-dashed transition-all flex items-center justify-center gap-2 cursor-pointer ${
|
||||
isDragOverTopZone
|
||||
? 'border-accent bg-accent/20 text-accent scale-[1.01] shadow-lg'
|
||||
: 'border-accent/40 text-accent bg-accent/5 hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 -rotate-90 text-accent animate-bounce" />
|
||||
<span>Ganz oben in der Liste ablegen</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedTasks ? (
|
||||
/* GROUPED LIST */
|
||||
groupedTasks.map((grp) => (
|
||||
|
||||
Reference in new issue
Block a user