Merge dev into main (v0.45.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s

This commit is contained in:
sandrodach committed 2026-11-01 16:30:35 +01:00
commit 3f96608992
27 files changed
+535 -64

No files matched your search

+12 -1
View File
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [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 ## [0.44.0] - 2026-11-01
### Changed ### 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. - 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. - 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.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.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 [0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![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) [![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) [![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. **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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.44.0", "version": "0.45.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.44.0", "version": "0.45.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.44.0", "version": "0.45.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+7 -8
View File
@@ -1,16 +1,16 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { SmilePlus } from 'lucide-react';
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal'; import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
interface EmojiIconButtonProps { interface EmojiIconButtonProps {
value: string; value: string;
// "" = no icon (removed in the picker)
onChange: (emoji: string) => void; 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 // 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). // picker as the icon of an open note, removing included (Doku/ui-patterns.md).
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange, fallback }) => { export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange }) => {
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null); const [anchor, setAnchor] = useState<DOMRect | null>(null);
return ( return (
@@ -19,17 +19,16 @@ export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChang
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))} onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
title="Icon auswählen" title={value ? 'Icon ändern' : 'Icon wählen'}
aria-label="Icon auswä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" 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 || <SmilePlus className="w-4 h-4 text-faint" strokeWidth={1.75} />}
</button> </button>
{anchor && ( {anchor && (
<EmojiPickerModal <EmojiPickerModal
currentIcon={value} currentIcon={value}
anchor={anchor} anchor={anchor}
allowRemove={false}
onSelectEmoji={(emoji) => { onSelectEmoji={(emoji) => {
onChange(emoji); onChange(emoji);
setAnchor(null); setAnchor(null);
@@ -257,8 +257,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0]; const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => { const getTargetLabel = () => {
if (target === 'task') return selectedTaskList ? `${selectedTaskList.icon || '📋'} ${selectedTaskList.title}` : 'Direkt-Aufgabe'; 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}` : 'Auf Liste'; if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste';
if (target === 'document') return 'Direkt-Notiz'; if (target === 'document') return 'Direkt-Notiz';
return 'Quick Dump'; return 'Quick Dump';
}; };
@@ -536,7 +536,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`} }`}
> >
<span>{tl.icon || '📋'}</span> {tl.icon && <span>{tl.icon}</span>}
<span>{tl.title}</span> <span>{tl.title}</span>
</button> </button>
); );
+2
View File
@@ -761,6 +761,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' }, { 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 + 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: '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: '/', 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: '[] 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' }, { key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
@@ -1171,7 +1171,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0"> <div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate"> <span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} {activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
</span> </span>
{openItems.length > 0 && ( {openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0"> <span className="badge-neutral text-[11px] flex-shrink-0">
@@ -1204,7 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<div> <div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label> <label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="🛒" /> <EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
<input <input
type="text" type="text"
value={newListTitle} value={newListTitle}
@@ -1540,7 +1540,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onClose={() => setShowShareModal(false)} onClose={() => setShowShareModal(false)}
title={`Liste teilen: ${activeList.title}`} title={`Liste teilen: ${activeList.title}`}
resourceName={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')} isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
shares={(activeList.shares || []).map((s) => ({ shares={(activeList.shares || []).map((s) => ({
@@ -606,7 +606,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<option value="">-- Keine Liste (Allgemein) --</option> <option value="">-- Keine Liste (Allgemein) --</option>
{taskLists.map((tl) => ( {taskLists.map((tl) => (
<option key={tl.id} value={tl.id}> <option key={tl.id} value={tl.id}>
{tl.icon} {tl.title} {[tl.icon, tl.title].filter(Boolean).join(' ')}
</option> </option>
))} ))}
</select> </select>
+112 -6
View File
@@ -596,8 +596,83 @@ export const TasksView: React.FC<TasksViewProps> = ({
// --- TASK ACTIONS --- // --- 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<UndoEntry[]>([]);
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<number | null>(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) => { const handleToggleDone = async (task: Thought) => {
triggerHaptic(15); triggerHaptic(15);
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
const nextState = !task.isCompleted; const nextState = !task.isCompleted;
const cols = columnsFor(task); const cols = columnsFor(task);
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1]; const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
@@ -615,6 +690,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => { const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
triggerHaptic(15); triggerHaptic(15);
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
const targetCol = columnsFor(task).find((c) => c.id === nextStatus); const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done'; const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
@@ -784,6 +860,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleDeletePermanent = async (id: string) => { const handleDeletePermanent = async (id: string) => {
triggerHaptic(20); triggerHaptic(20);
await api.deleteThought(id); await api.deleteThought(id);
pushUndo({ kind: 'delete', ids: [id] });
showToast('Aufgabe im Papierkorb', true);
await loadData(); await loadData();
onRefreshStats(); onRefreshStats();
}; };
@@ -803,7 +881,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleClearDone = async () => { const handleClearDone = async () => {
triggerHaptic(20); 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); 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(); await loadData();
onRefreshStats(); onRefreshStats();
}; };
@@ -967,6 +1051,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
sortOrder: idx, sortOrder: idx,
})); }));
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
setTasks(updatedWithSort); setTasks(updatedWithSort);
setSortBy('custom'); setSortBy('custom');
@@ -1103,6 +1188,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
sortOrder: idx, sortOrder: idx,
})); }));
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
setTasks(updatedWithSort); setTasks(updatedWithSort);
setSortBy('custom'); setSortBy('custom');
@@ -1333,7 +1419,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}, },
...safeTaskLists.map((tl) => ({ ...safeTaskLists.map((tl) => ({
id: `list-${tl.id}`, id: `list-${tl.id}`,
label: `${tl.icon || '📋'} ${tl.title}`, label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title,
badge: tl.tasksCount || undefined, badge: tl.tasksCount || undefined,
active: selectedListId === tl.id, active: selectedListId === tl.id,
onClick: () => setSelectedListId(tl.id), onClick: () => setSelectedListId(tl.id),
@@ -1846,7 +1932,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0"> <div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate"> <span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'} {currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
</span> </span>
<span className="badge-neutral text-[11px] flex-shrink-0"> <span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban' {viewMode === 'kanban'
@@ -2101,7 +2187,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<option value="">Standard (Alle Aufgaben)</option> <option value="">Standard (Alle Aufgaben)</option>
{safeTaskLists.map((tl) => ( {safeTaskLists.map((tl) => (
<option key={tl.id} value={tl.id}> <option key={tl.id} value={tl.id}>
{tl.icon || '📋'} {tl.title} {tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
</option> </option>
))} ))}
</select> </select>
@@ -2341,7 +2427,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div> <div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label> <label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" /> <EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
<input <input
type="text" type="text"
value={newListTitle} value={newListTitle}
@@ -2356,6 +2442,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
</FormSheet> </FormSheet>
)} )}
{/* Short notice after deleting or undoing (same look as in the workspace) */}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.undo && (
<button
type="button"
onClick={() => void undoLast()}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
title="Rückgängig (Strg+Z)"
>
Rückgängig
</button>
)}
</div>
)}
{/* UNIFIED SHARE LIST MODAL */} {/* UNIFIED SHARE LIST MODAL */}
{showShareListModal && sharingTaskList && ( {showShareListModal && sharingTaskList && (
<UnifiedShareModal <UnifiedShareModal
@@ -2366,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}} }}
title={`Aufgabenliste teilen: ${sharingTaskList.title}`} title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
resourceName={sharingTaskList.title} resourceName={sharingTaskList.title}
resourceIcon={sharingTaskList.icon || '📋'} resourceIcon={sharingTaskList.icon || undefined}
isOwner={sharingTaskList.isOwner !== false} isOwner={sharingTaskList.isOwner !== false}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
shares={(sharingTaskList.shares || []).map((sh) => ({ shares={(sharingTaskList.shares || []).map((sh) => ({
@@ -2817,7 +2923,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<p className="text-[11px] text-faint truncate"> <p className="text-[11px] text-faint truncate">
{selectedListId === 'all' && ( {selectedListId === 'all' && (
<> <>
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'} {listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
{' · '} {' · '}
</> </>
)} )}
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
onClick={() => onOpen(l.docId)} onClick={() => onOpen(l.docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group" className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
> >
<span className="shrink-0">{l.icon || '🗒️'}</span> {l.icon && <span className="shrink-0">{l.icon}</span>}
<span className="text-sm font-semibold text-main truncate group-hover:text-accent"> <span className="text-sm font-semibold text-main truncate group-hover:text-accent">
{l.title || 'Unbenannte Notiz'} {l.title || 'Unbenannte Notiz'}
</span> </span>
@@ -25,6 +25,7 @@ import {
Redo2, Redo2,
MoreHorizontal, MoreHorizontal,
CalendarDays, CalendarDays,
SmilePlus,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -708,6 +709,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[docId, canEdit, applyLocalBlocks] [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 // 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). // parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
const handleMoveSibling = useCallback( const handleMoveSibling = useCallback(
@@ -1254,6 +1268,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks} onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onReplaceRange={handleReplaceRange}
onMoveBlock={handleMoveBlock} onMoveBlock={handleMoveBlock}
onMoveSibling={handleMoveSibling} onMoveSibling={handleMoveSibling}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
@@ -1286,12 +1301,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}`} }`}
title={inJournal ? undefined : 'Icon ändern'} 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 ? ( {inJournal ? (
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} /> <CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
) : ( ) : doc?.icon ? (
doc?.icon || '🗒️' doc.icon
)} ) : canEdit ? (
<SmilePlus className="w-6 h-6 sm:w-7 sm:h-7 text-faint" strokeWidth={1.5} aria-label="Icon wählen" />
) : null}
</button> </button>
{showEmojiPicker && canEdit && ( {showEmojiPicker && canEdit && (
<EmojiPickerModal <EmojiPickerModal
@@ -1352,6 +1370,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks} onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onReplaceRange={handleReplaceRange}
onMoveBlock={handleMoveBlock} onMoveBlock={handleMoveBlock}
onMoveSibling={handleMoveSibling} onMoveSibling={handleMoveSibling}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
@@ -29,8 +29,6 @@ interface EmojiPickerModalProps {
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and // Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
// opens below this button on desktop // opens below this button on desktop
anchor?: DOMRect | null; 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<EmojiPickerModalProps> = ({ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
@@ -38,7 +36,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
onSelectEmoji, onSelectEmoji,
onClose, onClose,
anchor, anchor,
allowRemove = true,
}) => { }) => {
const { inset, visibleHeight } = useKeyboardInset(); const { inset, visibleHeight } = useKeyboardInset();
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
@@ -151,7 +148,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{currentIcon && allowRemove && ( {currentIcon && (
<button <button
type="button" type="button"
onClick={() => onSelectEmoji('')} onClick={() => onSelectEmoji('')}
@@ -205,8 +202,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</Suspense> </Suspense>
</div> </div>
{/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */} {/* Desktop: removing the icon (none at all) as a quiet last row, like an entry of a context menu */}
{currentIcon && allowRemove && ( {currentIcon && (
<button <button
type="button" type="button"
onClick={() => onSelectEmoji('')} onClick={() => onSelectEmoji('')}
@@ -362,7 +362,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer" className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
> >
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<span className="text-base shrink-0">{d.icon || '🗒️'}</span> {d.icon && <span className="text-base shrink-0">{d.icon}</span>}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-semibold text-main truncate group-hover:text-accent"> <div className="text-xs font-semibold text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'} {d.title || 'Unbenannte Notiz'}
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
onClick={() => setTarget(d.id)} onClick={() => setTarget(d.id)}
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated" className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
> >
<span className="shrink-0">{d.icon || '🗒️'}</span> {d.icon && <span className="shrink-0">{d.icon}</span>}
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span> <span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>} {d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
</button> </button>
@@ -125,6 +125,8 @@ export interface OutlinerBlockProps {
onResolveConflict?: (blockId: string, accept: boolean) => void; onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void; onUndo?: () => void;
onRedo?: () => 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 // 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<OutlinerBlockProps> = ({
onResolveConflict, onResolveConflict,
onUndo, onUndo,
onRedo, onRedo,
inRangeSelection,
}) => { }) => {
const [localContent, setLocalContent] = useState<string>(block.content || ''); const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false); const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
@@ -1077,7 +1080,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return ( return (
<div <div
data-outline-block={block.id}
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${ className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
inRangeSelection ? 'bg-accent/15' : ''
} ${
block.type === 'h1' block.type === 'h1'
? 'mt-2.5 sm:mt-3' ? 'mt-2.5 sm:mt-3'
: block.type === 'h2' : block.type === 'h2'
@@ -1387,7 +1393,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}`} }`}
> >
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}> <span ref={renderedTextRef} data-outline-text className={activeFocus ? 'invisible' : ''}>
{previewUrl && !activeFocus ? ( {previewUrl && !activeFocus ? (
// The card above shows the page; the address itself becomes a small link marker // The card above shows the page; the address itself becomes a small link marker
<a <a
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock'; import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline'; import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList'; import { numberedLabels } from '../../lib/outlineList';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
@@ -39,6 +39,9 @@ export interface OutlinerTreeProps {
onResolveConflict?: (blockId: string, accept: boolean) => void; onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void; onUndo?: () => void;
onRedo?: () => 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<OutlinerTreeHandle, OutlinerTreeProps>(({ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
@@ -67,6 +70,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onResolveConflict, onResolveConflict,
onUndo, onUndo,
onRedo, onRedo,
onReplaceRange,
}, ref) => { }, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null); const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
@@ -665,8 +669,245 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return () => window.removeEventListener('dragend', clearDrag); 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<RangeSel | null>(null);
const rangeSelRef = useRef<RangeSel | null>(null);
rangeSelRef.current = rangeSel;
const treeRootRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>(`[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<HTMLElement>('[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<HTMLElement>('[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 ( return (
<div className="space-y-0.5"> <div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => { {visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1; const isLast = entryIdx === visibleEntries.length - 1;
return ( return (
@@ -709,6 +950,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
isDragTarget={dragOverIndex === index && draggedIndex !== index} isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id} isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef} registerTextareaRef={registerTextareaRef}
inRangeSelection={rangeRowIds?.has(block.id)}
/> />
); );
})} })}
@@ -88,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group" className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
title="Schreibgeschützt ansehen" title="Schreibgeschützt ansehen"
> >
<span className="text-lg shrink-0">{t.icon || '🗒️'}</span> {t.icon && <span className="text-lg shrink-0">{t.icon}</span>}
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent"> <span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
{t.title || 'Unbenannte Notiz'} {t.title || 'Unbenannte Notiz'}
@@ -282,7 +282,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
> >
<span className="shrink-0">{d.icon || '🗒️'}</span> {d.icon && <span className="shrink-0">{d.icon}</span>}
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span> <span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div> </div>
))} ))}
@@ -589,7 +589,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
try { try {
const created = await api.createDocument({ const created = await api.createDocument({
title: title.trim() || 'Neue Notiz', title: title.trim() || 'Neue Notiz',
icon: icon.trim() || '🗒️', icon: icon.trim(),
folderPath, folderPath,
blocks: [ blocks: [
{ {
@@ -1420,7 +1420,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
map.set(d.id, { map.set(d.id, {
docId: d.id, docId: d.id,
title: d.title || 'Unbenannte Notiz', title: d.title || 'Unbenannte Notiz',
icon: d.icon || '🗒️', icon: d.icon || '',
folderPath: d.folderPath || '', folderPath: d.folderPath || '',
matchedBlocks: matched, matchedBlocks: matched,
}); });
@@ -1433,7 +1433,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
map.set(dg.docId, { map.set(dg.docId, {
docId: dg.docId, docId: dg.docId,
title: dg.title || 'Unbenannte Notiz', title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '🗒️', icon: dg.icon || '',
folderPath: dg.folderPath || '', folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [], matchedBlocks: dg.matchedBlocks || [],
}); });
@@ -1806,7 +1806,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer" 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" title="Andere Notiz wählen"
> >
{selectedDocObj?.icon} {selectedDocObj?.title} {[selectedDocObj?.icon, selectedDocObj?.title].filter(Boolean).join(' ')}
</button> </button>
<div ref={setZenActionsTarget} className="flex items-center" /> <div ref={setZenActionsTarget} className="flex items-center" />
<button <button
@@ -1990,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
> >
<span className="shrink-0">{d.icon || '🗒️'}</span> {d.icon && <span className="shrink-0">{d.icon}</span>}
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span> <span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && ( {d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" /> <Users className="w-3 h-3 text-status-ok shrink-0" />
@@ -2061,7 +2061,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
> >
<span className="shrink-0">{d.icon || '🗒️'}</span> {d.icon && <span className="shrink-0">{d.icon}</span>}
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span> <span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && ( {d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" /> <Users className="w-3 h-3 text-status-ok shrink-0" />
@@ -2363,7 +2363,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full" className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
title={meta.docs[0].title} title={meta.docs[0].title}
> >
<span>{meta.docs[0].icon || '🗒️'}</span> {meta.docs[0].icon && <span>{meta.docs[0].icon}</span>}
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span> <span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
</div> </div>
</div> </div>
@@ -2464,7 +2464,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onClick={() => handleJumpToDocBlock(docGroup.docId)} onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer" className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
> >
<span>{docGroup.icon || '🗒️'}</span> {docGroup.icon && <span>{docGroup.icon}</span>}
<span>{docGroup.title}</span> <span>{docGroup.title}</span>
{docGroup.folderPath && ( {docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal"> <span className="badge-neutral text-[10px] font-normal">
@@ -2714,7 +2714,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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" 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} title={item.doc.title}
> >
<span>{item.doc.icon || '🗒️'}</span> {item.doc.icon && <span>{item.doc.icon}</span>}
<span className="truncate max-w-[120px]">{item.doc.title}</span> <span className="truncate max-w-[120px]">{item.doc.title}</span>
{item.doc.folderPath && ( {item.doc.folderPath && (
<span className="text-faint text-[10px]">({item.doc.folderPath})</span> <span className="text-faint text-[10px]">({item.doc.folderPath})</span>
@@ -2815,7 +2815,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
) : ( ) : (
<ChevronDown className="w-3 h-3 text-accent" /> <ChevronDown className="w-3 h-3 text-accent" />
)} )}
<span>{doc.icon || '🗒️'}</span> {doc.icon && <span>{doc.icon}</span>}
<span>{doc.title}</span> <span>{doc.title}</span>
<span className="badge-neutral text-[9px] font-mono"> <span className="badge-neutral text-[9px] font-mono">
{docOpenCount} offen {docOpenCount} offen
@@ -2967,7 +2967,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}} }}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="🗒️" /> <EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} />
<input <input
type="text" type="text"
autoFocus autoFocus
+5
View File
@@ -606,6 +606,11 @@ body {
} }
} }
/* Selection over several bullets in a note (OutlinerTree, CSS Highlight API) */
::highlight(outline-range) {
background-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */ /* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
fieldset:disabled :is(button, input, select, textarea) { fieldset:disabled :is(button, input, select, textarea) {
opacity: 0.5; opacity: 0.5;
+3 -2
View File
@@ -953,11 +953,12 @@ export const api = {
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> { async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
let title = titleOrSpaceId; 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'; let type = (iconOrType as any) || 'shopping';
if (listType !== undefined) { if (listType !== undefined) {
title = titleOrIcon || ''; title = titleOrIcon || '';
icon = iconOrType || '🛒'; icon = iconOrType ?? '🛒';
type = listType; type = listType;
} }
const l = await request<SharedList>('/api/v1/lists', { const l = await request<SharedList>('/api/v1/lists', {
+15 -2
View File
@@ -47,7 +47,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
SpaceID string `json:"spaceId"` SpaceID string `json:"spaceId"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon *string `json:"icon"` // "" = no icon; missing = the default
FolderPath string `json:"folderPath"` FolderPath string `json:"folderPath"`
Blocks []domain.DocumentBlock `json:"blocks"` Blocks []domain.DocumentBlock `json:"blocks"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
@@ -58,7 +58,12 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -330,3 +335,11 @@ func (s *Server) docTitle(userID, docID string) string {
} }
return "Unbenannte Notiz" 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
}
+7 -2
View File
@@ -39,7 +39,7 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon *string `json:"icon"` // "" = no icon; missing = the default
ListType string `json:"listType"` ListType string `json:"listType"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
+7 -2
View File
@@ -28,14 +28,19 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
SpaceID string `json:"spaceId"` SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"` TeamID string `json:"teamId"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon *string `json:"icon"` // "" = no icon; missing = the default
Color string `json:"color"` Color string `json:"color"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
+12
View File
@@ -958,3 +958,15 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
} }
return users, nil 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
}
+30
View File
@@ -4,6 +4,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"github.com/sandrodach/mindsift/internal/domain"
) )
func inTaskTrash(s *Store, id string) bool { 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") 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")
}
}
+19 -1
View File
@@ -238,7 +238,7 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
for _, it := range items { for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" { if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0 isCompleted := 0
if *it.KanbanStatus == "done" { if isDoneColumn(tx, it.ID, *it.KanbanStatus) {
isCompleted = 1 isCompleted = 1
var was int var was int
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was) _ = 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 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 // UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) { func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID) thought, err := s.GetThoughtByID(userID, thoughtID)