From 25bb787a8e46e90930ce7bceb0f79e169e947dcd Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 31 Oct 2026 10:01:41 +0100 Subject: [PATCH] feat(lists): drag tasks and list entries onto another list; remember open list and view; live pill in lists Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++- Doku/api/rest.md | 1 + README.md | 6 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/features/lists/SharedListsView.tsx | 112 +++++++++++------- frontend/src/features/triage/TasksView.tsx | 65 +++++++++- frontend/src/lib/api.ts | 7 ++ frontend/src/lib/viewSession.ts | 20 ++++ internal/api/handlers_lists.go | 42 +++++++ internal/api/server.go | 1 + internal/api/server_test.go | 47 ++++++++ internal/storage/lists.go | 29 +++++ 13 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 frontend/src/lib/viewSession.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f7c77..24ce2ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] -## [0.16.2] - 2026-10-31 +## [0.17.0] - 2026-10-31 + +### Added +- Aufgaben und Kanban: Eine Aufgabe lässt sich auf den Reiter einer anderen Aufgabenliste ziehen und landet dort wie eine neue Aufgabe. Ihren Kanban-Status behält sie, wenn die Zielliste diese Spalte hat, sonst startet sie in der ersten Spalte. Nur lesbare Listen nehmen nichts an. +- Einkaufslisten: Einträge lassen sich genauso auf den Reiter einer anderen Liste ziehen; Kategorie, Menge und Haken bleiben erhalten. Neu dafür ist `POST /api/v1/lists/{id}/items/{itemId}/move` (Bearbeitungsrecht in beiden Listen nötig). +- Aufgaben und Einkaufslisten merken sich beim Wechsel zwischen den Reitern der App die offene Liste, Aufgaben außerdem Liste oder Kanban (pro Browser-Tab, wie der Workspace). + +### Changed +- Einkaufslisten: Die Verbindungsanzeige oben rechts sieht jetzt aus wie bei den Notizen (Punkt und „Live“ bzw. „Offline“) statt des alten Funk-Symbols. ### Fixed - Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert. @@ -387,8 +395,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.2...dev -[0.16.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.16.2 +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.0...dev +[0.17.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.17.0 [0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1 [0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0 [0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 849c414..555fc37 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -190,6 +190,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) | | PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) | | DELETE | `/lists/{id}/items/{itemId}` | Löschen | +| POST | `/lists/{id}/items/{itemId}/move` | In eine andere Liste verschieben (`targetListId`); Bearbeitungsrecht in beiden Listen nötig, der Eintrag landet oben | | POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) | | POST | `/lists/{id}/clear-completed` | Erledigte entfernen | diff --git a/README.md b/README.md index 7f89e5c..c3e05a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.16.2-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.17.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -52,7 +52,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar. * Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen. -* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board. +* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste. * **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**. * **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden. * **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken. @@ -97,7 +97,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) ### 🛒 Geteilte Listen -* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien. +* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste. * Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.

diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02f2821..31d9b23 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.16.2", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.16.2", + "version": "0.17.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index a42b264..a57abc3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.16.2", + "version": "0.17.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index f9793a0..fd795b7 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -4,13 +4,13 @@ import { Trash2, Users, Check, - Radio, Sparkles, X, ShoppingCart, } from 'lucide-react'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; +import { readViewSession, writeViewSession } from '../../lib/viewSession'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; @@ -83,7 +83,13 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser, initialListId }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); - const [activeListId, setActiveListId] = useState(initialListId || ''); + // The open list survives switching to other tabs (like the workspace's open note) + const [activeListId, setActiveListId] = useState( + () => initialListId || readViewSession<{ listId: string }>('lists').listId || '' + ); + useEffect(() => { + if (activeListId) writeViewSession('lists', { listId: activeListId }); + }, [activeListId]); useEffect(() => { if (initialListId) { @@ -116,6 +122,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [draggingItemId, setDraggingItemId] = useState(null); const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); + // List tab a dragged entry hovers over (dropping moves it into that list) + const [dragOverListTab, setDragOverListTab] = useState(null); const realtimeConnRef = useRef(null); @@ -378,6 +386,35 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setDraggingItemId(item.id); }; + const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer'; + + // An entry dragged onto another list's tab lands at the top of that list, like a new entry + const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => { + e.preventDefault(); + const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId; + setDraggingItemId(null); + setDragOverItemTarget(null); + setDragOverCategoryTarget(null); + setDragOverListTab(null); + const source = activeList; + const item = source?.items.find((i) => i.id === itemId); + if (!source || !item || target.id === source.id || !canWriteList(target)) return; + triggerHaptic(15); + setLists((prev) => + prev.map((l) => { + if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) }; + if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) }; + return l; + }) + ); + try { + await api.moveListItem(source.id, item.id, target.id); + } catch { + loadLists(); + } + onRefreshGlobal?.(); + }; + const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => { e.preventDefault(); e.stopPropagation(); @@ -849,6 +886,23 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ]); }, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]); + // Connection pill in the same look as the notes' header + const livePill = ( + + + {wsConnected ? 'Live' : 'Offline'} + + ); + return (

{/* Top Header: List Tabs & Actions (Desktop) */} @@ -861,9 +915,21 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return (
{ + if (!draggingItemId || isSelected || !canWriteList(lst)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverListTab !== lst.id) setDragOverListTab(lst.id); + }} + onDragLeave={() => { + if (dragOverListTab === lst.id) setDragOverListTab(null); + }} + onDrop={(e) => handleDropOnListTab(e, lst)} className={`relative group h-8 flex items-center border rounded-btn transition-colors ${ isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main' - } ${isSelected && lists.length > 1 ? 'pr-1' : ''}`} + } ${isSelected && lists.length > 1 ? 'pr-1' : ''} ${ + dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : '' + }`} >
@@ -996,24 +1044,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )} -
- -
+ {livePill} @@ -1195,6 +1226,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setDraggingItemId(null); setDragOverItemTarget(null); setDragOverCategoryTarget(null); + setDragOverListTab(null); }} onDragOver={(e) => handleItemDragOver(e, item.id)} onDragLeave={(e) => handleItemDragLeave(e, item.id)} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 2fbc642..37a672f 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react'; import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types'; import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due'; +import { readViewSession, writeViewSession } from '../../lib/viewSession'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; @@ -66,6 +67,8 @@ interface TasksViewProps { const KANBAN_WIDE_KEY = 'mindsift:kanban_wide'; +type TasksSession = { listId: string; viewMode: 'list' | 'kanban' }; + export const TasksView: React.FC = ({ currentUser, onRefreshStats, @@ -75,7 +78,10 @@ export const TasksView: React.FC = ({ openTasksCount, onWideChange, }) => { - const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); + // Open list and view survive switching to other tabs (like the workspace's open note) + const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() => + readViewSession('tasks').viewMode === 'kanban' ? 'kanban' : 'list' + ); // Wide Kanban: the board takes the whole window and shows every column in one row const [kanbanWide, setKanbanWide] = useState(() => { try { @@ -101,7 +107,12 @@ export const TasksView: React.FC = ({ const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [tasks, setTasks] = useState([]); const [taskLists, setTaskLists] = useState([]); - const [selectedListId, setSelectedListId] = useState('all'); + const [selectedListId, setSelectedListId] = useState( + () => readViewSession('tasks').listId || 'all' + ); + useEffect(() => { + writeViewSession('tasks', { listId: selectedListId, viewMode }); + }, [selectedListId, viewMode]); const [teams, setTeams] = useState([]); const [collaborators, setCollaborators] = useState([]); const [discarded, setDiscarded] = useState([]); @@ -111,6 +122,8 @@ export const TasksView: React.FC = ({ const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null); const [dragOverKanbanCol, setDragOverKanbanCol] = useState(null); + // List tab a dragged task hovers over (dropping moves it into that list) + const [dragOverListTab, setDragOverListTab] = useState(null); // Quick Task Add state const [quickTitle, setQuickTitle] = useState(''); @@ -190,6 +203,10 @@ export const TasksView: React.FC = ({ loadedListRef.current = listId; setTasks(Array.isArray(allTasks) ? allTasks : []); setTaskLists(Array.isArray(allLists) ? allLists : []); + // The remembered list was deleted or unshared meanwhile + if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) { + setSelectedListId('all'); + } setTeams(Array.isArray(allTeams) ? allTeams : []); setCollaborators(Array.isArray(allCollabs) ? allCollabs : []); setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []); @@ -535,6 +552,35 @@ export const TasksView: React.FC = ({ onRefreshStats(); }; + // A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status + // if the list has that column, otherwise it starts in the list's first column + const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => { + e.preventDefault(); + const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId; + setDraggingTaskId(null); + setDragOverTaskTarget(null); + setDragOverKanbanCol(null); + setDragOverListTab(null); + const task = tasks.find((t) => t.id === sourceId); + if (!task || task.taskListId === list.id || list.permission === 'viewer') return; + triggerHaptic(15); + const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns; + const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus); + const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id; + setTasks((prev) => + selectedListId !== 'all' + ? prev.filter((t) => t.id !== task.id) + : prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t)) + ); + try { + await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) }); + } catch { + loadData(); + } + api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {}); + onRefreshStats(); + }; + const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => { setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t))); await api.updateThought(task.id, { notes: newNotes }); @@ -1137,6 +1183,7 @@ export const TasksView: React.FC = ({ setDraggingTaskId(null); setDragOverTaskTarget(null); setDragOverKanbanCol(null); + setDragOverListTab(null); }} onDragOver={(e) => handleTaskDragOver(e, task.id)} onDragLeave={(e) => handleTaskDragLeave(e, task.id)} @@ -1385,9 +1432,20 @@ export const TasksView: React.FC = ({ {safeTaskLists.map((tl) => (
{ + const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null; + if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverListTab !== tl.id) setDragOverListTab(tl.id); + }} + onDragLeave={() => { + if (dragOverListTab === tl.id) setDragOverListTab(null); + }} + onDrop={(e) => handleDropOnListTab(e, tl)} className={`relative group h-8 flex items-center border rounded-btn transition-colors ${ selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main' - }`} + } ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`} >