From 35d4657a25bef823bd7fdfe12698145ddeac0c79 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 19:06:02 +0100 Subject: [PATCH] feat: add drag-and-drop for tasks, workspace bullets, and list items --- .../src/features/lists/SharedListsView.tsx | 235 +++++++++++++- frontend/src/features/triage/TasksView.tsx | 299 +++++++++++++++++- .../src/features/workspace/WorkspaceView.tsx | 144 +++++++-- frontend/src/lib/api.ts | 18 ++ frontend/src/lib/types.ts | 1 + internal/api/server.go | 54 ++++ internal/domain/models.go | 14 + internal/storage/sqlite.go | 98 +++++- migrations/001_init.sql | 2 + 9 files changed, 814 insertions(+), 51 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 403189d..c852ff6 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -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 = ({ onRefreshGloba const [workspaceTags, setWorkspaceTags] = useState([]); const inputRef = useRef(null); + const [draggingItemId, setDraggingItemId] = useState(null); + const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); + const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); + const wsRef = useRef(null); const loadLists = async () => { @@ -185,7 +190,8 @@ export const SharedListsView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ onRefreshGloba )} {/* Lighter panel-surface card container with items */} -
+
{ + 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 (
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' : '' + }`} > -
+ {/* Drop insertion line indicator */} + {isTarget && dragOverItemTarget.position === 'before' && ( +
+ )} + {isTarget && dragOverItemTarget.position === 'after' && ( +
+ )} + +
+ {/* Grip handle for dragging */} +
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" + > + +
+ {/* Eckiges Abhaken */}
- ))} + ); + })} {colTasks.length === 0 && (
@@ -1584,6 +1835,26 @@ export const TasksView: React.FC = ({
) : (
+ {/* Dedicated Top Drop Zone when dragging a task */} + {draggingTaskId && ( +
{ + 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' + }`} + > + + Ganz oben in der Liste ablegen +
+ )} + {groupedTasks ? ( /* GROUPED LIST */ groupedTasks.map((grp) => ( diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 236ee51..9fcaa48 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -171,6 +171,7 @@ export const WorkspaceView: React.FC = ({ // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(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 = ({ 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 = ({ {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 (
!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' && ( +
+ )} + {isTargetThis && dragOverBlockTarget.position === 'after' && ( +
+ )} + {/* Drag Handle on hover */}
!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" > @@ -3449,14 +3553,16 @@ export const WorkspaceView: React.FC = ({ {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
!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" > diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 843efa4..492e6b8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { const res = await request('/api/v1/organizations'); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 05f6012..ef62c94 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -60,6 +60,7 @@ export interface Thought { notes?: string; subtasks?: SubTask[]; alarmSent?: boolean; + sortOrder?: number; createdAt: string; triagedAt?: string; } diff --git a/internal/api/server.go b/internal/api/server.go index d5113ae..53c1f1c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,6 +89,8 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/triage/deck", s.handleGetTriageDeck) r.Patch("/api/v1/triage/{id}", s.handleTriageAction) 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 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.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) 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) // Organizations & Teams @@ -678,6 +682,34 @@ func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) { 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) { userID := auth.GetUserID(r.Context()) 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}) } +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) { roomID := chi.URLParam(r, "listId") if roomID == "" { diff --git a/internal/domain/models.go b/internal/domain/models.go index e129ee8..4934766 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -154,10 +154,24 @@ type Thought struct { Notes string `json:"notes,omitempty"` Subtasks []SubTask `json:"subtasks"` AlarmSent bool `json:"alarmSent,omitempty"` + SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` 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 { ID string `json:"id"` TaskListID string `json:"taskListId"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5a04b26..819cf75 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -319,6 +319,8 @@ func (s *Store) migrate() error { "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 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 '';", @@ -835,16 +837,17 @@ func scanThought(rows interface { }) (*domain.Thought, error) { t := &domain.Thought{} var tagsStr, subtasksStr, statusStr string - var isCompleted, alarmSent int + var isCompleted, alarmSent, sortOrder int err := rows.Scan( &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, - &t.CreatedAt, &t.TriagedAt, &subtasksStr, + &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, ) if err != nil { return nil, err } + t.SortOrder = sortOrder t.Status = domain.ThoughtStatus(statusStr) t.IsCompleted = isCompleted != 0 t.AlarmSent = alarmSent != 0 @@ -866,7 +869,7 @@ func scanThought(rows interface { func (s *Store) accessibleTaskListIDsQuery() string { return `SELECT id FROM task_lists - WHERE user_id = ? + WHERE user_id = ? OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) OR id IN ( SELECT task_list_id FROM task_list_shares @@ -877,7 +880,7 @@ func (s *Store) accessibleTaskListIDsQuery() string { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { 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()+`))`, 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" { 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 = ? - 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, ) } else { 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 = ? - ORDER BY created_at DESC LIMIT ?`, + ORDER BY sort_order ASC, created_at DESC LIMIT ?`, userID, limit, ) } @@ -926,21 +929,21 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er var err error if taskListID != "" { 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 = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND task_list_id = ? 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, ) } else { 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 = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) 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, ) } @@ -959,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er 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 func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) { thought, err := s.GetThoughtByID(userID, thoughtID) @@ -2569,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) { 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 --- 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)) + "%" 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`, userID, q, ) diff --git a/migrations/001_init.sql b/migrations/001_init.sql index 2d4c77d..9e98593 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts ( converted_type TEXT DEFAULT '', converted_ref_id TEXT DEFAULT '', due_date TEXT DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, triaged_at TEXT DEFAULT '', 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_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 ( id TEXT PRIMARY KEY,