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

This commit is contained in:
sandrodach committed 2026-10-25 19:06:02 +01:00
1 parent c0661cddae
commit 35d4657a25
9 files changed
+814 -51

No files matched your search

+230 -5
View File
@@ -9,6 +9,7 @@ import {
Share,
X,
Hash,
GripVertical,
} from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
@@ -86,6 +87,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement | null>(null);
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const loadLists = async () => {
@@ -185,7 +190,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED'
msg.event === 'LIST_UNSHARED' ||
msg.event === 'ITEM_REORDERED'
) {
loadLists();
}
@@ -210,7 +216,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED' ||
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED'
msg.event === 'LIST_DELETED' ||
msg.event === 'ITEM_REORDERED'
) {
loadLists();
}
@@ -282,6 +289,179 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
);
};
const handleItemDragStart = (e: React.DragEvent, item: ListItem) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-list-item-id', item.id);
e.dataTransfer.setData('text/plain', item.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingItemId(item.id);
};
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
e.preventDefault();
e.stopPropagation();
if (!draggingItemId || draggingItemId === targetItemId) return;
const rect = e.currentTarget.getBoundingClientRect();
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
if (
!dragOverItemTarget ||
dragOverItemTarget.itemId !== targetItemId ||
dragOverItemTarget.position !== position
) {
setDragOverItemTarget({ itemId: targetItemId, position });
}
};
const handleItemDragLeave = (e: React.DragEvent, targetItemId: string) => {
e.stopPropagation();
if (dragOverItemTarget?.itemId === targetItemId) {
setDragOverItemTarget(null);
}
};
const handleDropOnItem = async (e: React.DragEvent, targetItem: ListItem, targetCategory: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-list-item-id') ||
e.dataTransfer.getData('text/plain') ||
draggingItemId;
const position = dragOverItemTarget?.position || 'after';
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
if (!activeList || !sourceId || sourceId === targetItem.id) return;
triggerHaptic(15);
const sourceItem = activeList.items.find((i) => i.id === sourceId);
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
const isCategoryChanged = (sourceItem.category || '').toLowerCase() !== targetCatNormalized.toLowerCase();
let newCategory = sourceItem.category;
let newContent = sourceItem.content;
if (isCategoryChanged) {
newCategory = targetCatNormalized;
if (!targetCatNormalized) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
} else if (newContent.includes('#')) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
}
}
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
const targetIndex = remainingItems.findIndex((i) => i.id === targetItem.id);
if (targetIndex === -1) return;
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1;
const updatedSourceItem: ListItem = {
...sourceItem,
category: newCategory,
content: newContent,
};
const newItems = [...remainingItems];
newItems.splice(insertIndex, 0, updatedSourceItem);
const reorderPayload: { id: string; sortOrder: number; category?: string; content?: string }[] = [];
newItems.forEach((it, idx) => {
it.sortOrder = idx;
if (it.id === sourceId) {
reorderPayload.push({
id: it.id,
sortOrder: idx,
category: newCategory,
content: newContent,
});
} else {
reorderPayload.push({
id: it.id,
sortOrder: idx,
});
}
});
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
);
try {
await api.reorderListItems(activeList.id, reorderPayload);
} catch {
loadLists();
}
};
const handleDropOnCategoryHeader = async (e: React.DragEvent, targetCategory: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-list-item-id') ||
e.dataTransfer.getData('text/plain') ||
draggingItemId;
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
if (!activeList || !sourceId) return;
triggerHaptic(15);
const sourceItem = activeList.items.find((i) => i.id === sourceId);
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
let newCategory = targetCatNormalized;
let newContent = sourceItem.content;
if (!targetCatNormalized) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
} else if (newContent.includes('#')) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
}
const updatedSourceItem: ListItem = {
...sourceItem,
category: newCategory,
content: newContent,
};
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
const newItems = [updatedSourceItem, ...remainingItems];
const reorderPayload = newItems.map((it, idx) => {
it.sortOrder = idx;
if (it.id === sourceId) {
return {
id: it.id,
sortOrder: idx,
category: newCategory,
content: newContent,
};
}
return { id: it.id, sortOrder: idx };
});
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
);
try {
await api.reorderListItems(activeList.id, reorderPayload);
} catch {
loadLists();
}
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!newListTitle.trim()) return;
@@ -764,18 +944,63 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
{/* Lighter panel-surface card container with items */}
<div className="panel-surface divide-y divide-border overflow-hidden">
<div
onDragOver={(e) => {
e.preventDefault();
if (draggingItemId) setDragOverCategoryTarget(group.category);
}}
onDragLeave={() => {
if (dragOverCategoryTarget === group.category) setDragOverCategoryTarget(null);
}}
onDrop={(e) => handleDropOnCategoryHeader(e, group.category)}
className={`panel-surface divide-y divide-border overflow-hidden transition-all ${
dragOverCategoryTarget === group.category && !dragOverItemTarget
? 'ring-2 ring-accent/60 bg-accent/5'
: ''
}`}
>
{group.items.map((item) => {
const cat = getItemCategory(item);
const displayContent = getItemDisplayContent(item);
const isDragging = draggingItemId === item.id;
const isTarget = dragOverItemTarget?.itemId === item.id;
return (
<div
key={item.id}
onDragOver={(e) => handleItemDragOver(e, item.id)}
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
onDrop={(e) => handleDropOnItem(e, item, group.category)}
onClick={() => handleToggleItem(item)}
className="px-3 py-2.5 flex items-center justify-between gap-3 cursor-pointer transition-colors hover:bg-panel-hover group"
className={`px-3 py-2.5 flex items-center justify-between gap-3 cursor-pointer transition-colors hover:bg-panel-hover group relative ${
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 && dragOverItemTarget.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 && dragOverItemTarget.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 min-w-0 flex-1">
{/* Grip handle for dragging */}
<div
draggable
onDragStart={(e) => handleItemDragStart(e, item)}
onDragEnd={() => {
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(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="Ziehen zum Sortieren oder in andere Hashtag-Kategorie schieben"
>
<GripVertical className="w-3.5 h-3.5" />
</div>
{/* Eckiges Abhaken */}
<button
type="button"
+285 -14
View File
@@ -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) => (
+125 -19
View File
@@ -171,6 +171,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop reordering state for blocks
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' } | null>(null);
// --- GLOBAL TO-DO ROLLUP STATE ---
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
@@ -1911,35 +1912,117 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return childTodos;
};
// Drag & Drop Handlers on the Bullet Dot
const handleDragStart = (e: React.DragEvent, blockId: string) => {
// Helper: Get subtree count (root + all indented children)
const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => {
if (startIndex < 0 || startIndex >= blocks.length) return 0;
const rootLevel = blocks[startIndex].level || 0;
let count = 1;
for (let i = startIndex + 1; i < blocks.length; i++) {
if ((blocks[i].level || 0) > rootLevel) {
count++;
} else {
break;
}
}
return count;
};
// Helper: Check if target block is a descendant of source block
const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => {
if (sourceIdx < 0 || sourceIdx >= blocks.length) return false;
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) {
if (blocks[i]?.id === targetBlockId) return true;
}
return false;
};
// Drag & Drop Handlers on the Bullet row / Grip / Dot
const handleBlockDragStart = (e: React.DragEvent, blockId: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-block-id', blockId);
e.dataTransfer.setData('text/plain', blockId);
e.dataTransfer.effectAllowed = 'move';
setDraggingBlockId(blockId);
};
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => {
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
e.stopPropagation();
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
const rect = e.currentTarget.getBoundingClientRect();
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
if (
!dragOverBlockTarget ||
dragOverBlockTarget.blockId !== targetBlockId ||
dragOverBlockTarget.position !== position
) {
setDragOverBlockTarget({ blockId: targetBlockId, position });
}
};
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
e.stopPropagation();
if (dragOverBlockTarget?.blockId === targetBlockId) {
setDragOverBlockTarget(null);
}
};
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-block-id') ||
e.dataTransfer.getData('text/plain') ||
draggingBlockId;
const position = dragOverBlockTarget?.position || 'after';
setDraggingBlockId(null);
setDragOverBlockTarget(null);
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
setDraggingBlockId(null);
return;
}
const blocks = [...selectedDoc.blocks];
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
if (sourceIdx === -1 || targetIdx === -1) {
setDraggingBlockId(null);
if (sourceIdx === -1) return;
// Prevent circular drag into one's own children
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
return;
}
const [moved] = blocks.splice(sourceIdx, 1);
// Align moved block's level to target
moved.level = blocks[targetIdx]?.level || 0;
blocks.splice(targetIdx, 0, moved);
// Extract the entire subtree (parent + all indented children)
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
const subtree = blocks.splice(sourceIdx, subtreeCount);
// Find new index of target block after splice
const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId);
if (newTargetIdx === -1) return;
const targetBlock = blocks[newTargetIdx];
const targetLevel = targetBlock.level || 0;
const oldRootLevel = subtree[0].level || 0;
const levelDiff = targetLevel - oldRootLevel;
// Adjust levels of all blocks in subtree to match target hierarchy
for (const b of subtree) {
b.level = Math.max(0, (b.level || 0) + levelDiff);
}
// Calculate insertion index
let insertIdx = newTargetIdx;
if (position === 'after') {
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
insertIdx = newTargetIdx + targetSubtreeCount;
}
blocks.splice(insertIdx, 0, ...subtree);
persistDocument({ ...selectedDoc, blocks }, true);
setDraggingBlockId(null);
};
// Export full document as portable Logseq-indented Markdown
@@ -3413,16 +3496,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{visibleBlocks.map(({ block, originalIndex }) => {
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
const isDraggingThis = draggingBlockId === block.id;
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
return (
<div
key={block.id}
onDragOver={(e) => !isReadOnly && handleBlockDragOver(e, block.id)}
onDragLeave={(e) => !isReadOnly && handleBlockDragLeave(e, block.id)}
onDrop={(e) => !isReadOnly && handleBlockDrop(e, block.id)}
style={{ paddingLeft: `${blockLevel * 22}px` }}
className="group flex items-start gap-1 py-0.5 relative transition-all"
className={`group flex items-start gap-1 py-0.5 relative transition-all ${
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40 rounded' : ''
}`}
>
{/* Drop indicator lines */}
{isTargetThis && dragOverBlockTarget.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)]" />
)}
{isTargetThis && dragOverBlockTarget.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)]" />
)}
{/* Drag Handle on hover */}
<div
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-muted cursor-grab flex-shrink-0"
draggable={!isReadOnly}
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
onDragEnd={() => {
setDraggingBlockId(null);
setDragOverBlockTarget(null);
}}
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0"
title="Ziehen zum Verschieben"
>
<GripVertical className="w-3 h-3" />
@@ -3449,14 +3553,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
<div
draggable={!isReadOnly}
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)}
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
onDragEnd={() => {
setDraggingBlockId(null);
setDragOverBlockTarget(null);
}}
onClick={() => handleZoomTo(block.id)}
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
isReadOnly
? 'cursor-pointer text-muted'
: 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent'
: 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent'
}`}
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
>
+18
View File
@@ -310,6 +310,14 @@ export const api = {
return ensureArray(res);
},
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, {
method: 'POST',
body: JSON.stringify({ items }),
});
},
async updateThought(
thoughtId: string,
patch: {
@@ -717,6 +725,16 @@ export const api = {
});
},
async reorderListItems(
listId: string,
items: { id: string; sortOrder: number; category?: string; content?: string }[]
): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, {
method: 'POST',
body: JSON.stringify({ items }),
});
},
// --- ORGANIZATIONS & TEAMS ---
async getOrganizations(): Promise<Organization[]> {
const res = await request<Organization[]>('/api/v1/organizations');
+1
View File
@@ -60,6 +60,7 @@ export interface Thought {
notes?: string;
subtasks?: SubTask[];
alarmSent?: boolean;
sortOrder?: number;
createdAt: string;
triagedAt?: string;
}