MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3039 lines
129 KiB
TypeScript
3039 lines
129 KiB
TypeScript
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||
import { AnimatePresence, motion } from 'framer-motion';
|
||
import {
|
||
Check,
|
||
Plus,
|
||
Calendar,
|
||
Trash2,
|
||
RotateCcw,
|
||
FileText,
|
||
LayoutList,
|
||
Columns,
|
||
Maximize2,
|
||
Minimize2,
|
||
ArrowRight,
|
||
ArrowLeft,
|
||
Edit3,
|
||
X,
|
||
Layers,
|
||
Users,
|
||
CheckSquare,
|
||
User as
|
||
Lock,
|
||
Hash,
|
||
AlertTriangle,
|
||
Flame,
|
||
Settings,
|
||
Folder,
|
||
Filter,
|
||
Repeat,
|
||
} from 'lucide-react';
|
||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
|
||
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||
import { api, triggerHaptic } from '../../lib/api';
|
||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||
import { TaskDetailModal } from './TaskDetailModal';
|
||
import { OriginBadge } from '../../components/OriginBadge';
|
||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||
import { CountBadge } from '../../components/CountBadge';
|
||
import { FormSheet } from '../../components/FormSheet';
|
||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||
import { useMobileActions } from '../../context/MobileActionContext';
|
||
|
||
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
|
||
// longer tap, so the row would not open
|
||
const canDragTasks =
|
||
typeof window !== 'undefined' && !!window.matchMedia?.('(any-pointer: fine)').matches;
|
||
|
||
// Rows inside a task card keep taps on their own controls; taps on empty space or plain labels
|
||
// (due date, webhook) reach the card and open the task
|
||
const stopOnControl = (e: React.MouseEvent) => {
|
||
if ((e.target as HTMLElement).closest('button, select, a, input')) e.stopPropagation();
|
||
};
|
||
|
||
// Same rule as the server's task query: a fresh Quick Dump note (status inbox) is no task yet
|
||
const isTaskThought = (t: Thought) =>
|
||
t.status === 'kept' || (t.status === 'converted' && t.convertedType === 'task');
|
||
|
||
interface TasksViewProps {
|
||
currentUser?: User | null;
|
||
onRefreshStats: () => void;
|
||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||
refreshTrigger?: number;
|
||
initialSelectedTaskId?: string | null;
|
||
// The task from initialSelectedTaskId was opened; the parent forgets it, so returning to the tab does
|
||
// not open it again
|
||
onInitialTaskShown?: () => void;
|
||
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||
}
|
||
|
||
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
||
// Wide Kanban: a column grows to at most this width, the board stays centred
|
||
const KANBAN_COL_MAX = 380;
|
||
const KANBAN_GAP = 12; // gap-3
|
||
|
||
// Only the board leaves the page column (header and "new task" keep their width): its width follows
|
||
// the columns (at most KANBAN_COL_MAX each, at most the window) and a margin keeps it centred – a
|
||
// negative one when it is wider than the page column. Width and margin are animated.
|
||
const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties => {
|
||
if (!wide) return { '--kanban-w': '100%', '--kanban-ml': '0px' } as React.CSSProperties;
|
||
const w = `min(100vw - 4rem, ${columns * KANBAN_COL_MAX + (columns - 1) * KANBAN_GAP}px)`;
|
||
return {
|
||
'--kanban-cols': columns,
|
||
'--kanban-w': w,
|
||
'--kanban-ml': `calc((100% - ${w}) / 2)`,
|
||
} as React.CSSProperties;
|
||
};
|
||
|
||
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
|
||
|
||
export const TasksView: React.FC<TasksViewProps> = ({
|
||
currentUser,
|
||
onRefreshStats,
|
||
onOpenDocument,
|
||
refreshTrigger = 0,
|
||
initialSelectedTaskId,
|
||
onInitialTaskShown,
|
||
openTasksCount,
|
||
}) => {
|
||
// Open list and view survive switching to other tabs (like the workspace's open note)
|
||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
|
||
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
|
||
);
|
||
// Wide Kanban: the board takes the whole window and shows every column in one row
|
||
const [kanbanWide, setKanbanWide] = useState(() => {
|
||
try {
|
||
return localStorage.getItem(KANBAN_WIDE_KEY) === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
const toggleKanbanWide = () => {
|
||
const next = !kanbanWide;
|
||
setKanbanWide(next);
|
||
try {
|
||
localStorage.setItem(KANBAN_WIDE_KEY, next ? '1' : '0');
|
||
} catch {
|
||
// only a convenience
|
||
}
|
||
};
|
||
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
|
||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||
const [selectedListId, setSelectedListId] = useState<string>(
|
||
() => readViewSession<TasksSession>('tasks').listId || 'all'
|
||
);
|
||
useEffect(() => {
|
||
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||
}, [selectedListId, viewMode]);
|
||
const [teams, setTeams] = useState<Team[]>([]);
|
||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
|
||
const [taskTrash, setTaskTrash] = useState<TrashedTask[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// Drag and Drop state
|
||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
||
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
||
// Kanban drag: the column works out the slot from the pointer; a gap of the card's height opens there
|
||
// (no per-card enter/leave flicker), the dragged card leaves its old place, and lights up where it lands
|
||
const [kanbanSlot, setKanbanSlot] = useState<{ col: string; index: number } | null>(null);
|
||
const [kanbanHiddenId, setKanbanHiddenId] = useState<string | null>(null);
|
||
const [justDroppedId, setJustDroppedId] = useState<string | null>(null);
|
||
const dragHeightRef = useRef(64);
|
||
// List tab a dragged task hovers over (dropping moves it into that list)
|
||
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||
|
||
// Quick Task Add state
|
||
const [quickTitle, setQuickTitle] = useState('');
|
||
const [quickVoiceError, setQuickVoiceError] = useState<string | null>(null);
|
||
const [quickDueDate, setQuickDueDate] = useState('');
|
||
const quickInputRef = useRef<HTMLInputElement | null>(null);
|
||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||
|
||
// Mobile task creation & filter modal state
|
||
const [showMobileCreateModal, setShowMobileCreateModal] = useState(false);
|
||
const [mobileCreateTitle, setMobileCreateTitle] = useState('');
|
||
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
|
||
const mobileCreateInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [mobileCreateDueDate, setMobileCreateDueDate] = useState('');
|
||
const [mobileCreatePriority, setMobileCreatePriority] = useState<PriorityLevel>('normal');
|
||
const [mobileCreateListId, setMobileCreateListId] = useState<string>('');
|
||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||
|
||
// New TaskList modal state
|
||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||
const [newListTitle, setNewListTitle] = useState('');
|
||
const [newListIcon, setNewListIcon] = useState('📋');
|
||
|
||
// Share TaskList modal state
|
||
const [showShareListModal, setShowShareListModal] = useState(false);
|
||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||
|
||
// Detail modal state
|
||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
const [extraTask, setExtraTask] = useState<Thought | null>(null);
|
||
|
||
const onInitialTaskShownRef = useRef(onInitialTaskShown);
|
||
onInitialTaskShownRef.current = onInitialTaskShown;
|
||
useEffect(() => {
|
||
if (initialSelectedTaskId) {
|
||
setDetailTaskId(initialSelectedTaskId);
|
||
setShowDetailModal(true);
|
||
setFilter('open');
|
||
onInitialTaskShownRef.current?.();
|
||
api.getThought(initialSelectedTaskId).then((th) => {
|
||
if (th) setExtraTask(th);
|
||
}).catch(() => {});
|
||
}
|
||
}, [initialSelectedTaskId]);
|
||
|
||
// Manage columns modal state
|
||
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
|
||
const [globalColumns, setGlobalColumns] = useState<KanbanColumn[]>(() => {
|
||
try {
|
||
const stored = localStorage.getItem('mindsift:kanban_columns');
|
||
if (stored) {
|
||
const parsed = JSON.parse(stored);
|
||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||
}
|
||
} catch {}
|
||
return DEFAULT_KANBAN_COLUMNS;
|
||
});
|
||
|
||
// Hashtag filtering, grouping and sorting state
|
||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||
// Hashtag row on desktop: whether it is cut off, and whether it is opened up into several lines
|
||
const tagRowRef = useRef<HTMLDivElement>(null);
|
||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||
const [tagsOverflow, setTagsOverflow] = useState(false);
|
||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
||
|
||
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
|
||
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
|
||
// happened to fall into the reload (press and release then hit different elements).
|
||
const loadedListRef = useRef<string | null>(null);
|
||
const loadSeqRef = useRef(0);
|
||
const loadData = async () => {
|
||
const seq = ++loadSeqRef.current;
|
||
const listId = selectedListId;
|
||
if (loadedListRef.current !== listId) setLoading(true);
|
||
try {
|
||
const [allTasks, allLists, allTeams, allCollabs, trash] = await Promise.all([
|
||
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
|
||
api.getTaskLists().catch(() => []),
|
||
api.getUserTeams().catch(() => []),
|
||
api.getCollaborators().catch(() => []),
|
||
api.getTaskTrash(listId === 'all' ? '' : listId).catch(() => []),
|
||
]);
|
||
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||
loadedListRef.current = listId;
|
||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||
// The remembered list was deleted or unshared meanwhile
|
||
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
|
||
setSelectedListId('all');
|
||
}
|
||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||
setTaskTrash(Array.isArray(trash) ? trash : []);
|
||
} catch {
|
||
// Prevent unhandled promise rejection from crashing view
|
||
} finally {
|
||
if (seq === loadSeqRef.current) setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [refreshTrigger, selectedListId]);
|
||
|
||
const selectedListIdRef = useRef(selectedListId);
|
||
selectedListIdRef.current = selectedListId;
|
||
|
||
const onRefreshStatsRef = useRef(onRefreshStats);
|
||
onRefreshStatsRef.current = onRefreshStats;
|
||
// The realtime handler is registered once; it must reload with the current list, not the first one
|
||
const loadDataRef = useRef(loadData);
|
||
loadDataRef.current = loadData;
|
||
|
||
// Live-Sync WebSocket listener for tasks (registered once on mount)
|
||
useEffect(() => {
|
||
const handleWs = (e: any) => {
|
||
const msg = e.detail;
|
||
if (!msg || !msg.event) return;
|
||
|
||
if (msg.event === 'TASK_CREATED') {
|
||
const created = msg.payload as Thought;
|
||
if (created && isTaskThought(created)) {
|
||
setTasks((prev) => {
|
||
if (prev.some((t) => t.id === created.id)) return prev;
|
||
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
|
||
return [created, ...prev];
|
||
});
|
||
onRefreshStatsRef.current();
|
||
}
|
||
} else if (msg.event === 'TASK_UPDATED') {
|
||
const updated = msg.payload as Thought;
|
||
if (updated && updated.id) {
|
||
setTasks((prev) => {
|
||
const exists = prev.some((t) => t.id === updated.id);
|
||
if (!exists) {
|
||
if (
|
||
isTaskThought(updated) &&
|
||
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
|
||
) {
|
||
return [updated, ...prev];
|
||
}
|
||
return prev;
|
||
}
|
||
// Undone back into the Quick Dump, discarded, or moved to another list
|
||
if (
|
||
!isTaskThought(updated) ||
|
||
(selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current)
|
||
) {
|
||
return prev.filter((t) => t.id !== updated.id);
|
||
}
|
||
return prev.map((t) => (t.id === updated.id ? updated : t));
|
||
});
|
||
onRefreshStatsRef.current();
|
||
}
|
||
} else if (msg.event === 'TASK_DELETED') {
|
||
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
||
if (deletedId) {
|
||
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||
onRefreshStatsRef.current();
|
||
}
|
||
} else if (msg.event === 'TASK_REORDERED') {
|
||
loadDataRef.current();
|
||
} else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
|
||
// Payload only carries the list ID; reload so permissions reflect this user's view
|
||
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||
}
|
||
};
|
||
|
||
window.addEventListener('mindsift:ws', handleWs);
|
||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||
}, []);
|
||
|
||
const safeTaskLists = useMemo(() => (Array.isArray(taskLists) ? taskLists : []), [taskLists]);
|
||
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
|
||
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
|
||
const isTaskReadOnly = (task: Thought) =>
|
||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||
|
||
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
|
||
// assignees who lost access before the task payload caught up
|
||
const membersByList = useMemo(() => {
|
||
const m = new Map<string, TaskListMember[]>();
|
||
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
|
||
return m;
|
||
}, [safeTaskLists]);
|
||
const assignableMembers = (task: Thought): TaskListMember[] => {
|
||
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
|
||
return members.length > 1 ? members : [];
|
||
};
|
||
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
|
||
id: m.userId,
|
||
name: m.displayName || m.email,
|
||
avatarUrl: m.avatarUrl,
|
||
});
|
||
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
|
||
const members = assignableMembers(task);
|
||
return (task.assignees || [])
|
||
.map((id) => members.find((m) => m.userId === id))
|
||
.filter((m): m is TaskListMember => !!m)
|
||
.map(memberToPerson);
|
||
};
|
||
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
|
||
const people = getAssigneePeople(task);
|
||
if (people.length === 0) return null;
|
||
return (
|
||
<AvatarStack
|
||
people={people}
|
||
size={size}
|
||
ringClass="ring-[var(--bg-panel)]"
|
||
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// Active Kanban Columns (from selected list, or global custom, or default)
|
||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||
if (
|
||
currentSelectedList &&
|
||
Array.isArray(currentSelectedList.columns) &&
|
||
currentSelectedList.columns.length > 0
|
||
) {
|
||
return currentSelectedList.columns;
|
||
}
|
||
return globalColumns;
|
||
}, [currentSelectedList, globalColumns]);
|
||
|
||
// The columns of the list a task belongs to: in "Alle Aufgaben" every task keeps its own list's columns
|
||
const columnsFor = useCallback(
|
||
(task: Thought): KanbanColumn[] => {
|
||
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
|
||
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
|
||
},
|
||
[safeTaskLists, activeColumns]
|
||
);
|
||
|
||
// The column of its own list a task stands in; a status whose column is gone counts as the first open one
|
||
const ownColumnOf = useCallback(
|
||
(task: Thought): KanbanColumn | undefined => {
|
||
const cols = columnsFor(task);
|
||
if (task.isCompleted) return cols.find((c) => c.isDone && c.id === task.kanbanStatus) || cols.find((c) => c.isDone);
|
||
return (
|
||
cols.find((c) => c.id === task.kanbanStatus) ||
|
||
cols.find((c) => c.id === 'todo') ||
|
||
cols.find((c) => !c.isDone)
|
||
);
|
||
},
|
||
[columnsFor]
|
||
);
|
||
|
||
// Board columns: those of the chosen list; in "Alle Aufgaben" the columns of all lists joined by name
|
||
// (case aside), in the order and colour of the first list that has them. Their IDs are then "name:…".
|
||
const isCombinedBoard = selectedListId === 'all';
|
||
const boardIdOf = useCallback(
|
||
(col: KanbanColumn) => (isCombinedBoard ? `name:${col.name.trim().toLowerCase()}` : col.id),
|
||
[isCombinedBoard]
|
||
);
|
||
const boardColumns: KanbanColumn[] = useMemo(() => {
|
||
if (!isCombinedBoard) return activeColumns;
|
||
const out: KanbanColumn[] = [];
|
||
const sources = [
|
||
...safeTaskLists.map((l) => (Array.isArray(l.columns) && l.columns.length > 0 ? l.columns : globalColumns)),
|
||
globalColumns, // personal tasks
|
||
];
|
||
const idOf = (col: KanbanColumn) => `name:${col.name.trim().toLowerCase()}`;
|
||
for (const cols of sources) {
|
||
cols.forEach((col, i) => {
|
||
const id = idOf(col);
|
||
if (out.some((c) => c.id === id)) return;
|
||
// A new column goes right before the next column of its list that is already on the board
|
||
const next = cols.slice(i + 1).map((c) => out.findIndex((o) => o.id === idOf(c))).find((at) => at >= 0);
|
||
out.splice(next ?? out.length, 0, { ...col, id });
|
||
});
|
||
}
|
||
return out;
|
||
}, [isCombinedBoard, activeColumns, safeTaskLists, globalColumns]);
|
||
|
||
// The task's own column behind a board column, or null if its list has no column of that name
|
||
const ownColumnIdFor = (task: Thought | undefined, boardColId: string): string | null =>
|
||
task ? (columnsFor(task).find((c) => boardIdOf(c) === boardColId)?.id ?? null) : null;
|
||
const inBoardColumn = useCallback(
|
||
(task: Thought, boardCol: KanbanColumn) => {
|
||
const own = ownColumnOf(task);
|
||
return !!own && boardIdOf(own) === boardCol.id;
|
||
},
|
||
[ownColumnOf, boardIdOf]
|
||
);
|
||
|
||
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page
|
||
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll.
|
||
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
||
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
||
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4);
|
||
useEffect(() => {
|
||
if (!kanbanOneRow) {
|
||
setKanbanColHeight(null);
|
||
return;
|
||
}
|
||
const fit = () => {
|
||
const el = kanbanBoardRef.current;
|
||
if (!el || window.innerWidth < 1024) {
|
||
setKanbanColHeight(null);
|
||
return;
|
||
}
|
||
// From the board's top to the window's bottom; then take back what still overflows (footer, paddings)
|
||
const top = el.getBoundingClientRect().top + window.scrollY;
|
||
setKanbanColHeight(Math.max(320, window.innerHeight - top - 16));
|
||
requestAnimationFrame(() => {
|
||
const over = document.documentElement.scrollHeight - window.innerHeight;
|
||
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
|
||
});
|
||
};
|
||
fit();
|
||
window.addEventListener('resize', fit);
|
||
return () => window.removeEventListener('resize', fit);
|
||
}, [kanbanOneRow, selectedListId, loading]);
|
||
|
||
const safeTasks = useMemo(() => {
|
||
if (!Array.isArray(tasks)) return [];
|
||
const seen = new Set<string>();
|
||
const unique: Thought[] = [];
|
||
for (const t of tasks) {
|
||
if (t && t.id && !seen.has(t.id)) {
|
||
seen.add(t.id);
|
||
unique.push(t);
|
||
}
|
||
}
|
||
return unique;
|
||
}, [tasks]);
|
||
|
||
const detailTask = useMemo(() => {
|
||
if (!detailTaskId) return null;
|
||
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
|
||
}, [detailTaskId, tasks, extraTask]);
|
||
|
||
const safeTeams = Array.isArray(teams) ? teams : [];
|
||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
||
|
||
// Available unique hashtags from current tasks
|
||
const allAvailableTags = useMemo(() => {
|
||
const set = new Set<string>();
|
||
for (const t of safeTasks) {
|
||
if (Array.isArray(t.tags)) {
|
||
for (const tg of t.tags) {
|
||
if (tg) set.add(tg);
|
||
}
|
||
}
|
||
}
|
||
return Array.from(set).sort();
|
||
}, [safeTasks]);
|
||
|
||
// Cut off? Measured while the row is one line (on resize and when the tags change)
|
||
useEffect(() => {
|
||
const el = tagRowRef.current;
|
||
if (!el || tagsExpanded) return;
|
||
const measure = () => setTagsOverflow(el.scrollWidth > el.clientWidth + 1);
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [allAvailableTags, tagsExpanded, viewMode]);
|
||
|
||
// Filtered tasks for List view
|
||
const openTasks = safeTasks.filter((t) => !t.isCompleted);
|
||
const doneTasks = safeTasks.filter((t) => t.isCompleted);
|
||
|
||
const filteredTasks = useMemo(() => {
|
||
let result = safeTasks;
|
||
if (filter === 'open') {
|
||
result = result.filter((t) => !t.isCompleted);
|
||
} else if (filter === 'done') {
|
||
result = result.filter((t) => t.isCompleted);
|
||
}
|
||
if (selectedTag) {
|
||
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
|
||
}
|
||
if (sortBy === 'custom') {
|
||
return [...result].sort((a, b) => {
|
||
const orderA = a.sortOrder ?? 0;
|
||
const orderB = b.sortOrder ?? 0;
|
||
if (orderA !== orderB) return orderA - orderB;
|
||
return b.createdAt.localeCompare(a.createdAt);
|
||
});
|
||
}
|
||
return [...result].sort((a, b) => {
|
||
if (sortBy === 'due') {
|
||
if (!a.dueDate && !b.dueDate) return 0;
|
||
if (!a.dueDate) return 1;
|
||
if (!b.dueDate) return -1;
|
||
return a.dueDate.localeCompare(b.dueDate);
|
||
}
|
||
if (sortBy === 'priority') {
|
||
const pScore: Record<string, number> = { urgent: 3, high: 2, normal: 1, low: 0 };
|
||
const scoreA = pScore[a.priority] || 1;
|
||
const scoreB = pScore[b.priority] || 1;
|
||
return scoreB - scoreA;
|
||
}
|
||
if (sortBy === 'alpha') {
|
||
return a.content.localeCompare(b.content);
|
||
}
|
||
return b.createdAt.localeCompare(a.createdAt);
|
||
});
|
||
}, [safeTasks, filter, selectedTag, sortBy]);
|
||
|
||
// Tasks for Kanban board (shows all non-discarded matching tag)
|
||
const kanbanTasks = useMemo(() => {
|
||
let result = safeTasks;
|
||
if (selectedTag) {
|
||
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
|
||
}
|
||
return result;
|
||
}, [safeTasks, selectedTag]);
|
||
|
||
// Grouped tasks calculation
|
||
const groupedTasks = useMemo(() => {
|
||
if (groupBy === 'none') return null;
|
||
const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = [];
|
||
|
||
if (groupBy === 'tag') {
|
||
const map = new Map<string, Thought[]>();
|
||
const withoutTag: Thought[] = [];
|
||
for (const t of filteredTasks) {
|
||
if (!t.tags || t.tags.length === 0) {
|
||
withoutTag.push(t);
|
||
} else {
|
||
for (const tg of t.tags) {
|
||
if (!map.has(tg)) map.set(tg, []);
|
||
map.get(tg)!.push(t);
|
||
}
|
||
}
|
||
}
|
||
Array.from(map.keys()).sort().forEach((tg) => {
|
||
const list = map.get(tg)!;
|
||
groups.push({ key: `tag:${tg}`, title: `#${tg}`, count: list.length, tasks: list });
|
||
});
|
||
if (withoutTag.length > 0) {
|
||
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
|
||
}
|
||
} else if (groupBy === 'status') {
|
||
boardColumns.forEach((col) => {
|
||
const list = filteredTasks.filter((t) => inBoardColumn(t, col));
|
||
groups.push({ key: `status:${col.id}`, title: col.name, count: list.length, tasks: list });
|
||
});
|
||
} else if (groupBy === 'priority') {
|
||
const urgent = filteredTasks.filter((t) => t.priority === 'urgent');
|
||
const high = filteredTasks.filter((t) => t.priority === 'high');
|
||
const normal = filteredTasks.filter((t) => t.priority !== 'urgent' && t.priority !== 'high');
|
||
if (urgent.length > 0) groups.push({ key: 'p:urgent', title: '🔥 Dringend', count: urgent.length, tasks: urgent });
|
||
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
||
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
||
} else if (groupBy === 'due') {
|
||
const now = new Date();
|
||
const today = localDay(now);
|
||
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
|
||
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
|
||
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
|
||
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
||
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
||
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
||
if (upcoming.length > 0) groups.push({ key: 'due:upcoming', title: 'Demnächst', count: upcoming.length, tasks: upcoming });
|
||
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
|
||
}
|
||
return groups;
|
||
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]);
|
||
|
||
// --- 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];
|
||
const todoCol = cols.find((c) => !c.isDone) || cols[0];
|
||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === task.id ? { ...t, isCompleted: nextState, kanbanStatus: nextKanban } : t
|
||
)
|
||
);
|
||
await api.updateThought(task.id, { isCompleted: nextState, kanbanStatus: nextKanban });
|
||
onRefreshStats();
|
||
};
|
||
|
||
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';
|
||
|
||
setTasks((prev) =>
|
||
prev.map((t) => (t.id === task.id ? { ...t, kanbanStatus: nextStatus, isCompleted } : t))
|
||
);
|
||
await api.updateThought(task.id, { kanbanStatus: nextStatus, isCompleted });
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleSetPriority = async (task: Thought, nextPriority: string) => {
|
||
triggerHaptic(10);
|
||
const normalized = nextPriority === 'none' || nextPriority === '' ? 'normal' : nextPriority;
|
||
setTasks((prev) =>
|
||
prev.map((t) => (t.id === task.id ? { ...t, priority: normalized as any } : t))
|
||
);
|
||
await api.updateThought(task.id, { priority: normalized });
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleCyclePriority = async (task: Thought) => {
|
||
triggerHaptic(10);
|
||
const nextPriority =
|
||
task.priority === 'urgent'
|
||
? 'normal'
|
||
: task.priority === 'high'
|
||
? 'urgent'
|
||
: 'high';
|
||
await handleSetPriority(task, nextPriority);
|
||
};
|
||
|
||
const handleUpdateTaskTitle = async (task: Thought, newTitle: string) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, content: newTitle } : t)));
|
||
await api.updateThought(task.id, { content: newTitle });
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleUpdateTaskDueDate = async (task: Thought, newDate: string) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, dueDate: newDate } : t)));
|
||
await api.updateThought(task.id, { dueDate: newDate });
|
||
};
|
||
|
||
const handleUpdateTaskTaskList = async (task: Thought, newListId: string) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, taskListId: newListId } : t)));
|
||
await api.updateThought(task.id, { taskListId: newListId });
|
||
if (selectedListId !== 'all' && selectedListId !== newListId) {
|
||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||
}
|
||
onRefreshStats();
|
||
};
|
||
|
||
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
|
||
// if the list has that column, otherwise it starts in the list's first column
|
||
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
|
||
e.preventDefault();
|
||
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
|
||
setDraggingTaskId(null);
|
||
setDragOverTaskTarget(null);
|
||
setDragOverKanbanCol(null);
|
||
setDragOverListTab(null);
|
||
const task = tasks.find((t) => t.id === sourceId);
|
||
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
|
||
triggerHaptic(15);
|
||
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
|
||
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
|
||
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
|
||
setTasks((prev) =>
|
||
selectedListId !== 'all'
|
||
? prev.filter((t) => t.id !== task.id)
|
||
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
|
||
);
|
||
try {
|
||
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
|
||
} catch {
|
||
loadData();
|
||
}
|
||
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
||
await api.updateThought(task.id, { notes: newNotes });
|
||
};
|
||
|
||
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
|
||
try {
|
||
await api.updateThought(task.id, { recurrence });
|
||
} catch {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
|
||
}
|
||
};
|
||
|
||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||
triggerHaptic(10);
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||
try {
|
||
const updated = await api.updateThought(task.id, { assignees });
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
|
||
} catch {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
|
||
}
|
||
};
|
||
|
||
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||
await api.updateThought(task.id, { tags: newTags });
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
|
||
triggerHaptic(10);
|
||
const updatedSubtasks = (task.subtasks || []).map((s) =>
|
||
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
|
||
);
|
||
setTasks((prev) =>
|
||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||
);
|
||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||
};
|
||
|
||
const handleAddSubtask = async (task: Thought, title: string) => {
|
||
triggerHaptic(10);
|
||
const newSub: SubTask = {
|
||
id: `st_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||
title: title.trim(),
|
||
isCompleted: false,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
const updatedSubtasks = [...(task.subtasks || []), newSub];
|
||
setTasks((prev) =>
|
||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||
);
|
||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||
};
|
||
|
||
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
|
||
triggerHaptic(10);
|
||
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
|
||
setTasks((prev) =>
|
||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||
);
|
||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||
};
|
||
|
||
const handleConvertToDoc = async (task: Thought) => {
|
||
triggerHaptic(20);
|
||
// Already linked to a note: open it instead of creating another one
|
||
if (task.docId) {
|
||
onOpenDocument(task.docId, task.blockId);
|
||
return;
|
||
}
|
||
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
||
onRefreshStats();
|
||
// The task stays in its list, linked to the note's first checkbox (docId); open that note
|
||
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||
const docId = updated.docId || (updated.convertedType === 'document' ? updated.convertedRefId : '');
|
||
if (docId) {
|
||
onOpenDocument(docId);
|
||
} else {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
// Deleting a task puts it into the task trash (task_trash.go)
|
||
const handleDeletePermanent = async (id: string) => {
|
||
triggerHaptic(20);
|
||
await api.deleteThought(id);
|
||
pushUndo({ kind: 'delete', ids: [id] });
|
||
showToast('Aufgabe im Papierkorb', true);
|
||
await loadData();
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handleRestoreTrashed = async (item: TrashedTask) => {
|
||
triggerHaptic(15);
|
||
await api.restoreTrashedTask(item.id);
|
||
await loadData();
|
||
onRefreshStats();
|
||
};
|
||
|
||
const handlePurgeTrashed = async (item: TrashedTask) => {
|
||
triggerHaptic(20);
|
||
await api.purgeTrashedTask(item.id);
|
||
await loadData();
|
||
};
|
||
|
||
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();
|
||
};
|
||
|
||
const handleEmptyTaskTrash = async () => {
|
||
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `von „${currentSelectedList?.title || 'dieser Liste'}“`;
|
||
if (!window.confirm(`Den Papierkorb ${scope} endgültig leeren?`)) return;
|
||
triggerHaptic(20);
|
||
await api.emptyTaskTrash(selectedListId === 'all' ? '' : selectedListId);
|
||
await loadData();
|
||
};
|
||
|
||
const handleQuickAddTask = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const title = quickTitle.trim();
|
||
if (!title) return;
|
||
triggerHaptic(10);
|
||
setQuickTitle('');
|
||
|
||
try {
|
||
const created = await api.captureThought(title, 'normal', 'manual', quickDueDate, {
|
||
target: 'task',
|
||
taskListId: selectedListId === 'all' ? '' : selectedListId,
|
||
});
|
||
setTasks((prev) => {
|
||
if (prev.some((t) => t.id === created.id)) return prev;
|
||
return [created, ...prev];
|
||
});
|
||
onRefreshStats();
|
||
} catch {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
const handleMobileCreateTask = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const title = mobileCreateTitle.trim();
|
||
if (!title) return;
|
||
triggerHaptic(12);
|
||
|
||
try {
|
||
const created = await api.captureThought(title, mobileCreatePriority, 'manual', mobileCreateDueDate || undefined, {
|
||
target: 'task',
|
||
taskListId: mobileCreateListId || (selectedListId === 'all' ? '' : selectedListId),
|
||
});
|
||
setTasks((prev) => {
|
||
if (prev.some((t) => t.id === created.id)) return prev;
|
||
return [created, ...prev];
|
||
});
|
||
setMobileCreateTitle('');
|
||
setMobileCreateDueDate('');
|
||
setMobileCreatePriority('normal');
|
||
setShowMobileCreateModal(false);
|
||
onRefreshStats();
|
||
} catch {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
const handleQuickAddTaskToColumn = async (columnId: string) => {
|
||
const title = prompt('Neue Aufgabe für diese Spalte:');
|
||
if (!title || !title.trim()) return;
|
||
triggerHaptic(10);
|
||
const targetCol = activeColumns.find((c) => boardIdOf(c) === columnId);
|
||
if (!targetCol) return;
|
||
columnId = targetCol.id;
|
||
const isCompleted = !!targetCol.isDone;
|
||
try {
|
||
const created = await api.captureThought(title.trim(), 'normal', 'manual', undefined, {
|
||
target: 'task',
|
||
taskListId: selectedListId === 'all' ? '' : selectedListId,
|
||
});
|
||
if (created && created.id) {
|
||
const updated = await api.updateThought(created.id, { kanbanStatus: columnId, isCompleted });
|
||
setTasks((prev) => [updated, ...prev]);
|
||
onRefreshStats();
|
||
}
|
||
} catch {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
|
||
e.stopPropagation();
|
||
if (isTaskReadOnly(task)) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
e.dataTransfer.setData('application/mindsift-task-id', task.id);
|
||
e.dataTransfer.setData('text/plain', task.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setDraggingTaskId(task.id);
|
||
};
|
||
|
||
const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!draggingTaskId || draggingTaskId === targetTaskId) return;
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
||
|
||
if (
|
||
!dragOverTaskTarget ||
|
||
dragOverTaskTarget.taskId !== targetTaskId ||
|
||
dragOverTaskTarget.position !== position
|
||
) {
|
||
setDragOverTaskTarget({ taskId: targetTaskId, position });
|
||
}
|
||
};
|
||
|
||
const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => {
|
||
e.stopPropagation();
|
||
if (dragOverTaskTarget?.taskId === targetTaskId) {
|
||
setDragOverTaskTarget(null);
|
||
}
|
||
};
|
||
|
||
const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const sourceId =
|
||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||
e.dataTransfer.getData('text/plain') ||
|
||
draggingTaskId;
|
||
|
||
const position = dragOverTaskTarget?.position || 'after';
|
||
setDraggingTaskId(null);
|
||
setDragOverTaskTarget(null);
|
||
setDragOverKanbanCol(null);
|
||
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
|
||
};
|
||
|
||
// Shared by mouse drag & drop and the touch drag on the Kanban board
|
||
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
|
||
if (!sourceId || sourceId === targetTask.id) return;
|
||
triggerHaptic(15);
|
||
|
||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||
if (!sourceTask) return;
|
||
|
||
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
||
const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id);
|
||
if (targetIdx === -1) return;
|
||
|
||
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
|
||
const updatedSourceTask: Thought = {
|
||
...sourceTask,
|
||
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
|
||
isCompleted: newKanbanCol
|
||
? !!columnsFor(sourceTask).find((c) => c.id === newKanbanCol)?.isDone || newKanbanCol === 'done'
|
||
: targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
|
||
};
|
||
|
||
const newTasks = [...remainingTasks];
|
||
newTasks.splice(insertIdx, 0, updatedSourceTask);
|
||
|
||
const updatedWithSort = newTasks.map((t, idx) => ({
|
||
...t,
|
||
sortOrder: idx,
|
||
}));
|
||
|
||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||
setTasks(updatedWithSort);
|
||
setSortBy('custom');
|
||
|
||
const reorderPayload = updatedWithSort.map((t) => ({
|
||
id: t.id,
|
||
sortOrder: t.sortOrder ?? 0,
|
||
kanbanStatus: t.kanbanStatus,
|
||
}));
|
||
|
||
try {
|
||
await api.reorderTasks(
|
||
reorderPayload,
|
||
selectedListId !== 'all' ? selectedListId : undefined
|
||
);
|
||
} catch {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
const resetKanbanDrag = () => {
|
||
setDraggingTaskId(null);
|
||
setDragOverTaskTarget(null);
|
||
setDragOverKanbanCol(null);
|
||
setDragOverListTab(null);
|
||
setKanbanSlot(null);
|
||
setKanbanHiddenId(null);
|
||
};
|
||
|
||
// The gap where the dragged card would land; it opens and closes smoothly, the cards below slide along
|
||
const renderDropSlot = (colId: string, index: number) => (
|
||
<motion.div
|
||
key={`slot-${colId}-${index}`}
|
||
variants={{
|
||
closed: { height: 0, opacity: 0 },
|
||
open: { height: dragHeightRef.current, opacity: 1 },
|
||
// On a drop the card takes the gap's place at once, so the gap goes without closing first
|
||
gone: (dropped: boolean) => ({ height: 0, opacity: 0, transition: { duration: dropped ? 0 : 0.2 } }),
|
||
}}
|
||
initial="closed"
|
||
animate="open"
|
||
exit="gone"
|
||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
|
||
aria-hidden="true"
|
||
/>
|
||
);
|
||
|
||
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
|
||
if (!draggingTaskId) return;
|
||
// A column the card's list does not have takes nothing (no preventDefault = no drop allowed)
|
||
if (!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId)) return;
|
||
e.preventDefault();
|
||
if (dragOverKanbanCol !== colId) setDragOverKanbanCol(colId);
|
||
// Slot = number of cards (without the dragged one) whose middle lies above the pointer
|
||
const cards = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-kanban-task]')).filter(
|
||
(el) => el.dataset.kanbanTask !== draggingTaskId
|
||
);
|
||
let index = cards.length;
|
||
for (let i = 0; i < cards.length; i++) {
|
||
const r = cards[i].getBoundingClientRect();
|
||
if (e.clientY < r.top + r.height / 2) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
if (!kanbanSlot || kanbanSlot.col !== colId || kanbanSlot.index !== index) setKanbanSlot({ col: colId, index });
|
||
};
|
||
|
||
const handleKanbanColDragLeave = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
|
||
// Moving onto a child of the column is no leaving
|
||
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||
if (dragOverKanbanCol === colId) setDragOverKanbanCol(null);
|
||
setKanbanSlot((cur) => (cur?.col === colId ? null : cur));
|
||
};
|
||
|
||
const handleKanbanDrop = async (e: React.DragEvent, colId: string, colTasks: Thought[]) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const sourceId =
|
||
e.dataTransfer.getData('application/mindsift-task-id') || e.dataTransfer.getData('text/plain') || draggingTaskId;
|
||
const slot = kanbanSlot;
|
||
resetKanbanDrag();
|
||
if (!sourceId) return;
|
||
const ownColId = ownColumnIdFor(tasks.find((t) => t.id === sourceId), colId);
|
||
if (!ownColId) return;
|
||
// Set with the move in one render: the card shows up marked in its new place, not a moment later
|
||
setJustDroppedId(sourceId);
|
||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||
const others = colTasks.filter((t) => t.id !== sourceId);
|
||
if (others.length === 0 || !slot || slot.col !== colId) {
|
||
await moveTaskToColumn(sourceId, ownColId);
|
||
} else if (slot.index < others.length) {
|
||
await moveTaskNextTo(sourceId, others[slot.index], 'before', ownColId);
|
||
} else {
|
||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', ownColId);
|
||
}
|
||
};
|
||
|
||
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
|
||
useEffect(() => {
|
||
if (!draggingTaskId) return;
|
||
const end = () => {
|
||
setKanbanSlot(null);
|
||
setKanbanHiddenId(null);
|
||
};
|
||
window.addEventListener('dragend', end);
|
||
window.addEventListener('drop', end);
|
||
return () => {
|
||
window.removeEventListener('dragend', end);
|
||
window.removeEventListener('drop', end);
|
||
};
|
||
}, [draggingTaskId]);
|
||
|
||
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
|
||
if (!sourceId) return;
|
||
triggerHaptic(15);
|
||
|
||
const sourceTask = tasks.find((t) => t.id === sourceId);
|
||
if (!sourceTask) return;
|
||
|
||
const targetCol = columnsFor(sourceTask).find((c) => c.id === colId);
|
||
const isDone = targetCol ? !!targetCol.isDone : colId === 'done';
|
||
const updatedSourceTask: Thought = {
|
||
...sourceTask,
|
||
kanbanStatus: colId,
|
||
isCompleted: isDone,
|
||
};
|
||
|
||
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
||
const newTasks = [updatedSourceTask, ...remainingTasks];
|
||
|
||
const updatedWithSort = newTasks.map((t, idx) => ({
|
||
...t,
|
||
sortOrder: idx,
|
||
}));
|
||
|
||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||
setTasks(updatedWithSort);
|
||
setSortBy('custom');
|
||
|
||
const reorderPayload = updatedWithSort.map((t) => ({
|
||
id: t.id,
|
||
sortOrder: t.sortOrder ?? 0,
|
||
kanbanStatus: t.kanbanStatus,
|
||
}));
|
||
|
||
try {
|
||
await api.reorderTasks(
|
||
reorderPayload,
|
||
selectedListId !== 'all' ? selectedListId : undefined
|
||
);
|
||
} catch {
|
||
loadData();
|
||
}
|
||
};
|
||
|
||
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
|
||
const clearDragState = () => {
|
||
setDraggingTaskId(null);
|
||
setDragOverTaskTarget(null);
|
||
setDragOverKanbanCol(null);
|
||
};
|
||
const kanbanTouch = useKanbanTouchDrag({
|
||
enabled: !canDragTasks && viewMode === 'kanban',
|
||
onStart: (taskId) => setDraggingTaskId(taskId),
|
||
onHover: (colId, target) => {
|
||
const allowed = !!colId && !!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId);
|
||
setDragOverKanbanCol(allowed ? colId : null);
|
||
setDragOverTaskTarget(allowed ? target : null);
|
||
},
|
||
onDrop: (taskId, colId, target) => {
|
||
clearDragState();
|
||
const ownColId = colId ? ownColumnIdFor(tasks.find((t) => t.id === taskId), colId) : null;
|
||
if (!ownColId) return;
|
||
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
|
||
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, ownColId);
|
||
else void moveTaskToColumn(taskId, ownColId);
|
||
},
|
||
onCancel: clearDragState,
|
||
});
|
||
|
||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const title = newListTitle.trim();
|
||
if (!title) return;
|
||
try {
|
||
const created = await api.createTaskList({
|
||
title,
|
||
icon: newListIcon,
|
||
});
|
||
const freshLists = await api.getTaskLists();
|
||
setTaskLists(freshLists);
|
||
setSelectedListId(created.id);
|
||
setShowNewListModal(false);
|
||
setNewListTitle('');
|
||
setNewListIcon('📋');
|
||
} catch (err: any) {
|
||
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
|
||
}
|
||
};
|
||
|
||
const handleDeleteTaskList = async (listId: string) => {
|
||
if (
|
||
!window.confirm(
|
||
'Möchten Sie diese Aufgabenliste wirklich löschen? Die enthaltenen Aufgaben bleiben erhalten.'
|
||
)
|
||
)
|
||
return;
|
||
try {
|
||
await api.deleteTaskList(listId);
|
||
setTaskLists((prev) => prev.filter((l) => l.id !== listId));
|
||
setSelectedListId('all');
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
const handleOpenShareModal = async (tl: TaskList) => {
|
||
setSharingTaskList(tl);
|
||
setShowShareListModal(true);
|
||
try {
|
||
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
||
api.getUserTeams().catch(() => []),
|
||
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||
api.getTaskListShares(tl.id).catch(() => []),
|
||
]);
|
||
setTeams(freshTeams);
|
||
setCollaborators(freshUsers);
|
||
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
|
||
triggerHaptic(15);
|
||
if (currentSelectedList && selectedListId !== 'all') {
|
||
const updated = await api.updateTaskList(currentSelectedList.id, { columns: newColumns });
|
||
setTaskLists((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
|
||
} else {
|
||
localStorage.setItem('mindsift:kanban_columns', JSON.stringify(newColumns));
|
||
setGlobalColumns(newColumns);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
setPrimaryAction(() => {
|
||
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
|
||
setShowMobileCreateModal(true);
|
||
});
|
||
|
||
const primaryItems: any[] = [
|
||
{
|
||
id: 'new-task',
|
||
label: 'Neue Aufgabe erstellen',
|
||
icon: <Plus className="w-4 h-4" />,
|
||
variant: 'accent',
|
||
onClick: () => {
|
||
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
|
||
setShowMobileCreateModal(true);
|
||
},
|
||
},
|
||
];
|
||
|
||
const filterSortActiveCount =
|
||
(selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0);
|
||
|
||
const filterItems: any[] = [
|
||
{
|
||
id: 'tasks-filter-modal',
|
||
label: 'Filter, Hashtags & Sortierung',
|
||
icon: <Filter className="w-4 h-4 text-accent" />,
|
||
badge: filterSortActiveCount > 0 ? `${filterSortActiveCount} aktiv` : undefined,
|
||
active: filterSortActiveCount > 0,
|
||
onClick: () => setShowFilterModal(true),
|
||
},
|
||
];
|
||
|
||
const listManagementItems: any[] = [
|
||
{
|
||
id: 'new-task-list',
|
||
label: 'Neue Aufgabenliste anlegen',
|
||
icon: <Folder className="w-4 h-4" />,
|
||
onClick: () => setShowNewListModal(true),
|
||
},
|
||
];
|
||
|
||
if (currentSelectedList) {
|
||
listManagementItems.push({
|
||
id: 'share-task-list',
|
||
label: `Liste „${currentSelectedList.title}“ teilen`,
|
||
icon: <Users className="w-4 h-4" />,
|
||
onClick: () => handleOpenShareModal(currentSelectedList),
|
||
});
|
||
listManagementItems.push({
|
||
id: 'delete-task-list',
|
||
label: `Liste „${currentSelectedList.title}“ löschen`,
|
||
icon: <Trash2 className="w-4 h-4" />,
|
||
variant: 'danger',
|
||
onClick: () => handleDeleteTaskList(currentSelectedList.id),
|
||
});
|
||
}
|
||
|
||
const viewItems: any[] = [
|
||
{
|
||
id: 'filter-open',
|
||
label: 'Offene Aufgaben (Liste)',
|
||
icon: <LayoutList className="w-4 h-4" />,
|
||
badge: openTasks.length,
|
||
active: viewMode === 'list' && filter === 'open',
|
||
onClick: () => {
|
||
setViewMode('list');
|
||
setFilter('open');
|
||
},
|
||
},
|
||
{
|
||
id: 'filter-done',
|
||
label: 'Erledigte Aufgaben',
|
||
icon: <Check className="w-4 h-4" />,
|
||
badge: doneTasks.length,
|
||
active: viewMode === 'list' && filter === 'done',
|
||
onClick: () => {
|
||
setViewMode('list');
|
||
setFilter('done');
|
||
},
|
||
},
|
||
{
|
||
id: 'filter-discarded',
|
||
label: 'Papierkorb',
|
||
icon: <Trash2 className="w-4 h-4" />,
|
||
badge: taskTrash.length,
|
||
active: viewMode === 'list' && filter === 'discarded',
|
||
onClick: () => {
|
||
setViewMode('list');
|
||
setFilter('discarded');
|
||
},
|
||
},
|
||
{
|
||
id: 'view-kanban',
|
||
label: 'Kanban-Board Ansicht',
|
||
icon: <Columns className="w-4 h-4" />,
|
||
badge: kanbanTasks.length,
|
||
active: viewMode === 'kanban',
|
||
onClick: () => setViewMode('kanban'),
|
||
},
|
||
];
|
||
|
||
if (viewMode === 'kanban' && !isSelectedListReadOnly) {
|
||
viewItems.push({
|
||
id: 'manage-columns',
|
||
label: 'Kanban-Spalten verwalten',
|
||
icon: <Settings className="w-4 h-4" />,
|
||
onClick: () => setShowManageColumnsModal(true),
|
||
});
|
||
}
|
||
|
||
const listItems: any[] = [
|
||
{
|
||
id: 'list-all',
|
||
label: 'Alle Aufgaben',
|
||
icon: <Layers className="w-4 h-4" />,
|
||
badge: openTasksCount || undefined,
|
||
active: selectedListId === 'all',
|
||
onClick: () => setSelectedListId('all'),
|
||
},
|
||
...safeTaskLists.map((tl) => ({
|
||
id: `list-${tl.id}`,
|
||
label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title,
|
||
badge: tl.tasksCount || undefined,
|
||
active: selectedListId === tl.id,
|
||
onClick: () => setSelectedListId(tl.id),
|
||
})),
|
||
];
|
||
|
||
setGroups([
|
||
{
|
||
id: 'tasks-primary',
|
||
items: primaryItems,
|
||
},
|
||
{
|
||
id: 'tasks-filters',
|
||
title: 'Filter & Sortierung',
|
||
items: filterItems,
|
||
},
|
||
{
|
||
id: 'tasks-list-management',
|
||
title: 'Listen-Verwaltung',
|
||
items: listManagementItems,
|
||
},
|
||
{
|
||
id: 'tasks-views',
|
||
title: 'Ansicht & Status',
|
||
items: viewItems,
|
||
},
|
||
{
|
||
id: 'tasks-lists',
|
||
title: 'Aufgabenlisten wechseln',
|
||
items: listItems,
|
||
},
|
||
]);
|
||
}, [
|
||
viewMode,
|
||
filter,
|
||
selectedListId,
|
||
selectedTag,
|
||
groupBy,
|
||
sortBy,
|
||
openTasks.length,
|
||
doneTasks.length,
|
||
taskTrash.length,
|
||
kanbanTasks.length,
|
||
safeTaskLists,
|
||
openTasksCount,
|
||
currentSelectedList,
|
||
isSelectedListReadOnly,
|
||
setPrimaryAction,
|
||
setGroups,
|
||
]);
|
||
|
||
const renderTaskProgressBar = (task: Thought) => {
|
||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||
const total = task.subtasks.length;
|
||
const done = task.subtasks.filter((s) => s.isCompleted).length;
|
||
const percent = Math.round((done / total) * 100);
|
||
|
||
return (
|
||
<div
|
||
className="space-y-0.5 mt-0.5 mb-0.5 select-none"
|
||
title={`${done} von ${total} Unteraufgaben erledigt (${percent}%)`}
|
||
>
|
||
<div className="flex items-center justify-between text-[9px] text-muted font-medium">
|
||
<span className="flex items-center gap-1">
|
||
<CheckSquare className="w-2.5 h-2.5 text-accent" />
|
||
<span>
|
||
{done}/{total} Unteraufgaben
|
||
</span>
|
||
</span>
|
||
<span className={percent === 100 ? 'text-status-ok font-bold' : 'text-muted'}>
|
||
{percent}%
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full transition-all duration-300 ${
|
||
percent === 100 ? 'bg-status-ok' : 'bg-accent'
|
||
}`}
|
||
style={{ width: `${percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Clean Task Row in List View
|
||
const renderTaskRow = (task: Thought) => {
|
||
const isCompleted = task.isCompleted;
|
||
const readOnly = isTaskReadOnly(task);
|
||
const isDragging = draggingTaskId === task.id;
|
||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||
|
||
return (
|
||
<div
|
||
key={task.id}
|
||
draggable={canDragTasks && !readOnly}
|
||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||
onDragEnd={() => {
|
||
setDraggingTaskId(null);
|
||
setDragOverTaskTarget(null);
|
||
setDragOverKanbanCol(null);
|
||
setDragOverListTab(null);
|
||
}}
|
||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||
onDrop={(e) => handleDropOnTask(e, task)}
|
||
onClick={() => {
|
||
setDetailTaskId(task.id);
|
||
setShowDetailModal(true);
|
||
}}
|
||
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${
|
||
readOnly ? '' : 'cursor-grab active:cursor-grabbing'
|
||
} ${
|
||
isCompleted ? 'opacity-65' : ''
|
||
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
|
||
>
|
||
{/* Drop insertion line indicator */}
|
||
{isTarget && dragOverTaskTarget.position === 'before' && (
|
||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||
)}
|
||
{isTarget && dragOverTaskTarget.position === 'after' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 sm:gap-2.5 min-w-0 flex-1">
|
||
{/* Checkbox (larger touch target on mobile) */}
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
disabled={readOnly}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleToggleDone(task);
|
||
}}
|
||
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||
isCompleted
|
||
? 'bg-status-ok border-status-ok text-on-accent'
|
||
: 'border-strong hover:border-accent text-transparent'
|
||
}`}
|
||
title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
|
||
>
|
||
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
|
||
</button>
|
||
|
||
{/* Clickable Title & Metadata */}
|
||
<div className="min-w-0 flex-1 cursor-pointer space-y-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<p
|
||
className={`text-sm break-words leading-tight ${
|
||
isCompleted ? 'line-through text-faint' : 'text-main font-medium'
|
||
}`}
|
||
>
|
||
{task.content}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Subtasks Progress Bar */}
|
||
{renderTaskProgressBar(task)}
|
||
|
||
{/* Interactive Badges Bar */}
|
||
<div
|
||
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
||
onClick={stopOnControl}
|
||
>
|
||
{(task.convertedType === 'document_block' || task.docId) && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||
if (docId) onOpenDocument(docId, blockId);
|
||
}}
|
||
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
|
||
title="In Notiz an der passenden Stelle öffnen"
|
||
>
|
||
<FileText className="w-2.5 h-2.5" />
|
||
<span>In Notiz</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* Status: dot in the column colour; a selector on desktop, plain text on phones (changed in TaskDetailModal) */}
|
||
{(() => {
|
||
const statusId = task.kanbanStatus || (isCompleted ? 'done' : 'todo');
|
||
const taskColumns = columnsFor(task);
|
||
const statusCol = taskColumns.find((c) => c.id === statusId);
|
||
const dot = <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${columnDotClass(statusCol)}`} aria-hidden="true" />;
|
||
return (
|
||
<>
|
||
<span className="hidden sm:inline-flex items-center gap-1">
|
||
{dot}
|
||
<select
|
||
value={statusId}
|
||
disabled={readOnly}
|
||
onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
|
||
className="h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
|
||
title="Status / Zustand ändern"
|
||
>
|
||
{taskColumns.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</span>
|
||
{statusCol && (
|
||
<span className="sm:hidden inline-flex items-center gap-1 text-[10px] text-muted" title="Status">
|
||
{dot}
|
||
{statusCol.name}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
{/* Priority badge: click to cycle or clear */}
|
||
{task.priority === 'urgent' && (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => handleCyclePriority(task)}
|
||
className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
||
>
|
||
🔥 Dringend
|
||
</button>
|
||
)}
|
||
{task.priority === 'high' && (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => handleCyclePriority(task)}
|
||
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
||
>
|
||
⚠️ Wichtig
|
||
</button>
|
||
)}
|
||
|
||
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||
|
||
{/* Note indicator */}
|
||
{task.notes && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDetailTaskId(task.id);
|
||
setShowDetailModal(true);
|
||
}}
|
||
className="inline-flex items-center gap-1 text-[10px] text-muted hover:text-main bg-canvas px-1.5 py-0.5 rounded border border-border"
|
||
title="Notiz anzeigen / bearbeiten"
|
||
>
|
||
<FileText className="w-2.5 h-2.5 text-accent" />
|
||
<span className="truncate max-w-[120px]">{task.notes}</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* Due Date */}
|
||
{task.dueDate && (
|
||
<span
|
||
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||
>
|
||
<Calendar className="w-2.5 h-2.5 text-accent" />
|
||
<span>{formatDue(task.dueDate)}</span>
|
||
</span>
|
||
)}
|
||
{task.recurrence && (
|
||
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||
<Repeat className="w-3 h-3" />
|
||
</span>
|
||
)}
|
||
|
||
{/* Hashtag badges */}
|
||
{task.tags?.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||
className={`text-[10px] px-1.5 py-0.2 rounded-pill border transition-colors ${
|
||
selectedTag === tag
|
||
? 'bg-accent/15 border-accent text-accent font-semibold'
|
||
: 'badge-neutral hover:border-accent'
|
||
}`}
|
||
title={`Nach #${tag} filtern`}
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{renderAssignees(task, 'xs')}
|
||
|
||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
onClick={() => {
|
||
setDetailTaskId(task.id);
|
||
setShowDetailModal(true);
|
||
}}
|
||
className="text-faint hover:text-accent p-1.5 transition-colors"
|
||
title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
|
||
>
|
||
{readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
|
||
</button>
|
||
{!readOnly && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
onClick={() => handleConvertToDoc(task)}
|
||
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
|
||
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-1.5 transition-colors hidden sm:block`}
|
||
>
|
||
<FileText className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
onClick={() => handleDeletePermanent(task.id)}
|
||
title="Aufgabe löschen"
|
||
className="text-faint hover:text-status-err p-1.5 transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="w-full space-y-3">
|
||
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
|
||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||
{/* Task Lists selector tabs */}
|
||
<div className="flex flex-wrap items-center gap-1">
|
||
<button
|
||
onClick={() => setSelectedListId('all')}
|
||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
|
||
selectedListId === 'all'
|
||
? 'bg-panel border-accent text-main'
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
{/* Counter on the label, like in the nav bar */}
|
||
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
|
||
Alle Aufgaben
|
||
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
|
||
</span>
|
||
</button>
|
||
|
||
{safeTaskLists.map((tl) => (
|
||
<div
|
||
key={tl.id}
|
||
onDragOver={(e) => {
|
||
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
|
||
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
|
||
}}
|
||
onDragLeave={() => {
|
||
if (dragOverListTab === tl.id) setDragOverListTab(null);
|
||
}}
|
||
onDrop={(e) => handleDropOnListTab(e, tl)}
|
||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||
} ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
|
||
>
|
||
<button
|
||
onClick={() => setSelectedListId(tl.id)}
|
||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||
>
|
||
<span>{tl.icon}</span>
|
||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
|
||
{tl.title}
|
||
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
|
||
</span>
|
||
|
||
{(() => {
|
||
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||
if (!isShared) return null;
|
||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||
if (otherMembers.length > 0) {
|
||
return (
|
||
<AvatarStack
|
||
people={otherMembers.map(memberToPerson)}
|
||
size="xxs"
|
||
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||
className="ml-0.5"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<span title="Geteilt mit Team oder Benutzern">
|
||
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
|
||
</span>
|
||
);
|
||
})()}
|
||
</button>
|
||
{selectedListId === tl.id && (
|
||
<button
|
||
onClick={() => handleDeleteTaskList(tl.id)}
|
||
title="Liste löschen"
|
||
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<button
|
||
onClick={() => setShowNewListModal(true)}
|
||
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||
title="Neue Aufgabenliste anlegen"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
<span>Neue Liste</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
|
||
<div className="flex items-center gap-2">
|
||
{currentSelectedList && (() => {
|
||
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
||
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||
? currentSelectedList.shares.length
|
||
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
||
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
|
||
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
||
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||
|
||
return (
|
||
<div className="flex items-center gap-1.5">
|
||
{isShared && (
|
||
isOwner ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||
>
|
||
<Users className="w-2.5 h-2.5" />
|
||
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||
title="Teilen & Berechtigungen öffnen"
|
||
>
|
||
<Users className="w-2.5 h-2.5" />
|
||
<span>Geteilt von {ownerDisplayName}</span>
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="btn-icon"
|
||
title="Aufgabenliste teilen und Berechtigungen"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* View Switcher: List vs Kanban */}
|
||
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
||
<button
|
||
onClick={() => setViewMode('list')}
|
||
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors border-r border-border ${
|
||
viewMode === 'list' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
|
||
}`}
|
||
title="Listenansicht"
|
||
>
|
||
<LayoutList className="w-3.5 h-3.5" />
|
||
<span className="hidden sm:inline">Liste</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('kanban')}
|
||
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors ${
|
||
viewMode === 'kanban'
|
||
? 'bg-panel text-main font-medium'
|
||
: 'text-muted hover:text-main'
|
||
}`}
|
||
title="Kanban-Board"
|
||
>
|
||
<Columns className="w-3.5 h-3.5" />
|
||
<span className="hidden sm:inline">Kanban</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
|
||
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
||
<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].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
||
</span>
|
||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||
{viewMode === 'kanban'
|
||
? 'Kanban'
|
||
: filter === 'open'
|
||
? `Offen (${openTasks.length})`
|
||
: filter === 'done'
|
||
? `Erledigt (${doneTasks.length})`
|
||
: `Papierkorb (${taskTrash.length})`}
|
||
</span>
|
||
</div>
|
||
{currentSelectedList && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
|
||
title="Aufgabenliste teilen"
|
||
>
|
||
<Users className="w-4 h-4 text-accent" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isSelectedListReadOnly && (
|
||
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
|
||
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
|
||
<span>
|
||
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
|
||
{!isSelectedListReadOnly && (
|
||
<form
|
||
onSubmit={handleQuickAddTask}
|
||
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
|
||
>
|
||
<Plus className="w-4 h-4 text-accent ml-1 flex-shrink-0" />
|
||
<input
|
||
ref={quickInputRef}
|
||
type="text"
|
||
value={quickTitle}
|
||
onChange={(e) => setQuickTitle(e.target.value)}
|
||
placeholder={
|
||
quickVoiceError ||
|
||
(currentSelectedList ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…` : 'Neue Aufgabe anlegen…')
|
||
}
|
||
className="flex-1 bg-transparent px-2 py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none min-w-0"
|
||
/>
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
<VoiceInputButton
|
||
value={quickTitle}
|
||
onChange={setQuickTitle}
|
||
focusRef={quickInputRef}
|
||
onError={(message) => {
|
||
setQuickVoiceError(message);
|
||
window.setTimeout(() => setQuickVoiceError(null), 4000);
|
||
}}
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={quickDueDate}
|
||
onChange={(e) => setQuickDueDate(e.target.value)}
|
||
className="hidden sm:block h-7 px-2 input-field text-xs"
|
||
title="Fälligkeitsdatum"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={!quickTitle.trim()}
|
||
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40"
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
|
||
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
|
||
<div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs">
|
||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||
{selectedTag && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-on-accent text-[11px] font-semibold">
|
||
#{selectedTag}
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTag(null)}
|
||
className="hover:opacity-75 cursor-pointer ml-0.5"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
{groupBy !== 'none' && (
|
||
<span className="text-muted text-[11px]">
|
||
Gruppe: <strong className="text-main">{groupBy}</strong>
|
||
</span>
|
||
)}
|
||
{sortBy !== 'custom' && (
|
||
<span className="text-muted text-[11px]">
|
||
Sortiert: <strong className="text-main">{sortBy}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedTag(null);
|
||
setGroupBy('none');
|
||
setSortBy('custom');
|
||
}}
|
||
className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer"
|
||
>
|
||
Zurücksetzen
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Hashtag Filter & Grouping / Sorting Toolbar (Desktop only - on mobile accessible via Logo Menu Filter & Sort) */}
|
||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 pt-1 pb-0.5">
|
||
{/* Hashtag filter chips: one line, cut off with "…" when too many; "…" opens them all in lines */}
|
||
<div
|
||
ref={tagRowRef}
|
||
className={`flex-1 min-w-0 flex items-center gap-1.5 pb-1 ${tagsExpanded ? 'flex-wrap' : 'overflow-hidden'}`}
|
||
>
|
||
<span className="text-[11px] font-semibold text-muted flex items-center gap-1 flex-shrink-0">
|
||
<Hash className="w-3 h-3 text-accent" />
|
||
<span>Hashtags:</span>
|
||
</span>
|
||
<button
|
||
onClick={() => setSelectedTag(null)}
|
||
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex-shrink-0 ${
|
||
selectedTag === null
|
||
? 'bg-panel border-accent text-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
Alle ({safeTasks.length})
|
||
</button>
|
||
{allAvailableTags.map((tag) => {
|
||
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
|
||
const isSelected = selectedTag === tag;
|
||
return (
|
||
<button
|
||
key={tag}
|
||
onClick={() => setSelectedTag(isSelected ? null : tag)}
|
||
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex items-center gap-1 flex-shrink-0 ${
|
||
isSelected
|
||
? 'bg-accent/15 border-accent text-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
<span>#{tag}</span>
|
||
<span className="text-[9px] opacity-70 font-mono">({count})</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{tagsExpanded && (
|
||
<button
|
||
onClick={() => setTagsExpanded(false)}
|
||
className="h-6 px-2 text-[11px] rounded-pill text-accent hover:underline flex-shrink-0"
|
||
>
|
||
Weniger
|
||
</button>
|
||
)}
|
||
</div>
|
||
{!tagsExpanded && tagsOverflow && (
|
||
<button
|
||
onClick={() => setTagsExpanded(true)}
|
||
className="h-6 px-2 -ml-1 mb-1 text-xs rounded-pill border border-border bg-canvas text-muted hover:text-main flex-shrink-0"
|
||
title="Alle Hashtags zeigen"
|
||
aria-label="Alle Hashtags zeigen"
|
||
>
|
||
…
|
||
</button>
|
||
)}
|
||
|
||
{/* Kanban: manage columns and width (the list view has grouping and sorting above the list) */}
|
||
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
||
{viewMode === 'kanban' && (
|
||
<>
|
||
{!isSelectedListReadOnly && (
|
||
<button
|
||
onClick={() => setShowManageColumnsModal(true)}
|
||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
|
||
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
|
||
>
|
||
<Columns className="w-3.5 h-3.5 text-accent" />
|
||
<span>Spalten anpassen</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={toggleKanbanWide}
|
||
aria-pressed={kanbanWide}
|
||
className="btn-action h-7 w-7 p-0 hidden lg:inline-flex items-center justify-center"
|
||
title={kanbanWide ? 'Board wieder normal breit' : 'Board auf volle Breite strecken'}
|
||
>
|
||
{kanbanWide ? (
|
||
<Minimize2 className="w-3.5 h-3.5 text-accent" />
|
||
) : (
|
||
<Maximize2 className="w-3.5 h-3.5 text-accent" />
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* MOBILE CREATE TASK BOTTOM SHEET MODAL */}
|
||
{showMobileCreateModal && (
|
||
<div
|
||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||
onClick={() => setShowMobileCreateModal(false)}
|
||
>
|
||
<div
|
||
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||
<CheckSquare className="w-4 h-4" />
|
||
</div>
|
||
<h3 className="text-sm font-semibold text-main">Neue Aufgabe anlegen</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowMobileCreateModal(false)}
|
||
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleMobileCreateTask} className="space-y-3">
|
||
<div>
|
||
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabentext</label>
|
||
<div className="relative">
|
||
<input
|
||
ref={mobileCreateInputRef}
|
||
type="text"
|
||
autoFocus
|
||
value={mobileCreateTitle}
|
||
onChange={(e) => setMobileCreateTitle(e.target.value)}
|
||
placeholder={mobileVoiceError || 'Was ist zu tun?…'}
|
||
className="w-full input-field pl-3 pr-11 py-2 text-sm"
|
||
/>
|
||
<VoiceInputButton
|
||
value={mobileCreateTitle}
|
||
onChange={setMobileCreateTitle}
|
||
focusRef={mobileCreateInputRef}
|
||
onError={(message) => {
|
||
setMobileVoiceError(message);
|
||
window.setTimeout(() => setMobileVoiceError(null), 4000);
|
||
}}
|
||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* List Selector (if lists exist) */}
|
||
{safeTaskLists.length > 0 && (
|
||
<div>
|
||
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabenliste</label>
|
||
<select
|
||
value={mobileCreateListId}
|
||
onChange={(e) => setMobileCreateListId(e.target.value)}
|
||
className="w-full input-field px-2.5 py-2 text-xs bg-panel"
|
||
>
|
||
<option value="">Standard (Alle Aufgaben)</option>
|
||
{safeTaskLists.map((tl) => (
|
||
<option key={tl.id} value={tl.id}>
|
||
{tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Priority - 4 Buttons */}
|
||
<div className="space-y-1.5 pt-1">
|
||
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
|
||
<div className="grid grid-cols-4 gap-1.5">
|
||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
|
||
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
|
||
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
|
||
high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
|
||
urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
|
||
};
|
||
const active = mobileCreatePriority === p;
|
||
const item = config[p];
|
||
return (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onClick={() => {
|
||
triggerHaptic(10);
|
||
setMobileCreatePriority(p);
|
||
}}
|
||
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||
active
|
||
? item.activeClass
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
{item.icon}
|
||
<span className="truncate">{item.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Due Date */}
|
||
<div className="space-y-1 pt-1">
|
||
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
|
||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||
<span>Fällig am</span>
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={mobileCreateDueDate}
|
||
onChange={(e) => setMobileCreateDueDate(e.target.value)}
|
||
className="w-full input-field px-3 py-2 text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="pt-2 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowMobileCreateModal(false)}
|
||
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={!mobileCreateTitle.trim()}
|
||
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
|
||
>
|
||
Aufgabe erstellen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MOBILE FILTER & SORT MODAL */}
|
||
{showFilterModal && (
|
||
<div
|
||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||
onClick={() => setShowFilterModal(false)}
|
||
>
|
||
<div
|
||
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200 space-y-4"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||
<Filter className="w-4 h-4" />
|
||
</div>
|
||
<h3 className="text-sm font-semibold text-main">Filter & Sortierung</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowFilterModal(false)}
|
||
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Hashtag Filters */}
|
||
<div className="space-y-2">
|
||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted flex items-center gap-1">
|
||
<Hash className="w-3 h-3 text-accent" />
|
||
<span>Nach Hashtags filtern</span>
|
||
</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTag(null)}
|
||
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors cursor-pointer ${
|
||
selectedTag === null
|
||
? 'bg-accent text-on-accent border-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted'
|
||
}`}
|
||
>
|
||
Alle ({safeTasks.length})
|
||
</button>
|
||
{allAvailableTags.map((tag) => {
|
||
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
|
||
const isSelected = selectedTag === tag;
|
||
return (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => setSelectedTag(isSelected ? null : tag)}
|
||
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors flex items-center gap-1 cursor-pointer ${
|
||
isSelected
|
||
? 'bg-accent text-on-accent border-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted'
|
||
}`}
|
||
>
|
||
<span>#{tag}</span>
|
||
<span className="text-[10px] opacity-75">({count})</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grouping */}
|
||
{viewMode !== 'kanban' && (
|
||
<div className="space-y-2 pt-2 border-t border-border">
|
||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
|
||
Gruppierung
|
||
</span>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{[
|
||
{ id: 'none', label: 'Keine' },
|
||
{ id: 'tag', label: 'Nach Hashtag' },
|
||
{ id: 'status', label: 'Nach Status' },
|
||
{ id: 'priority', label: 'Nach Priorität' },
|
||
{ id: 'due', label: 'Nach Fälligkeit' },
|
||
].map((g) => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
onClick={() => setGroupBy(g.id as any)}
|
||
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
|
||
groupBy === g.id
|
||
? 'bg-accent-dim border-accent text-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
{g.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sorting */}
|
||
{viewMode !== 'kanban' && (
|
||
<div className="space-y-2 pt-2 border-t border-border">
|
||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
|
||
Sortierung
|
||
</span>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{[
|
||
{ id: 'custom', label: 'Manuell (Drag & Drop)' },
|
||
{ id: 'created', label: 'Erstelldatum' },
|
||
{ id: 'due', label: 'Fälligkeitsdatum' },
|
||
{ id: 'priority', label: 'Priorität' },
|
||
{ id: 'alpha', label: 'Alphabetisch' },
|
||
].map((s) => (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
onClick={() => setSortBy(s.id as any)}
|
||
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
|
||
sortBy === s.id
|
||
? 'bg-accent-dim border-accent text-accent font-semibold'
|
||
: 'bg-canvas border-border text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="pt-3 border-t border-border flex items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedTag(null);
|
||
setGroupBy('none');
|
||
setSortBy('custom');
|
||
}}
|
||
className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer"
|
||
>
|
||
Filter zurücksetzen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowFilterModal(false)}
|
||
className="btn-primary h-9 px-5 text-xs font-semibold cursor-pointer"
|
||
>
|
||
Fertig
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* NEW LIST MODAL */}
|
||
{showNewListModal && (
|
||
<FormSheet
|
||
title="Neue Aufgabenliste erstellen"
|
||
icon={<Layers className="w-4 h-4 text-accent" />}
|
||
onClose={() => setShowNewListModal(false)}
|
||
onSubmit={handleCreateTaskList}
|
||
>
|
||
<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} />
|
||
<input
|
||
type="text"
|
||
value={newListTitle}
|
||
onChange={(e) => setNewListTitle(e.target.value)}
|
||
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
||
autoFocus
|
||
required
|
||
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</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
|
||
isOpen={showShareListModal}
|
||
onClose={() => {
|
||
setShowShareListModal(false);
|
||
setSharingTaskList(null);
|
||
}}
|
||
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
||
resourceName={sharingTaskList.title}
|
||
resourceIcon={sharingTaskList.icon || undefined}
|
||
isOwner={sharingTaskList.isOwner !== false}
|
||
currentUserId={currentUser?.id}
|
||
shares={(sharingTaskList.shares || []).map((sh) => ({
|
||
id: sh.id,
|
||
targetType: sh.targetType,
|
||
targetId: sh.targetId,
|
||
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
||
targetEmail: sh.email,
|
||
avatarUrl: sh.avatarUrl,
|
||
permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
|
||
role: sh.permission === 'viewer' ? 'viewer' : 'editor',
|
||
createdAt: sh.createdAt,
|
||
}))}
|
||
teams={safeTeams}
|
||
collaborators={safeCollaborators}
|
||
onAddShare={async (targetType, targetId, permission) => {
|
||
await api.addTaskListShare(sharingTaskList.id, {
|
||
targetType,
|
||
targetId,
|
||
permission,
|
||
});
|
||
const [freshLists, freshShares] = await Promise.all([
|
||
api.getTaskLists(),
|
||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||
]);
|
||
setTaskLists(freshLists);
|
||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||
if (updated) {
|
||
setSharingTaskList({ ...updated, shares: freshShares });
|
||
}
|
||
await loadData();
|
||
}}
|
||
onRemoveShare={async (shareId) => {
|
||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
||
const [freshLists, freshShares] = await Promise.all([
|
||
api.getTaskLists(),
|
||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||
]);
|
||
setTaskLists(freshLists);
|
||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||
if (updated) {
|
||
setSharingTaskList({ ...updated, shares: freshShares });
|
||
}
|
||
await loadData();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* MANAGE COLUMNS MODAL */}
|
||
<ManageColumnsModal
|
||
isOpen={showManageColumnsModal}
|
||
onClose={() => setShowManageColumnsModal(false)}
|
||
initialColumns={activeColumns}
|
||
onSave={handleSaveColumns}
|
||
listTitle={currentSelectedList?.title || 'Alle Aufgaben'}
|
||
/>
|
||
|
||
{/* TASK DETAIL MODAL */}
|
||
<TaskDetailModal
|
||
task={detailTask}
|
||
isOpen={showDetailModal}
|
||
onClose={() => {
|
||
setShowDetailModal(false);
|
||
setDetailTaskId(null);
|
||
}}
|
||
activeColumns={detailTask ? columnsFor(detailTask) : activeColumns}
|
||
taskLists={safeTaskLists}
|
||
onOpenDocument={onOpenDocument}
|
||
onUpdateTitle={handleUpdateTaskTitle}
|
||
onUpdateStatus={handleSetKanbanStatus}
|
||
onUpdatePriority={handleSetPriority}
|
||
onUpdateDueDate={handleUpdateTaskDueDate}
|
||
onUpdateRecurrence={handleUpdateTaskRecurrence}
|
||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||
onUpdateNotes={handleUpdateTaskNotes}
|
||
onUpdateTags={handleUpdateTaskTags}
|
||
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||
onUpdateAssignees={handleUpdateTaskAssignees}
|
||
onToggleSubtask={handleToggleSubtask}
|
||
onAddSubtask={handleAddSubtask}
|
||
onDeleteSubtask={handleDeleteSubtask}
|
||
onConvertToDoc={handleConvertToDoc}
|
||
onDeleteTask={handleDeletePermanent}
|
||
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
|
||
/>
|
||
|
||
{/* VIEW MODES: LIST OR KANBAN */}
|
||
{viewMode === 'kanban' ? (
|
||
/* DYNAMIC KANBAN BOARD */
|
||
<div className="space-y-2">
|
||
{kanbanTouch.ghost}
|
||
<div
|
||
ref={kanbanBoardRef}
|
||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
|
||
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
|
||
}`}
|
||
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
||
>
|
||
{boardColumns.map((col) => {
|
||
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
|
||
// While dragging: a column the card's list does not have is greyed out and takes nothing
|
||
const blockedForDrag =
|
||
!!draggingTaskId && !ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), col.id);
|
||
|
||
// Cards that take part in the slot count (the dragged one has left its place)
|
||
const visibleIds = colTasks.filter((t) => t.id !== kanbanHiddenId).map((t) => t.id);
|
||
const slotHere = kanbanSlot && kanbanSlot.col === col.id ? kanbanSlot.index : -1;
|
||
|
||
return (
|
||
<div
|
||
key={col.id}
|
||
onDragOver={(e) => handleKanbanColDragOver(e, col.id)}
|
||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||
data-kanban-col={col.id}
|
||
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
|
||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
|
||
kanbanColHeight ? '' : 'min-h-[420px]'
|
||
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
|
||
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
||
>
|
||
{/* Column Header */}
|
||
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
|
||
<span className={`w-2 h-2 rounded-full ${columnDotClass(col)}`} />
|
||
<span>{col.name}</span>
|
||
</span>
|
||
<span className="badge-neutral font-mono">{colTasks.length}</span>
|
||
</div>
|
||
|
||
{/* Tasks List in Column */}
|
||
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
|
||
{colTasks.map((task) => {
|
||
const isDragging = draggingTaskId === task.id;
|
||
const readOnly = isTaskReadOnly(task);
|
||
const visibleIdx = visibleIds.indexOf(task.id);
|
||
const ownCols = columnsFor(task);
|
||
const ownIdx = ownCols.findIndex((c) => c.id === ownColumnOf(task)?.id);
|
||
const prevCol = ownIdx > 0 ? ownCols[ownIdx - 1] : undefined;
|
||
const nextCol = ownIdx >= 0 ? ownCols[ownIdx + 1] : undefined;
|
||
|
||
return (
|
||
<React.Fragment key={task.id}>
|
||
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
||
</AnimatePresence>
|
||
<div
|
||
draggable={canDragTasks && !readOnly}
|
||
onDragStart={(e) => {
|
||
handleTaskDragStart(e, task);
|
||
setJustDroppedId(null);
|
||
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
||
// Hidden a tick later: hiding the source right away would cancel the drag
|
||
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
||
}}
|
||
onDragEnd={resetKanbanDrag}
|
||
data-kanban-task={task.id}
|
||
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
|
||
onClick={() => {
|
||
if (kanbanTouch.consumeClick()) return;
|
||
setDetailTaskId(task.id);
|
||
setShowDetailModal(true);
|
||
}}
|
||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-colors shadow-sm group relative select-none ${
|
||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||
} ${
|
||
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60' : ''}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<p
|
||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||
task.isCompleted ? 'line-through text-faint' : 'text-main'
|
||
}`}
|
||
>
|
||
{task.content}
|
||
</p>
|
||
{!readOnly && (
|
||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => handleConvertToDoc(task)}
|
||
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
|
||
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-0.5`}
|
||
>
|
||
<FileText className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeletePermanent(task.id)}
|
||
title="Aufgabe löschen"
|
||
className="text-faint hover:text-status-err p-0.5"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Subtasks progress */}
|
||
{renderTaskProgressBar(task)}
|
||
|
||
{/* Notes preview badge */}
|
||
{task.notes && (
|
||
<div className="flex items-center gap-1 text-[10px] text-muted bg-canvas p-1 rounded border border-border truncate">
|
||
<FileText className="w-2.5 h-2.5 text-accent flex-shrink-0" />
|
||
<span className="truncate">{task.notes}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Badges: In Notiz, Priority, Status, Tags */}
|
||
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={stopOnControl}>
|
||
{(task.convertedType === 'document_block' || task.docId) && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||
if (docId) onOpenDocument(docId, blockId);
|
||
}}
|
||
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
|
||
title="In Notiz an der passenden Stelle öffnen"
|
||
>
|
||
<FileText className="w-2.5 h-2.5" />
|
||
<span>In Notiz</span>
|
||
</button>
|
||
)}
|
||
{task.priority === 'urgent' && (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => handleCyclePriority(task)}
|
||
className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
||
>
|
||
🔥 Dringend
|
||
</button>
|
||
)}
|
||
{task.priority === 'high' && (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => handleCyclePriority(task)}
|
||
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
||
>
|
||
⚠️ Wichtig
|
||
</button>
|
||
)}
|
||
<OriginBadge origin={task.origin} createdAt={task.createdAt} size="xs" />
|
||
{task.dueDate && (
|
||
<span
|
||
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||
>
|
||
<Calendar className="w-2.5 h-2.5" />
|
||
<span>{formatDue(task.dueDate)}</span>
|
||
</span>
|
||
)}
|
||
{task.recurrence && (
|
||
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||
<Repeat className="w-2.5 h-2.5" />
|
||
</span>
|
||
)}
|
||
{task.tags?.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||
className={`text-[9px] px-1.5 py-0.2 rounded-pill border transition-colors ${
|
||
selectedTag === tag
|
||
? 'bg-accent/15 border-accent text-accent font-semibold'
|
||
: 'badge-neutral hover:border-accent'
|
||
}`}
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
{getAssigneePeople(task).length > 0 && (
|
||
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Column Navigation controls: Move Left / Move Right */}
|
||
{!readOnly && (
|
||
<div
|
||
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
|
||
onClick={stopOnControl}
|
||
>
|
||
<button
|
||
type="button"
|
||
disabled={!prevCol}
|
||
onClick={() => prevCol && handleSetKanbanStatus(task, prevCol.id)}
|
||
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
||
title={prevCol ? `Nach "${prevCol.name}"` : ''}
|
||
>
|
||
<ArrowLeft className="w-3 h-3" />
|
||
<span className="hidden sm:inline">{prevCol?.name ?? ''}</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!nextCol}
|
||
onClick={() => nextCol && handleSetKanbanStatus(task, nextCol.id)}
|
||
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
||
title={nextCol ? `Nach "${nextCol.name}"` : ''}
|
||
>
|
||
<span className="hidden sm:inline">{nextCol?.name ?? ''}</span>
|
||
<ArrowRight className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
|
||
</AnimatePresence>
|
||
|
||
{colTasks.length === 0 && slotHere < 0 && (
|
||
<div className="p-4 text-center text-faint text-xs italic">
|
||
Keine Aufgaben in dieser Spalte
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quick Add Task to Column */}
|
||
{!isSelectedListReadOnly && activeColumns.some((c) => boardIdOf(c) === col.id) && (
|
||
<div className="p-2 border-t border-border bg-panel">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleQuickAddTaskToColumn(col.id)}
|
||
className="w-full h-7 px-2 text-xs border border-dashed border-border rounded-btn text-muted hover:text-main hover:bg-canvas flex items-center justify-center gap-1 transition-colors"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
<span>Aufgabe hinzufügen</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
|
||
<div className="space-y-2">
|
||
{/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="hidden sm:flex items-center border border-border rounded-btn bg-canvas overflow-hidden">
|
||
<button
|
||
onClick={() => setFilter('open')}
|
||
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
|
||
filter === 'open' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
Offen ({openTasks.length})
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setFilter('done')}
|
||
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
|
||
filter === 'done' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
Erledigt ({doneTasks.length})
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setFilter('discarded')}
|
||
className={`h-8 px-3.5 text-xs font-medium transition-colors ${
|
||
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
||
}`}
|
||
>
|
||
Papierkorb ({taskTrash.length})
|
||
</button>
|
||
</div>
|
||
|
||
{/* Grouping and sorting right above the list, then clearing as a plain trash icon (desktop; phones
|
||
use the menu) */}
|
||
<div className="hidden sm:flex items-center gap-2 ml-auto">
|
||
<div className="flex items-center gap-1 text-xs text-muted">
|
||
<span className="text-[11px]">Gruppe:</span>
|
||
<select
|
||
value={groupBy}
|
||
onChange={(e) => setGroupBy(e.target.value as any)}
|
||
className="h-8 px-2 input-field text-xs bg-panel"
|
||
>
|
||
<option value="none">Keine</option>
|
||
<option value="tag">Nach Hashtag</option>
|
||
<option value="status">Nach Status</option>
|
||
<option value="priority">Nach Priorität</option>
|
||
<option value="due">Nach Fälligkeit</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-xs text-muted">
|
||
<span className="text-[11px]">Sortierung:</span>
|
||
<select
|
||
value={sortBy}
|
||
onChange={(e) => setSortBy(e.target.value as any)}
|
||
className="h-8 px-2 input-field text-xs bg-panel"
|
||
>
|
||
<option value="custom">Manuell (Drag & Drop)</option>
|
||
<option value="created">Erstelldatum</option>
|
||
<option value="due">Fälligkeitsdatum</option>
|
||
<option value="priority">Priorität</option>
|
||
<option value="alpha">Titel (A-Z)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
|
||
<button
|
||
onClick={handleEmptyTaskTrash}
|
||
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
|
||
title="Papierkorb leeren"
|
||
aria-label="Papierkorb leeren"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
|
||
<button
|
||
onClick={handleClearDone}
|
||
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
|
||
title="Erledigte in den Papierkorb"
|
||
aria-label="Erledigte in den Papierkorb"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
|
||
) : filter === 'discarded' ? (
|
||
<div className="panel-surface divide-y divide-border">
|
||
{taskTrash.length === 0 ? (
|
||
<div className="px-4 py-6 text-center text-muted text-sm">Der Papierkorb ist leer.</div>
|
||
) : (
|
||
taskTrash.map((item) => {
|
||
const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined;
|
||
return (
|
||
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover">
|
||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
|
||
<div className="min-w-0">
|
||
<p className={`text-sm truncate ${item.isCompleted ? 'text-muted line-through' : 'text-main'}`}>{item.content}</p>
|
||
<p className="text-[11px] text-faint truncate">
|
||
{selectedListId === 'all' && (
|
||
<>
|
||
{listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
|
||
{' · '}
|
||
</>
|
||
)}
|
||
gelöscht {formatDeletedAt(item.trashedAt)}
|
||
{item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{item.canEdit && (
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<button onClick={() => handleRestoreTrashed(item)} className="btn-action min-h-[38px] sm:min-h-0">
|
||
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
|
||
</button>
|
||
<button
|
||
onClick={() => handlePurgeTrashed(item)}
|
||
className="btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0"
|
||
title="Endgültig löschen"
|
||
aria-label="Endgültig löschen"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{groupedTasks ? (
|
||
/* GROUPED LIST */
|
||
groupedTasks.map((grp) => (
|
||
<div key={grp.key} className="panel-surface overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-panel border-b border-border flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-main">{grp.title}</span>
|
||
<span className="badge-neutral font-mono text-[10px]">{grp.count}</span>
|
||
</div>
|
||
<div>
|
||
{grp.tasks.map((task) => renderTaskRow(task))}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
/* FLAT LIST */
|
||
<div className="panel-surface divide-y divide-border overflow-hidden">
|
||
{filteredTasks.length === 0 ? (
|
||
<div className="p-8 text-center text-muted text-sm space-y-1">
|
||
<p className="font-medium text-main">Keine Aufgaben gefunden</p>
|
||
<p className="text-xs text-faint">
|
||
{selectedTag
|
||
? `Keine Aufgaben mit Hashtag #${selectedTag}.`
|
||
: filter === 'done'
|
||
? 'Noch keine Aufgaben erledigt.'
|
||
: 'Erstelle oben eine neue Aufgabe.'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
filteredTasks.map((task) => renderTaskRow(task))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|