On the desktop the window no longer scrolls: the area between header and footer does, with room for its scrollbar kept on both sides so the content stays centred and nothing shifts between pages. This replaces the scrollbar-gutter tricks (header colour in the gutter, Chrome's always-on scrollbar, which also let the sticky header scroll away in Chrome). Triage fills the height: card and buttons stay, the queue takes the rest and scrolls inside. The Kanban height fit and the Quick Dump button use the scroll area. Phones keep scrolling the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3174 lines
135 KiB
TypeScript
3174 lines
135 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 { readViewMemory, writeViewMemory } 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 { ListSwitcher } from '../../components/ListSwitcher';
|
||
import { LivePill } from '../../components/LivePill';
|
||
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 GroupBy = 'none' | 'list' | 'tag' | 'status' | 'priority' | 'due';
|
||
type ViewMode = 'list' | 'kanban';
|
||
// viewModes: list or Kanban per task list ("all" for Alle Aufgaben); viewMode is the old single value
|
||
type TasksSession = {
|
||
listId: string;
|
||
viewModes: Record<string, ViewMode>;
|
||
viewMode?: ViewMode;
|
||
groupAll: GroupBy;
|
||
groupList: GroupBy;
|
||
};
|
||
|
||
// "Alle Aufgaben" is grouped by task list unless the user picks something else; a single list has no
|
||
// grouping by default (grouping it by list would give one group)
|
||
const DEFAULT_GROUP_ALL: GroupBy = 'list';
|
||
const DEFAULT_GROUP_LIST: GroupBy = 'none';
|
||
const GROUP_LABELS: Record<GroupBy, string> = {
|
||
none: 'Keine',
|
||
list: 'Nach Liste',
|
||
tag: 'Nach Hashtag',
|
||
status: 'Nach Status',
|
||
priority: 'Nach Priorität',
|
||
due: 'Nach Fälligkeit',
|
||
};
|
||
const SORT_LABELS: Record<string, string> = {
|
||
custom: 'Manuell',
|
||
created: 'Erstelldatum',
|
||
due: 'Fälligkeitsdatum',
|
||
priority: 'Priorität',
|
||
alpha: 'Alphabetisch',
|
||
};
|
||
|
||
export const TasksView: React.FC<TasksViewProps> = ({
|
||
currentUser,
|
||
onRefreshStats,
|
||
onOpenDocument,
|
||
refreshTrigger = 0,
|
||
initialSelectedTaskId,
|
||
onInitialTaskShown,
|
||
openTasksCount,
|
||
}) => {
|
||
// Open list, view per list and grouping are remembered on this device (new tab, restart of the
|
||
// installed app)
|
||
const remembered = useMemo(() => readViewMemory<TasksSession>('tasks'), []);
|
||
const [viewModes, setViewModes] = useState<Record<string, ViewMode>>(() => {
|
||
const out: Record<string, ViewMode> = {};
|
||
if (remembered.viewModes && typeof remembered.viewModes === 'object') {
|
||
for (const [id, mode] of Object.entries(remembered.viewModes)) {
|
||
if (mode === 'kanban') out[id] = 'kanban';
|
||
}
|
||
} else if (remembered.viewMode === 'kanban' && remembered.listId) {
|
||
out[remembered.listId] = 'kanban'; // stored before views were kept per list
|
||
}
|
||
return out;
|
||
});
|
||
const [groupAll, setGroupAll] = useState<GroupBy>(() =>
|
||
remembered.groupAll && remembered.groupAll in GROUP_LABELS ? remembered.groupAll : DEFAULT_GROUP_ALL
|
||
);
|
||
const [groupList, setGroupList] = useState<GroupBy>(() =>
|
||
remembered.groupList && remembered.groupList in GROUP_LABELS && remembered.groupList !== 'list'
|
||
? remembered.groupList
|
||
: DEFAULT_GROUP_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 [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||
const [selectedListId, setSelectedListIdState] = useState<string>(
|
||
() => remembered.listId || 'all'
|
||
);
|
||
const viewMode: ViewMode = viewModes[selectedListId] || 'list';
|
||
// Only Kanban is stored; the list view is the default
|
||
const setViewMode = useCallback(
|
||
(mode: ViewMode) =>
|
||
setViewModes((prev) => {
|
||
const next = { ...prev };
|
||
if (mode === 'kanban') next[selectedListId] = 'kanban';
|
||
else delete next[selectedListId];
|
||
return next;
|
||
}),
|
||
[selectedListId]
|
||
);
|
||
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
|
||
const isAllTasks = selectedListId === 'all';
|
||
const groupBy: GroupBy = isAllTasks ? groupAll : groupList;
|
||
const setGroupBy = (g: GroupBy) => (isAllTasks ? setGroupAll(g) : setGroupList(g));
|
||
const defaultGroupBy = isAllTasks ? DEFAULT_GROUP_ALL : DEFAULT_GROUP_LIST;
|
||
// "Nach Liste" only where several lists show up
|
||
const groupOptions = (Object.keys(GROUP_LABELS) as GroupBy[])
|
||
.filter((id) => isAllTasks || id !== 'list')
|
||
.map((id) => ({ id, label: GROUP_LABELS[id] }));
|
||
useEffect(() => {
|
||
writeViewMemory<TasksSession>('tasks', { listId: selectedListId, viewModes, groupAll, groupList });
|
||
}, [selectedListId, viewModes, groupAll, groupList]);
|
||
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 [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;
|
||
listCacheRef.current.set(listId, {
|
||
tasks: Array.isArray(allTasks) ? allTasks : [],
|
||
trash: Array.isArray(trash) ? trash : [],
|
||
});
|
||
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;
|
||
|
||
// Last known tasks of every list opened in this session. Switching lists shows them in the same render
|
||
// as the new list's view (list or Kanban), so the board never flashes the previous list's tasks; the
|
||
// reload then brings them up to date. A list not opened yet takes its share of "Alle Aufgaben".
|
||
const listCacheRef = useRef(new Map<string, { tasks: Thought[]; trash: TrashedTask[] }>());
|
||
useEffect(() => {
|
||
if (!loading && loadedListRef.current === selectedListId) {
|
||
listCacheRef.current.set(selectedListId, { tasks, trash: taskTrash });
|
||
}
|
||
}, [tasks, taskTrash, loading, selectedListId]);
|
||
const setSelectedListId = useCallback((id: string) => {
|
||
if (id === selectedListIdRef.current) return;
|
||
const cache = listCacheRef.current;
|
||
let known = cache.get(id);
|
||
const all = cache.get('all');
|
||
if (!known && id !== 'all' && all) {
|
||
known = {
|
||
tasks: all.tasks.filter((t) => t.taskListId === id),
|
||
trash: all.trash.filter((t) => t.taskListId === id),
|
||
};
|
||
}
|
||
setTasks(known ? known.tasks : []);
|
||
setTaskTrash(known ? known.trash : []);
|
||
setLoading(!known);
|
||
loadedListRef.current = known ? id : null;
|
||
setSelectedListIdState(id);
|
||
}, []);
|
||
|
||
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 bottom of the app's scroll area (the desktop window itself does not
|
||
// scroll); then take back what still overflows (paddings)
|
||
const area = el.closest<HTMLElement>('[data-app-scroll]');
|
||
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
|
||
const areaHeight = area ? area.clientHeight : window.innerHeight;
|
||
const top = el.getBoundingClientRect().top - areaTop;
|
||
setKanbanColHeight(Math.max(320, areaHeight - top - 16));
|
||
requestAnimationFrame(() => {
|
||
const over = area ? area.scrollHeight - area.clientHeight : 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 === 'list') {
|
||
// Tasks without a list first, then the lists in the order of the tabs
|
||
const byList = new Map<string, Thought[]>();
|
||
for (const t of filteredTasks) {
|
||
const key = t.taskListId || '';
|
||
if (!byList.has(key)) byList.set(key, []);
|
||
byList.get(key)!.push(t);
|
||
}
|
||
const without = byList.get('') || [];
|
||
if (without.length > 0) groups.push({ key: 'list:none', title: 'Ohne Liste', count: without.length, tasks: without });
|
||
for (const tl of safeTaskLists) {
|
||
const list = byList.get(tl.id);
|
||
if (!list || list.length === 0) continue;
|
||
groups.push({ key: `list:${tl.id}`, title: [tl.icon, tl.title].filter(Boolean).join(' '), count: list.length, tasks: list });
|
||
byList.delete(tl.id);
|
||
}
|
||
byList.delete('');
|
||
byList.forEach((list, id) => groups.push({ key: `list:${id}`, title: 'Andere Liste', count: list.length, tasks: list }));
|
||
} else 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, safeTaskLists]);
|
||
|
||
// --- 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));
|
||
// Plain switch: the deleted list's tasks belong to "Alle Aufgaben" anyway (and the menu effect keeps
|
||
// this handler out of its dependencies)
|
||
setSelectedListIdState('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 !== defaultGroupBy ? 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,
|
||
defaultGroupBy,
|
||
setViewMode,
|
||
setSelectedListId,
|
||
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="inline-flex items-center">
|
||
Alle Aufgaben
|
||
<CountBadge count={openTasksCount} position="text" />
|
||
</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="inline-flex items-center">
|
||
{tl.title}
|
||
<CountBadge count={tl.tasksCount} position="text" />
|
||
</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 like the notes: status (live, shared) | actions (share, view switcher) */}
|
||
<div className="flex items-center gap-2">
|
||
<LivePill />
|
||
{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>
|
||
)
|
||
)}
|
||
|
||
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="btn-icon"
|
||
title="Aufgabenliste teilen und Berechtigungen"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
{!currentSelectedList && <span className="w-px h-5 bg-border mx-0.5" aria-hidden />}
|
||
|
||
{/* 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">
|
||
<ListSwitcher
|
||
label={currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
||
items={[
|
||
{ id: 'all', title: 'Alle Aufgaben', icon: '📋', count: openTasksCount },
|
||
...safeTaskLists.map((tl) => ({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })),
|
||
]}
|
||
activeId={selectedListId}
|
||
onSelect={setSelectedListId}
|
||
/>
|
||
<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>
|
||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||
<LivePill />
|
||
{currentSelectedList && (
|
||
<>
|
||
<span className="w-px h-5 bg-border" aria-hidden />
|
||
<button
|
||
type="button"
|
||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||
title="Aufgabenliste teilen"
|
||
>
|
||
<Users className="w-4 h-4 text-accent" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</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 !== defaultGroupBy || 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 !== defaultGroupBy && (
|
||
<span className="text-muted text-[11px]">
|
||
Gruppe: <strong className="text-main">{GROUP_LABELS[groupBy]}</strong>
|
||
</span>
|
||
)}
|
||
{sortBy !== 'custom' && (
|
||
<span className="text-muted text-[11px]">
|
||
Sortiert: <strong className="text-main">{SORT_LABELS[sortBy]}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedTag(null);
|
||
setGroupBy(defaultGroupBy);
|
||
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">
|
||
{groupOptions.map((g) => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
onClick={() => setGroupBy(g.id)}
|
||
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(defaultGroupBy);
|
||
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 GroupBy)}
|
||
className="h-8 px-2 input-field text-xs bg-panel"
|
||
>
|
||
{groupOptions.map((g) => (
|
||
<option key={g.id} value={g.id}>
|
||
{g.label}
|
||
</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 && groupedTasks.length > 0 ? (
|
||
/* 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>
|
||
);
|
||
};
|