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
+806
-43
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
Share,
|
Share,
|
||||||
X,
|
X,
|
||||||
Hash,
|
Hash,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||||
@@ -86,6 +87,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
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 wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const loadLists = async () => {
|
const loadLists = async () => {
|
||||||
@@ -185,7 +190,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
msg.event === 'LIST_CREATED' ||
|
msg.event === 'LIST_CREATED' ||
|
||||||
msg.event === 'LIST_DELETED' ||
|
msg.event === 'LIST_DELETED' ||
|
||||||
msg.event === 'LIST_SHARED' ||
|
msg.event === 'LIST_SHARED' ||
|
||||||
msg.event === 'LIST_UNSHARED'
|
msg.event === 'LIST_UNSHARED' ||
|
||||||
|
msg.event === 'ITEM_REORDERED'
|
||||||
) {
|
) {
|
||||||
loadLists();
|
loadLists();
|
||||||
}
|
}
|
||||||
@@ -210,7 +216,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
msg.event === 'LIST_SHARED' ||
|
msg.event === 'LIST_SHARED' ||
|
||||||
msg.event === 'LIST_UNSHARED' ||
|
msg.event === 'LIST_UNSHARED' ||
|
||||||
msg.event === 'LIST_CREATED' ||
|
msg.event === 'LIST_CREATED' ||
|
||||||
msg.event === 'LIST_DELETED'
|
msg.event === 'LIST_DELETED' ||
|
||||||
|
msg.event === 'ITEM_REORDERED'
|
||||||
) {
|
) {
|
||||||
loadLists();
|
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) => {
|
const handleCreateList = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newListTitle.trim()) return;
|
if (!newListTitle.trim()) return;
|
||||||
@@ -764,18 +944,63 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lighter panel-surface card container with items */}
|
{/* 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) => {
|
{group.items.map((item) => {
|
||||||
const cat = getItemCategory(item);
|
const cat = getItemCategory(item);
|
||||||
const displayContent = getItemDisplayContent(item);
|
const displayContent = getItemDisplayContent(item);
|
||||||
|
const isDragging = draggingItemId === item.id;
|
||||||
|
const isTarget = dragOverItemTarget?.itemId === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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)}
|
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 */}
|
{/* Eckiges Abhaken */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
@@ -65,6 +66,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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
|
// Quick Task Add state
|
||||||
const [quickTitle, setQuickTitle] = useState('');
|
const [quickTitle, setQuickTitle] = useState('');
|
||||||
const [quickDueDate, setQuickDueDate] = useState('');
|
const [quickDueDate, setQuickDueDate] = useState('');
|
||||||
@@ -189,6 +196,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||||||
onRefreshStatsRef.current();
|
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) => {
|
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const title = newListTitle.trim();
|
const title = newListTitle.trim();
|
||||||
@@ -704,15 +887,45 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
// Clean Task Row in List View
|
// Clean Task Row in List View
|
||||||
const renderTaskRow = (task: Thought) => {
|
const renderTaskRow = (task: Thought) => {
|
||||||
const isCompleted = task.isCompleted;
|
const isCompleted = task.isCompleted;
|
||||||
|
const isDragging = draggingTaskId === task.id;
|
||||||
|
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
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' : ''
|
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 */}
|
{/* Checkbox */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1341,7 +1554,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Column Header */}
|
||||||
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
|
<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">
|
<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 */}
|
{/* Tasks List in Column */}
|
||||||
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
||||||
{colTasks.map((task) => (
|
{colTasks.map((task) => {
|
||||||
|
const isDragging = draggingTaskId === task.id;
|
||||||
|
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
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={() => {
|
onClick={() => {
|
||||||
setDetailTaskId(task.id);
|
setDetailTaskId(task.id);
|
||||||
setShowDetailModal(true);
|
setShowDetailModal(true);
|
||||||
}}
|
}}
|
||||||
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-pointer shadow-sm group"
|
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">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p
|
<p
|
||||||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||||||
@@ -1481,7 +1731,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{colTasks.length === 0 && (
|
{colTasks.length === 0 && (
|
||||||
<div className="p-4 text-center text-faint text-xs italic">
|
<div className="p-4 text-center text-faint text-xs italic">
|
||||||
@@ -1584,6 +1835,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<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 ? (
|
{groupedTasks ? (
|
||||||
/* GROUPED LIST */
|
/* GROUPED LIST */
|
||||||
groupedTasks.map((grp) => (
|
groupedTasks.map((grp) => (
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
// Drag and Drop reordering state for blocks
|
// Drag and Drop reordering state for blocks
|
||||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||||
|
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' } | null>(null);
|
||||||
|
|
||||||
// --- GLOBAL TO-DO ROLLUP STATE ---
|
// --- GLOBAL TO-DO ROLLUP STATE ---
|
||||||
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
||||||
@@ -1911,35 +1912,117 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return childTodos;
|
return childTodos;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag & Drop Handlers on the Bullet Dot
|
// Helper: Get subtree count (root + all indented children)
|
||||||
const handleDragStart = (e: React.DragEvent, blockId: string) => {
|
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.setData('text/plain', blockId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
setDraggingBlockId(blockId);
|
setDraggingBlockId(blockId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => {
|
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
|
e.stopPropagation();
|
||||||
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
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);
|
setDraggingBlockId(null);
|
||||||
|
setDragOverBlockTarget(null);
|
||||||
|
|
||||||
|
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = [...selectedDoc.blocks];
|
const blocks = [...selectedDoc.blocks];
|
||||||
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
|
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
|
||||||
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
if (sourceIdx === -1) return;
|
||||||
if (sourceIdx === -1 || targetIdx === -1) {
|
|
||||||
setDraggingBlockId(null);
|
// Prevent circular drag into one's own children
|
||||||
|
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [moved] = blocks.splice(sourceIdx, 1);
|
// Extract the entire subtree (parent + all indented children)
|
||||||
// Align moved block's level to target
|
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
||||||
moved.level = blocks[targetIdx]?.level || 0;
|
const subtree = blocks.splice(sourceIdx, subtreeCount);
|
||||||
blocks.splice(targetIdx, 0, moved);
|
|
||||||
|
|
||||||
|
// 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);
|
persistDocument({ ...selectedDoc, blocks }, true);
|
||||||
setDraggingBlockId(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export full document as portable Logseq-indented Markdown
|
// Export full document as portable Logseq-indented Markdown
|
||||||
@@ -3413,16 +3496,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{visibleBlocks.map(({ block, originalIndex }) => {
|
{visibleBlocks.map(({ block, originalIndex }) => {
|
||||||
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
|
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
|
||||||
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
|
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
|
||||||
|
const isDraggingThis = draggingBlockId === block.id;
|
||||||
|
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={block.id}
|
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` }}
|
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 */}
|
{/* Drag Handle on hover */}
|
||||||
<div
|
<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"
|
title="Ziehen zum Verschieben"
|
||||||
>
|
>
|
||||||
<GripVertical className="w-3 h-3" />
|
<GripVertical className="w-3 h-3" />
|
||||||
@@ -3449,14 +3553,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
|
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
|
||||||
<div
|
<div
|
||||||
draggable={!isReadOnly}
|
draggable={!isReadOnly}
|
||||||
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)}
|
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragEnd={() => {
|
||||||
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)}
|
setDraggingBlockId(null);
|
||||||
|
setDragOverBlockTarget(null);
|
||||||
|
}}
|
||||||
onClick={() => handleZoomTo(block.id)}
|
onClick={() => handleZoomTo(block.id)}
|
||||||
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
|
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
|
||||||
isReadOnly
|
isReadOnly
|
||||||
? 'cursor-pointer text-muted'
|
? '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"
|
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ export const api = {
|
|||||||
return ensureArray(res);
|
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(
|
async updateThought(
|
||||||
thoughtId: string,
|
thoughtId: string,
|
||||||
patch: {
|
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 ---
|
// --- ORGANIZATIONS & TEAMS ---
|
||||||
async getOrganizations(): Promise<Organization[]> {
|
async getOrganizations(): Promise<Organization[]> {
|
||||||
const res = await request<Organization[]>('/api/v1/organizations');
|
const res = await request<Organization[]>('/api/v1/organizations');
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface Thought {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
triagedAt?: string;
|
triagedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
||||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||||
|
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||||
|
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
||||||
|
|
||||||
// Task Lists & Shares
|
// Task Lists & Shares
|
||||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||||
@@ -137,6 +139,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||||
|
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
|
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||||||
|
|
||||||
// Organizations & Teams
|
// Organizations & Teams
|
||||||
@@ -678,6 +682,34 @@ func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, tasks)
|
writeJSON(w, http.StatusOK, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
taskListID := chi.URLParam(r, "id")
|
||||||
|
if taskListID == "" {
|
||||||
|
taskListID = r.URL.Query().Get("taskListId")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Items []domain.ThoughtReorderItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
|
||||||
|
"taskListId": taskListID,
|
||||||
|
"items": req.Items,
|
||||||
|
"userId": userID,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
thoughtID := chi.URLParam(r, "id")
|
thoughtID := chi.URLParam(r, "id")
|
||||||
@@ -1278,6 +1310,28 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
listID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
Items []domain.ListItemReorderItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
|
||||||
|
"listId": listID,
|
||||||
|
"items": req.Items,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
roomID := chi.URLParam(r, "listId")
|
roomID := chi.URLParam(r, "listId")
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
|
|||||||
@@ -154,10 +154,24 @@ type Thought struct {
|
|||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Subtasks []SubTask `json:"subtasks"`
|
Subtasks []SubTask `json:"subtasks"`
|
||||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
TriagedAt string `json:"triagedAt,omitempty"`
|
TriagedAt string `json:"triagedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThoughtReorderItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
KanbanStatus *string `json:"kanbanStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListItemReorderItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Content *string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type TaskListShare struct {
|
type TaskListShare struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TaskListID string `json:"taskListId"`
|
TaskListID string `json:"taskListId"`
|
||||||
|
|||||||
+84
-12
@@ -319,6 +319,8 @@ func (s *Store) migrate() error {
|
|||||||
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
||||||
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||||||
|
"ALTER TABLE thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);",
|
||||||
|
|
||||||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||||||
|
|
||||||
@@ -835,16 +837,17 @@ func scanThought(rows interface {
|
|||||||
}) (*domain.Thought, error) {
|
}) (*domain.Thought, error) {
|
||||||
t := &domain.Thought{}
|
t := &domain.Thought{}
|
||||||
var tagsStr, subtasksStr, statusStr string
|
var tagsStr, subtasksStr, statusStr string
|
||||||
var isCompleted, alarmSent int
|
var isCompleted, alarmSent, sortOrder int
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr,
|
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.SortOrder = sortOrder
|
||||||
t.Status = domain.ThoughtStatus(statusStr)
|
t.Status = domain.ThoughtStatus(statusStr)
|
||||||
t.IsCompleted = isCompleted != 0
|
t.IsCompleted = isCompleted != 0
|
||||||
t.AlarmSent = alarmSent != 0
|
t.AlarmSent = alarmSent != 0
|
||||||
@@ -877,7 +880,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
|||||||
|
|
||||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||||
thoughtID, userID, userID, userID, userID, userID,
|
thoughtID, userID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
@@ -893,16 +896,16 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
|||||||
|
|
||||||
if statusFilter != "" && statusFilter != "all" {
|
if statusFilter != "" && statusFilter != "all" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts WHERE user_id = ? AND status = ?
|
FROM thoughts WHERE user_id = ? AND status = ?
|
||||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, statusFilter, limit,
|
userID, statusFilter, limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts WHERE user_id = ?
|
FROM thoughts WHERE user_id = ?
|
||||||
ORDER BY created_at DESC LIMIT ?`,
|
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, limit,
|
userID, limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -926,21 +929,21 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
var err error
|
var err error
|
||||||
if taskListID != "" {
|
if taskListID != "" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND task_list_id = ?
|
AND task_list_id = ?
|
||||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||||
userID, userID, userID, userID, userID, taskListID,
|
userID, userID, userID, userID, userID, taskListID,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||||
userID, userID, userID, userID, userID,
|
userID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -959,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, it := range items {
|
||||||
|
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||||
|
isCompleted := 0
|
||||||
|
if *it.KanbanStatus == "done" {
|
||||||
|
isCompleted = 1
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
|
||||||
|
it.SortOrder, *it.KanbanStatus, isCompleted, it.ID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
|
||||||
|
it.SortOrder, it.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||||
@@ -2569,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
|||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Category != nil && it.Content != nil {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||||
|
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
|
||||||
|
)
|
||||||
|
} else if it.Category != nil {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
|
||||||
|
it.SortOrder, *it.Category, it.ID, listID,
|
||||||
|
)
|
||||||
|
} else if it.Content != nil {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||||
|
it.SortOrder, *it.Content, it.ID, listID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
|
||||||
|
it.SortOrder, it.ID, listID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// --- API KEYS & WEBHOOKS ---
|
// --- API KEYS & WEBHOOKS ---
|
||||||
|
|
||||||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||||||
@@ -2685,7 +2757,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
|||||||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||||
|
|
||||||
tRows, err := s.db.Query(
|
tRows, err := s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||||||
userID, q,
|
userID, q,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
|||||||
converted_type TEXT DEFAULT '',
|
converted_type TEXT DEFAULT '',
|
||||||
converted_ref_id TEXT DEFAULT '',
|
converted_ref_id TEXT DEFAULT '',
|
||||||
due_date TEXT DEFAULT '',
|
due_date TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
triaged_at TEXT DEFAULT '',
|
triaged_at TEXT DEFAULT '',
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS spaces (
|
CREATE TABLE IF NOT EXISTS spaces (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
Reference in new issue
Block a user