diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f4e40..f550997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.45.0] - 2026-11-01 + +### Added +- Notizen: Text lässt sich mit der Maus über mehrere Punkte hinweg markieren. Entf/Backspace löscht ihn wie in einem Texteditor (der Rest des ersten und des letzten Punkts wird ein Punkt, Punkte dazwischen samt Unterpunkten verschwinden), ein getipptes Zeichen ersetzt ihn, Strg+C kopiert, Strg+X schneidet aus. Strg+Z holt alles in einem Schritt zurück. +- Aufgaben: Strg+Z (Cmd+Z) macht Löschen, Verschieben (Spalten, Reihenfolge in Kanban und Liste) und Abhaken rückgängig. Nach dem Löschen erscheint unten „Aufgabe im Papierkorb · Rückgängig“, auch am Handy. +- Notizen, Aufgabenlisten und Listen lassen sich ohne Emoji anlegen: Alle Emoji-Auswähler haben „Entfernen“. Ohne Emoji steht der Titel allein; im Kopf einer Notiz zeigt ein dezentes Symbol, wo man eins wählen kann. + +### Fixed +- Kanban: Eine Aufgabe, die in eine selbst angelegte Erledigt-Spalte gezogen wurde, galt nach dem Umsortieren nicht als erledigt (nur die Standard-Spalte „Erledigt“ zählte). + ## [0.44.0] - 2026-11-01 ### Changed @@ -718,7 +728,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.44.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...dev +[0.45.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...v0.45.0 [0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0 [0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0 [0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0 diff --git a/README.md b/README.md index 4d252c7..2204782 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.44.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.45.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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8277360..9404214 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.44.0", + "version": "0.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.44.0", + "version": "0.45.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 4829917..bc10609 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.44.0", + "version": "0.45.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/EmojiIconButton.tsx b/frontend/src/components/EmojiIconButton.tsx index f3053e9..07415ee 100644 --- a/frontend/src/components/EmojiIconButton.tsx +++ b/frontend/src/components/EmojiIconButton.tsx @@ -1,16 +1,16 @@ import React, { useRef, useState } from 'react'; +import { SmilePlus } from 'lucide-react'; import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal'; interface EmojiIconButtonProps { value: string; + // "" = no icon (removed in the picker) onChange: (emoji: string) => void; - // Shown while no icon is chosen - fallback: string; } // Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same -// picker as the icon of an open note (Doku/ui-patterns.md). -export const EmojiIconButton: React.FC = ({ value, onChange, fallback }) => { +// picker as the icon of an open note, removing included (Doku/ui-patterns.md). +export const EmojiIconButton: React.FC = ({ value, onChange }) => { const buttonRef = useRef(null); const [anchor, setAnchor] = useState(null); return ( @@ -19,17 +19,16 @@ export const EmojiIconButton: React.FC = ({ value, onChang ref={buttonRef} type="button" onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))} - title="Icon auswählen" - aria-label="Icon auswählen" + title={value ? 'Icon ändern' : 'Icon wählen'} + aria-label={value ? 'Icon ändern' : 'Icon wählen'} className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong" > - {value || fallback} + {value || } {anchor && ( { onChange(emoji); setAnchor(null); diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 0d81ff0..a83e1d0 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -257,8 +257,8 @@ export const InstantCaptureBar: React.FC = ({ const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0]; const getTargetLabel = () => { - if (target === 'task') return selectedTaskList ? `${selectedTaskList.icon || '📋'} ${selectedTaskList.title}` : 'Direkt-Aufgabe'; - if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste'; + if (target === 'task') return selectedTaskList ? (selectedTaskList.icon ? `${selectedTaskList.icon} ${selectedTaskList.title}` : selectedTaskList.title) : 'Direkt-Aufgabe'; + if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste'; if (target === 'document') return 'Direkt-Notiz'; return 'Quick Dump'; }; @@ -536,7 +536,7 @@ export const InstantCaptureBar: React.FC = ({ : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' }`} > - {tl.icon || '📋'} + {tl.icon && {tl.icon}} {tl.title} ); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 2a4a83c..e4a2fbf 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -761,6 +761,8 @@ export const HelpView: React.FC = ({ onBack }) => { { key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' }, { key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' }, { key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' }, + { key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' }, + { key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' }, { key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' }, { key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' }, { key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' }, diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 46ec633..0fec8c6 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -1171,7 +1171,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} + {activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'} {openItems.length > 0 && ( @@ -1204,7 +1204,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- + = ({ onRefreshGloba onClose={() => setShowShareModal(false)} title={`Liste teilen: ${activeList.title}`} resourceName={activeList.title} - resourceIcon={activeList.icon || '🛒'} + resourceIcon={activeList.icon || undefined} isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')} currentUserId={currentUser?.id} shares={(activeList.shares || []).map((s) => ({ diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index c587b3f..5c85a78 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -606,7 +606,7 @@ const TaskDetailModalContent: React.FC {taskLists.map((tl) => ( ))} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index a5b7201..f02a919 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -596,8 +596,83 @@ export const TasksView: React.FC = ({ // --- TASK ACTIONS --- + // --- UNDO (Strg+Z / Cmd+Z): deleting, moving and ticking off, newest first; deleted tasks come back + // from the task trash. Deleting also shows "Rückgängig" for phones. + type UndoEntry = + | { kind: 'delete'; ids: string[] } + | { kind: 'order'; snapshot: { id: string; sortOrder: number; kanbanStatus?: string; isCompleted: boolean }[] } + | { kind: 'status'; id: string; isCompleted: boolean; kanbanStatus?: string }; + const undoStackRef = useRef([]); + const [toast, setToast] = useState<{ text: string; undo: boolean } | null>(null); + const pushUndo = (entry: UndoEntry) => { + undoStackRef.current = [...undoStackRef.current, entry].slice(-20); + setToast(null); // its "Rückgängig" would now undo this newer step + }; + const orderSnapshot = () => + tasks.map((t) => ({ id: t.id, sortOrder: t.sortOrder ?? 0, kanbanStatus: t.kanbanStatus, isCompleted: !!t.isCompleted })); + const toastTimerRef = useRef(null); + const showToast = (text: string, undo = false) => { + setToast({ text, undo }); + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); + toastTimerRef.current = window.setTimeout(() => setToast(null), undo ? 8000 : 3000); + }; + useEffect(() => () => { + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); + }, []); + + const undoLast = async () => { + const entry = undoStackRef.current.pop(); + if (!entry) return; + setToast(null); + triggerHaptic(10); + try { + if (entry.kind === 'delete') { + for (const id of entry.ids) await api.restoreTrashedTask(id).catch(() => {}); + showToast(entry.ids.length === 1 ? 'Aufgabe wiederhergestellt' : `${entry.ids.length} Aufgaben wiederhergestellt`); + } else if (entry.kind === 'order') { + const byId = new Map(entry.snapshot.map((x) => [x.id, x])); + setTasks((prev) => + prev.map((t) => { + const old = byId.get(t.id); + return old ? { ...t, sortOrder: old.sortOrder, kanbanStatus: old.kanbanStatus, isCompleted: old.isCompleted } : t; + }) + ); + await api.reorderTasks( + entry.snapshot.map((x) => ({ id: x.id, sortOrder: x.sortOrder, kanbanStatus: x.kanbanStatus })), + selectedListId !== 'all' ? selectedListId : undefined + ); + showToast('Verschieben rückgängig gemacht'); + } else { + setTasks((prev) => + prev.map((t) => (t.id === entry.id ? { ...t, isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus } : t)) + ); + await api.updateThought(entry.id, { isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus }); + showToast('Rückgängig gemacht'); + } + } finally { + await loadData(); + onRefreshStats(); + } + }; + const undoLastRef = useRef(undoLast); + undoLastRef.current = undoLast; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'z') return; + // Text fields keep their own undo + const el = e.target as HTMLElement | null; + if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return; + if (undoStackRef.current.length === 0) return; + e.preventDefault(); + void undoLastRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + const handleToggleDone = async (task: Thought) => { triggerHaptic(15); + pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus }); const nextState = !task.isCompleted; const cols = columnsFor(task); const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1]; @@ -615,6 +690,7 @@ export const TasksView: React.FC = ({ const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => { triggerHaptic(15); + pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus }); const targetCol = columnsFor(task).find((c) => c.id === nextStatus); const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done'; @@ -784,6 +860,8 @@ export const TasksView: React.FC = ({ const handleDeletePermanent = async (id: string) => { triggerHaptic(20); await api.deleteThought(id); + pushUndo({ kind: 'delete', ids: [id] }); + showToast('Aufgabe im Papierkorb', true); await loadData(); onRefreshStats(); }; @@ -803,7 +881,13 @@ export const TasksView: React.FC = ({ const handleClearDone = async () => { triggerHaptic(20); + // The ones that will go (completed and editable here), for undo + const ids = doneTasks.filter((t) => !isTaskReadOnly(t)).map((t) => t.id); await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId); + if (ids.length > 0) { + pushUndo({ kind: 'delete', ids }); + showToast(ids.length === 1 ? 'Aufgabe im Papierkorb' : `${ids.length} Aufgaben im Papierkorb`, true); + } await loadData(); onRefreshStats(); }; @@ -967,6 +1051,7 @@ export const TasksView: React.FC = ({ sortOrder: idx, })); + pushUndo({ kind: 'order', snapshot: orderSnapshot() }); setTasks(updatedWithSort); setSortBy('custom'); @@ -1103,6 +1188,7 @@ export const TasksView: React.FC = ({ sortOrder: idx, })); + pushUndo({ kind: 'order', snapshot: orderSnapshot() }); setTasks(updatedWithSort); setSortBy('custom'); @@ -1333,7 +1419,7 @@ export const TasksView: React.FC = ({ }, ...safeTaskLists.map((tl) => ({ id: `list-${tl.id}`, - label: `${tl.icon || '📋'} ${tl.title}`, + label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title, badge: tl.tasksCount || undefined, active: selectedListId === tl.id, onClick: () => setSelectedListId(tl.id), @@ -1846,7 +1932,7 @@ export const TasksView: React.FC = ({
- {currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'} + {currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'} {viewMode === 'kanban' @@ -2101,7 +2187,7 @@ export const TasksView: React.FC = ({ {safeTaskLists.map((tl) => ( ))} @@ -2341,7 +2427,7 @@ export const TasksView: React.FC = ({
- + = ({ )} + {/* Short notice after deleting or undoing (same look as in the workspace) */} + {toast && ( +
+ {toast.text} + {toast.undo && ( + + )} +
+ )} + {/* UNIFIED SHARE LIST MODAL */} {showShareListModal && sharingTaskList && ( = ({ }} title={`Aufgabenliste teilen: ${sharingTaskList.title}`} resourceName={sharingTaskList.title} - resourceIcon={sharingTaskList.icon || '📋'} + resourceIcon={sharingTaskList.icon || undefined} isOwner={sharingTaskList.isOwner !== false} currentUserId={currentUser?.id} shares={(sharingTaskList.shares || []).map((sh) => ({ @@ -2817,7 +2923,7 @@ export const TasksView: React.FC = ({

{selectedListId === 'all' && ( <> - {listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'} + {listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'} {' · '} )} diff --git a/frontend/src/features/workspace/BacklinksSection.tsx b/frontend/src/features/workspace/BacklinksSection.tsx index 0bf0ee3..6448b44 100644 --- a/frontend/src/features/workspace/BacklinksSection.tsx +++ b/frontend/src/features/workspace/BacklinksSection.tsx @@ -84,7 +84,7 @@ export const BacklinksSection: React.FC = ({ docId, title onClick={() => onOpen(l.docId)} className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group" > - {l.icon || '🗒️'} + {l.icon && {l.icon}} {l.title || 'Unbenannte Notiz'} diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 12881b2..9adea12 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -25,6 +25,7 @@ import { Redo2, MoreHorizontal, CalendarDays, + SmilePlus, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; @@ -708,6 +709,19 @@ export const DocumentEditor: React.FC = ({ [docId, canEdit, applyLocalBlocks] ); + // A selection over several bullets was deleted (OutlinerTree): one change, so Strg+Z undoes it at once + const handleReplaceRange = useCallback( + (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => { + if (!canEdit) return; + const changed = new Map([first, ...movedChildren].map((b) => [b.id, b])); + const gone = new Set(deleteIds); + applyLocalBlocks((prev) => + ensureNonEmptyBlocks(docId, prev.filter((b) => !gone.has(b.id)).map((b) => changed.get(b.id) || b)) + ); + }, + [docId, canEdit, applyLocalBlocks] + ); + // Swaps a bullet (with its children) with the previous or next one on the same level, within the same // parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice). const handleMoveSibling = useCallback( @@ -1254,6 +1268,7 @@ export const DocumentEditor: React.FC = ({ onCreateBlock={handleCreateBlock} onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} + onReplaceRange={handleReplaceRange} onMoveBlock={handleMoveBlock} onMoveSibling={handleMoveSibling} onOpenTask={onOpenTask} @@ -1286,12 +1301,15 @@ export const DocumentEditor: React.FC = ({ }`} title={inJournal ? undefined : 'Icon ändern'} > - {/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji */} + {/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji; + a note without an icon shows a faint "choose icon" to editors only */} {inJournal ? ( - ) : ( - doc?.icon || '🗒️' - )} + ) : doc?.icon ? ( + doc.icon + ) : canEdit ? ( + + ) : null} {showEmojiPicker && canEdit && ( = ({ onCreateBlock={handleCreateBlock} onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} + onReplaceRange={handleReplaceRange} onMoveBlock={handleMoveBlock} onMoveSibling={handleMoveSibling} onOpenTask={onOpenTask} diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx index 0a5ab61..dc3ba4e 100644 --- a/frontend/src/features/workspace/EmojiPickerModal.tsx +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -29,8 +29,6 @@ interface EmojiPickerModalProps { // Inside a dialog (which clips and transforms its content): the picker is put on the page instead and // opens below this button on desktop anchor?: DOMRect | null; - // Offer "Icon entfernen" (notes; a create dialog only has its default icon to go back to) - allowRemove?: boolean; } export const EmojiPickerModal: React.FC = ({ @@ -38,7 +36,6 @@ export const EmojiPickerModal: React.FC = ({ onSelectEmoji, onClose, anchor, - allowRemove = true, }) => { const { inset, visibleHeight } = useKeyboardInset(); const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; @@ -151,7 +148,7 @@ export const EmojiPickerModal: React.FC = ({

- {currentIcon && allowRemove && ( + {currentIcon && (
- {/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */} - {currentIcon && allowRemove && ( + {/* Desktop: removing the icon (none at all) as a quiet last row, like an entry of a context menu */} + {currentIcon && ( diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 35ffc6e..6ddc0a6 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -125,6 +125,8 @@ export interface OutlinerBlockProps { onResolveConflict?: (blockId: string, accept: boolean) => void; onUndo?: () => void; onRedo?: () => void; + /** Part of a selection over several bullets (browsers without the CSS Highlight API tint the row) */ + inRangeSelection?: boolean; } // Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw @@ -175,6 +177,7 @@ export const OutlinerBlock: React.FC = ({ onResolveConflict, onUndo, onRedo, + inRangeSelection, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [isLocallyFocused, setIsLocallyFocused] = useState(false); @@ -1077,7 +1080,10 @@ export const OutlinerBlock: React.FC = ({ return (
= ({ }`} > {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} - + {previewUrl && !activeFocus ? ( // The card above shows the page; the address itself becomes a small link marker void; onUndo?: () => void; onRedo?: () => void; + /** Deleting a selection over several bullets in one step: the first bullet with its new text, the + * bullets to delete, and children of the last bullet that move under the first */ + onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void; } export const OutlinerTree = forwardRef(({ @@ -67,6 +70,7 @@ export const OutlinerTree = forwardRef(({ onResolveConflict, onUndo, onRedo, + onReplaceRange, }, ref) => { const [focusedBlockId, setFocusedBlockId] = useState(null); @@ -665,8 +669,245 @@ export const OutlinerTree = forwardRef(({ return () => window.removeEventListener('dragend', clearDrag); }, []); + // --- Selection over several bullets --- + // Each bullet is a text field of its own, so the browser cannot select across them. Dragging from one + // bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in + // its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the + // CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text + // like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies. + type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number }; + const [rangeSel, setRangeSel] = useState(null); + const rangeSelRef = useRef(null); + rangeSelRef.current = rangeSel; + const treeRootRef = useRef(null); + const dragAnchorRef = useRef<{ id: string; offset: number } | null>(null); + const highlightSupported = typeof CSS !== 'undefined' && 'highlights' in CSS && typeof (window as any).Highlight === 'function'; + + const caretAtPoint = (x: number, y: number): { node: Node; offset: number } | null => { + const doc = document as any; + if (typeof doc.caretPositionFromPoint === 'function') { + const p = doc.caretPositionFromPoint(x, y); + return p ? { node: p.offsetNode, offset: p.offset } : null; + } + if (typeof doc.caretRangeFromPoint === 'function') { + const r = doc.caretRangeFromPoint(x, y); + return r ? { node: r.startContainer, offset: r.startOffset } : null; + } + return null; + }; + + // Position in the raw text of a bullet for a point in its rendered text (outside it: start or end) + const rawOffsetAtPoint = (row: HTMLElement, id: string, x: number, y: number) => { + const raw = textareaMapRef.current.get(id)?.value ?? blocks.find((b) => b.id === id)?.content ?? ''; + const span = row.querySelector('[data-outline-text]'); + const caret = caretAtPoint(x, y); + if (span && caret && span.contains(caret.node)) { + const range = document.createRange(); + range.setStart(span, 0); + range.setEnd(caret.node, caret.offset); + return { offset: mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length), node: caret.node, nodeOffset: caret.offset }; + } + const rect = (span || row).getBoundingClientRect(); + const before = y < rect.top || (y <= rect.bottom && x < rect.left); + return { offset: before ? 0 : raw.length }; + }; + + const clearRangeSel = useCallback(() => { + dragAnchorRef.current = null; + setRangeSel(null); + }, []); + + // Highlight the selected text of the bullets other than the start one + useEffect(() => { + if (!highlightSupported) return; + const registry = (CSS as any).highlights; + if (!rangeSel || !treeRootRef.current) { + registry.delete('outline-range'); + return; + } + const order = visibleEntries.map((e) => e.block.id); + const a = order.indexOf(rangeSel.anchorId); + const f = order.indexOf(rangeSel.focusId); + if (a < 0 || f < 0) { + registry.delete('outline-range'); + return; + } + const ranges: Range[] = []; + const spanOf = (id: string) => + treeRootRef.current!.querySelector(`[data-outline-block="${id}"] [data-outline-text]`); + for (let i = Math.min(a, f); i <= Math.max(a, f); i++) { + const id = order[i]; + if (id === rangeSel.anchorId) continue; // shown by the browser in its text field + const span = spanOf(id); + if (!span) continue; + const r = document.createRange(); + r.selectNodeContents(span); + if (id === rangeSel.focusId && rangeSel.focusNode && span.contains(rangeSel.focusNode)) { + if (f > a) r.setEnd(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0); + else r.setStart(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0); + } + ranges.push(r); + } + registry.set('outline-range', new (window as any).Highlight(...ranges)); + return () => registry.delete('outline-range'); + }, [rangeSel, visibleEntries, highlightSupported]); + + // Mouse: a drag that leaves its bullet becomes a selection over several bullets + useEffect(() => { + const onMove = (e: MouseEvent) => { + const start = dragAnchorRef.current; + if (!start || !(e.buttons & 1)) return; + const row = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-outline-block]'); + if (!row || !treeRootRef.current?.contains(row)) return; + const id = row.dataset.outlineBlock!; + if (id === start.id) { + if (rangeSelRef.current) setRangeSel(null); + return; + } + const at = rawOffsetAtPoint(row, id, e.clientX, e.clientY); + setRangeSel({ anchorId: start.id, anchorOffset: start.offset, focusId: id, focusOffset: at.offset, focusNode: at.node, focusNodeOffset: at.nodeOffset }); + }; + const onUp = () => { + dragAnchorRef.current = null; + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + // rawOffsetAtPoint only reads refs and blocks + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blocks]); + + const handleTreeMouseDownCapture = (e: React.MouseEvent) => { + if (e.button !== 0) return; + setRangeSel(null); + const target = e.target as HTMLElement; + const row = target.closest('[data-outline-block]'); + if (!row || target.closest('button, a, input, select, [contenteditable="true"]')) { + dragAnchorRef.current = null; + return; + } + const id = row.dataset.outlineBlock!; + // The bullet's own handler places the caret first; read it right after + dragAnchorRef.current = { id, offset: rawOffsetAtPoint(row, id, e.clientX, e.clientY).offset }; + requestAnimationFrame(() => { + const ta = textareaMapRef.current.get(id); + if (dragAnchorRef.current?.id === id && ta && document.activeElement === ta) { + dragAnchorRef.current = { id, offset: ta.selectionStart }; + } + }); + }; + + // The selection in text order: first bullet + offset, last bullet + offset, bullets in between + const resolveRangeSel = (sel: RangeSel) => { + const order = visibleEntries.map((en) => en.block.id); + const a = order.indexOf(sel.anchorId); + const f = order.indexOf(sel.focusId); + if (a < 0 || f < 0 || a === f) return null; + const [firstId, firstOffset, lastId, lastOffset] = + a < f ? [sel.anchorId, sel.anchorOffset, sel.focusId, sel.focusOffset] : [sel.focusId, sel.focusOffset, sel.anchorId, sel.anchorOffset]; + const fIdx = blocks.findIndex((b) => b.id === firstId); + const lIdx = blocks.findIndex((b) => b.id === lastId); + if (fIdx < 0 || lIdx < 0 || lIdx <= fIdx) return null; + return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx }; + }; + + const selectedText = (sel: RangeSel) => { + const r = resolveRangeSel(sel); + if (!r) return ''; + const lines = [(r.first.content || '').slice(r.firstOffset)]; + for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || ''); + lines.push((r.last.content || '').slice(0, r.lastOffset)); + return lines.join('\n'); + }; + + const deleteRangeSel = (sel: RangeSel, insert = '') => { + const r = resolveRangeSel(sel); + if (!r || !canEdit) return false; + const notMergeable = ['code', 'divider', 'table', 'progress']; + if (notMergeable.includes(r.first.type) || notMergeable.includes(r.last.type)) return false; + const deleted = blocks.slice(r.fIdx + 1, r.lIdx + 1); + const holder = [r.first, ...deleted].map((b) => lockHolder(b.id)).find(Boolean); + if (holder) { + onLockedAction?.(holder); + return true; + } + // Children of the last bullet stay and move under the first, like merging two bullets + const lastLevel = Math.max(0, r.last.level || 0); + const delta = Math.max(0, r.first.level || 0) - lastLevel; + const moved: DocumentBlock[] = []; + for (let j = r.lIdx + 1; j < blocks.length; j++) { + const lvl = Math.max(0, blocks[j].level || 0); + if (lvl <= lastLevel) break; + moved.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) }); + } + const merged: DocumentBlock = { + ...r.first, + content: (r.first.content || '').slice(0, r.firstOffset) + insert + (r.last.content || '').slice(r.lastOffset), + }; + const deleteIds = deleted.map((b) => b.id); + if (onReplaceRange) { + onReplaceRange(merged, deleteIds, moved); + } else { + if (onUpdateBlocks) onUpdateBlocks([merged, ...moved]); + else [merged, ...moved].forEach((b) => onUpdateBlock(b, true)); + deleteIds.forEach((id) => onDeleteBlock(id)); + } + clearRangeSel(); + focusBlockInput(r.first.id, r.firstOffset + insert.length); + return true; + }; + + // Keys while a selection over several bullets exists + const deleteRangeSelRef = useRef(deleteRangeSel); + deleteRangeSelRef.current = deleteRangeSel; + const selectedTextRef = useRef(selectedText); + selectedTextRef.current = selectedText; + useEffect(() => { + if (!rangeSel) return; + const onKey = (e: KeyboardEvent) => { + const sel = rangeSelRef.current; + if (!sel) return; + const mod = e.ctrlKey || e.metaKey; + const stop = () => { + e.preventDefault(); + e.stopPropagation(); + }; + if (mod && (e.key === 'c' || e.key === 'x')) { + stop(); + void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {}); + if (e.key === 'x') deleteRangeSelRef.current(sel); + return; + } + if (e.key === 'Backspace' || e.key === 'Delete') { + stop(); + deleteRangeSelRef.current(sel); + return; + } + if (!mod && !e.altKey && e.key.length === 1) { + stop(); + deleteRangeSelRef.current(sel, e.key); + return; + } + if (!['Shift', 'Control', 'Meta', 'Alt'].includes(e.key)) clearRangeSel(); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [rangeSel, clearRangeSel]); + + const rangeRowIds = useMemo(() => { + if (!rangeSel || highlightSupported) return null; + const order = visibleEntries.map((e) => e.block.id); + const a = order.indexOf(rangeSel.anchorId); + const f = order.indexOf(rangeSel.focusId); + if (a < 0 || f < 0) return null; + return new Set(order.slice(Math.min(a, f), Math.max(a, f) + 1)); + }, [rangeSel, visibleEntries, highlightSupported]); + return ( -
+
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => { const isLast = entryIdx === visibleEntries.length - 1; return ( @@ -709,6 +950,7 @@ export const OutlinerTree = forwardRef(({ isDragTarget={dragOverIndex === index && draggedIndex !== index} isTargeted={highlightedBlockId === block.id} registerTextareaRef={registerTextareaRef} + inRangeSelection={rangeRowIds?.has(block.id)} /> ); })} diff --git a/frontend/src/features/workspace/TrashView.tsx b/frontend/src/features/workspace/TrashView.tsx index 377441b..5a1e052 100644 --- a/frontend/src/features/workspace/TrashView.tsx +++ b/frontend/src/features/workspace/TrashView.tsx @@ -88,7 +88,7 @@ export const TrashView: React.FC = ({ items, onOpen, onRestore, className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group" title="Schreibgeschützt ansehen" > - {t.icon || '🗒️'} + {t.icon && {t.icon}} {t.title || 'Unbenannte Notiz'} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 9c0aa12..88f2f88 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -282,7 +282,7 @@ const FolderTreeItem: React.FC = ({ : 'border-transparent text-main hover:bg-panel-elevated' }`} > - {d.icon || '🗒️'} + {d.icon && {d.icon}} {d.title || 'Unbenannte Notiz'}
))} @@ -589,7 +589,7 @@ export const WorkspaceView: React.FC = ({ try { const created = await api.createDocument({ title: title.trim() || 'Neue Notiz', - icon: icon.trim() || '🗒️', + icon: icon.trim(), folderPath, blocks: [ { @@ -1420,7 +1420,7 @@ export const WorkspaceView: React.FC = ({ map.set(d.id, { docId: d.id, title: d.title || 'Unbenannte Notiz', - icon: d.icon || '🗒️', + icon: d.icon || '', folderPath: d.folderPath || '', matchedBlocks: matched, }); @@ -1433,7 +1433,7 @@ export const WorkspaceView: React.FC = ({ map.set(dg.docId, { docId: dg.docId, title: dg.title || 'Unbenannte Notiz', - icon: dg.icon || '🗒️', + icon: dg.icon || '', folderPath: dg.folderPath || '', matchedBlocks: dg.matchedBlocks || [], }); @@ -1806,7 +1806,7 @@ export const WorkspaceView: React.FC = ({ className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer" title="Andere Notiz wählen" > - {selectedDocObj?.icon} {selectedDocObj?.title} + {[selectedDocObj?.icon, selectedDocObj?.title].filter(Boolean).join(' ')}
@@ -2464,7 +2464,7 @@ export const WorkspaceView: React.FC = ({ onClick={() => handleJumpToDocBlock(docGroup.docId)} className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer" > - {docGroup.icon || '🗒️'} + {docGroup.icon && {docGroup.icon}} {docGroup.title} {docGroup.folderPath && ( @@ -2714,7 +2714,7 @@ export const WorkspaceView: React.FC = ({ className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main" title={item.doc.title} > - {item.doc.icon || '🗒️'} + {item.doc.icon && {item.doc.icon}} {item.doc.title} {item.doc.folderPath && ( ({item.doc.folderPath}) @@ -2815,7 +2815,7 @@ export const WorkspaceView: React.FC = ({ ) : ( )} - {doc.icon || '🗒️'} + {doc.icon && {doc.icon}} {doc.title} {docOpenCount} offen @@ -2967,7 +2967,7 @@ export const WorkspaceView: React.FC = ({ }} >
- + { let title = titleOrSpaceId; - let icon = titleOrIcon || '🛒'; + // An empty icon means "no icon"; only a missing one gets the default + let icon = titleOrIcon ?? '🛒'; let type = (iconOrType as any) || 'shopping'; if (listType !== undefined) { title = titleOrIcon || ''; - icon = iconOrType || '🛒'; + icon = iconOrType ?? '🛒'; type = listType; } const l = await request('/api/v1/lists', { diff --git a/internal/api/handlers_documents.go b/internal/api/handlers_documents.go index a337092..74da649 100644 --- a/internal/api/handlers_documents.go +++ b/internal/api/handlers_documents.go @@ -47,7 +47,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { var req struct { SpaceID string `json:"spaceId"` Title string `json:"title"` - Icon string `json:"icon"` + Icon *string `json:"icon"` // "" = no icon; missing = the default FolderPath string `json:"folderPath"` Blocks []domain.DocumentBlock `json:"blocks"` Tags []string `json:"tags"` @@ -58,7 +58,12 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { return } - doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned) + doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, derefIcon(req.Icon), req.FolderPath, req.Blocks, req.Tags, req.IsPinned) + if err == nil && doc != nil && req.Icon != nil && *req.Icon == "" { + if err = s.store.ClearIcon("documents", doc.ID); err == nil { + doc.Icon = "" + } + } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -330,3 +335,11 @@ func (s *Server) docTitle(userID, docID string) string { } return "Unbenannte Notiz" } + +// derefIcon: the icon of a create request; a missing one lets the store pick the default. +func derefIcon(icon *string) string { + if icon == nil { + return "" + } + return *icon +} diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index dcce2f5..1b0d907 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -39,7 +39,7 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { Title string `json:"title"` - Icon string `json:"icon"` + Icon *string `json:"icon"` // "" = no icon; missing = the default ListType string `json:"listType"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -47,7 +47,12 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { return } - lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType) + lst, err := s.store.CreateList(userID, req.Title, derefIcon(req.Icon), req.ListType) + if err == nil && lst != nil && req.Icon != nil && *req.Icon == "" { + if err = s.store.ClearIcon("lists", lst.ID); err == nil { + lst.Icon = "" + } + } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go index 25a359e..fbe26d7 100644 --- a/internal/api/handlers_tasklists.go +++ b/internal/api/handlers_tasklists.go @@ -28,14 +28,19 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) { SpaceID string `json:"spaceId"` TeamID string `json:"teamId"` Title string `json:"title"` - Icon string `json:"icon"` + Icon *string `json:"icon"` // "" = no icon; missing = the default Color string `json:"color"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } - tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color) + tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, derefIcon(req.Icon), req.Color) + if err == nil && tl != nil && req.Icon != nil && *req.Icon == "" { + if err = s.store.ClearIcon("task_lists", tl.ID); err == nil { + tl.Icon = "" + } + } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/storage/documents.go b/internal/storage/documents.go index 9f71566..2af7ff1 100644 --- a/internal/storage/documents.go +++ b/internal/storage/documents.go @@ -958,3 +958,15 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { } return users, nil } + +// ClearIcon removes the icon of a note, task list or list (created on purpose without one; the create +// functions put in a default for an empty icon). +func (s *Store) ClearIcon(table, id string) error { + switch table { + case "documents", "task_lists", "lists": + default: + return fmt.Errorf("unknown table %q", table) + } + _, err := s.db.Exec(`UPDATE `+table+` SET icon = '' WHERE id = ?`, id) + return err +} diff --git a/internal/storage/task_trash_test.go b/internal/storage/task_trash_test.go index 290659f..c591f33 100644 --- a/internal/storage/task_trash_test.go +++ b/internal/storage/task_trash_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/sandrodach/mindsift/internal/domain" ) func inTaskTrash(s *Store, id string) bool { @@ -215,3 +217,31 @@ func TestPurgeExpiredAreas(t *testing.T) { t.Fatal("things from before the update count from the update on") } } + +// Moving a card into a list's own done column (any ID) completes the task, other columns reopen it. +func TestReorderIntoCustomDoneColumn(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "donecol.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member") + list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "") + cols := []domain.KanbanColumn{{ID: "todo", Name: "Offen"}, {ID: "col_fertig", Name: "Fertig", IsDone: true}} + if _, err := s.UpdateTaskList(anna.ID, list.ID, nil, nil, nil, nil, &cols); err != nil { + t.Fatal(err) + } + task, _ := s.CreateThoughtWithDetails(anna.ID, "Bericht", "normal", "manual", "", list.ID, "todo", "") + _, _ = s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", list.ID) + done, open := "col_fertig", "todo" + if err := s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &done}}); err != nil { + t.Fatal(err) + } + if got, _ := s.GetThoughtByIDUnchecked(task.ID); !got.IsCompleted { + t.Fatal("the list's done column should complete the task") + } + _ = s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &open}}) + if got, _ := s.GetThoughtByIDUnchecked(task.ID); got.IsCompleted { + t.Fatal("an open column reopens it") + } +} diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index dcf7645..2693642 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -238,7 +238,7 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte for _, it := range items { if it.KanbanStatus != nil && *it.KanbanStatus != "" { isCompleted := 0 - if *it.KanbanStatus == "done" { + if isDoneColumn(tx, it.ID, *it.KanbanStatus) { isCompleted = 1 var was int _ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was) @@ -275,6 +275,24 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte return nil } +// isDoneColumn: whether a Kanban column completes the task: the column of the task's list marked as done +// (its ID may be anything), else the default ID "done". +func isDoneColumn(q interface { + QueryRow(query string, args ...any) *sql.Row +}, taskID, column string) bool { + var raw string + _ = q.QueryRow(`SELECT COALESCE(tl.columns, '') FROM thoughts t LEFT JOIN task_lists tl ON tl.id = t.task_list_id WHERE t.id = ?`, taskID).Scan(&raw) + var cols []domain.KanbanColumn + if raw != "" && json.Unmarshal([]byte(raw), &cols) == nil && len(cols) > 0 { + for _, c := range cols { + if c.ID == column { + return c.IsDone + } + } + } + return column == "done" +} + // 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)