feat: select text across bullets and delete it like a text editor; Ctrl+Z in tasks; notes and lists without emoji; custom done columns complete tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2b9daba75
commit
6fb98d4002
27 files changed
+535
-64
No files matched your search
@@ -596,8 +596,83 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
// --- 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) => {
|
||||
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<TasksViewProps> = ({
|
||||
|
||||
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<TasksViewProps> = ({
|
||||
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<TasksViewProps> = ({
|
||||
|
||||
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<TasksViewProps> = ({
|
||||
sortOrder: idx,
|
||||
}));
|
||||
|
||||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||
setTasks(updatedWithSort);
|
||||
setSortBy('custom');
|
||||
|
||||
@@ -1103,6 +1188,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
sortOrder: idx,
|
||||
}));
|
||||
|
||||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||
setTasks(updatedWithSort);
|
||||
setSortBy('custom');
|
||||
|
||||
@@ -1333,7 +1419,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
},
|
||||
...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<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 items-center gap-2 min-w-0">
|
||||
<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 className="badge-neutral text-[11px] flex-shrink-0">
|
||||
{viewMode === 'kanban'
|
||||
@@ -2101,7 +2187,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<option value="">Standard (Alle Aufgaben)</option>
|
||||
{safeTaskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon || '📋'} {tl.title}
|
||||
{tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -2341,7 +2427,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" />
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
@@ -2356,6 +2442,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</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 */}
|
||||
{showShareListModal && sharingTaskList && (
|
||||
<UnifiedShareModal
|
||||
@@ -2366,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}}
|
||||
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<TasksViewProps> = ({
|
||||
<p className="text-[11px] text-faint truncate">
|
||||
{selectedListId === 'all' && (
|
||||
<>
|
||||
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'}
|
||||
{listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user