feat(lists): drag tasks and list entries onto another list; remember open list and view; live pill in lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m3s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6abcfa655b
commit
25bb787a8e
13 files changed
+298
-52
No files matched your search
@@ -4,13 +4,13 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Check,
|
||||
Radio,
|
||||
Sparkles,
|
||||
X,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
@@ -83,7 +83,13 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
||||
// The open list survives switching to other tabs (like the workspace's open note)
|
||||
const [activeListId, setActiveListId] = useState<string>(
|
||||
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
|
||||
);
|
||||
useEffect(() => {
|
||||
if (activeListId) writeViewSession('lists', { listId: activeListId });
|
||||
}, [activeListId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialListId) {
|
||||
@@ -116,6 +122,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
||||
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
|
||||
// List tab a dragged entry hovers over (dropping moves it into that list)
|
||||
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||
|
||||
const realtimeConnRef = useRef<any>(null);
|
||||
|
||||
@@ -378,6 +386,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setDraggingItemId(item.id);
|
||||
};
|
||||
|
||||
const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer';
|
||||
|
||||
// An entry dragged onto another list's tab lands at the top of that list, like a new entry
|
||||
const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => {
|
||||
e.preventDefault();
|
||||
const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId;
|
||||
setDraggingItemId(null);
|
||||
setDragOverItemTarget(null);
|
||||
setDragOverCategoryTarget(null);
|
||||
setDragOverListTab(null);
|
||||
const source = activeList;
|
||||
const item = source?.items.find((i) => i.id === itemId);
|
||||
if (!source || !item || target.id === source.id || !canWriteList(target)) return;
|
||||
triggerHaptic(15);
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) };
|
||||
if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) };
|
||||
return l;
|
||||
})
|
||||
);
|
||||
try {
|
||||
await api.moveListItem(source.id, item.id, target.id);
|
||||
} catch {
|
||||
loadLists();
|
||||
}
|
||||
onRefreshGlobal?.();
|
||||
};
|
||||
|
||||
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -849,6 +886,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
]);
|
||||
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
||||
|
||||
// Connection pill in the same look as the notes' header
|
||||
const livePill = (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||
title={
|
||||
!wsConnected
|
||||
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
||||
: realtimeTransport === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: 'Live-Verbindung über WebSocket'
|
||||
}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
||||
{wsConnected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: List Tabs & Actions (Desktop) */}
|
||||
@@ -861,9 +915,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
return (
|
||||
<div
|
||||
key={lst.id}
|
||||
onDragOver={(e) => {
|
||||
if (!draggingItemId || isSelected || !canWriteList(lst)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverListTab !== lst.id) setDragOverListTab(lst.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverListTab === lst.id) setDragOverListTab(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnListTab(e, lst)}
|
||||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
||||
} ${isSelected && lists.length > 1 ? 'pr-1' : ''} ${
|
||||
dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveListId(lst.id)}
|
||||
@@ -951,25 +1017,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Live Sync Status */}
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
||||
: 'Live synchronisiert (WebSocket online)'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
{livePill}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -996,24 +1044,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
||||
: 'Live synchronisiert (WebSocket online)'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
{livePill}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1195,6 +1226,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setDraggingItemId(null);
|
||||
setDragOverItemTarget(null);
|
||||
setDragOverCategoryTarget(null);
|
||||
setDragOverListTab(null);
|
||||
}}
|
||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
@@ -66,6 +67,8 @@ interface TasksViewProps {
|
||||
|
||||
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
||||
|
||||
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
currentUser,
|
||||
onRefreshStats,
|
||||
@@ -75,7 +78,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
openTasksCount,
|
||||
onWideChange,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||
// Open list and view survive switching to other tabs (like the workspace's open note)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
|
||||
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
|
||||
);
|
||||
// Wide Kanban: the board takes the whole window and shows every column in one row
|
||||
const [kanbanWide, setKanbanWide] = useState(() => {
|
||||
try {
|
||||
@@ -101,7 +107,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||
const [selectedListId, setSelectedListId] = useState<string>('all');
|
||||
const [selectedListId, setSelectedListId] = useState<string>(
|
||||
() => readViewSession<TasksSession>('tasks').listId || 'all'
|
||||
);
|
||||
useEffect(() => {
|
||||
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||||
}, [selectedListId, viewMode]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||
@@ -111,6 +122,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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);
|
||||
// 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('');
|
||||
@@ -190,6 +203,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
loadedListRef.current = listId;
|
||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||
// The remembered list was deleted or unshared meanwhile
|
||||
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
|
||||
setSelectedListId('all');
|
||||
}
|
||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||
@@ -535,6 +552,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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 });
|
||||
@@ -1137,6 +1183,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
setDragOverListTab(null);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
@@ -1385,9 +1432,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{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)}
|
||||
@@ -2221,6 +2279,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
setDragOverListTab(null);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
|
||||
@@ -994,6 +994,13 @@ export const api = {
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async moveListItem(listId: string, itemId: string, targetListId: string): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetListId }),
|
||||
});
|
||||
},
|
||||
|
||||
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
||||
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// View state for this browser tab that survives switching between the app's tabs (the views
|
||||
// remount), like the workspace's open note: which list was open, list or Kanban view
|
||||
const PREFIX = 'mindsift_view:';
|
||||
|
||||
export function readViewSession<T extends object>(view: string): Partial<T> {
|
||||
try {
|
||||
const raw = JSON.parse(sessionStorage.getItem(PREFIX + view) || '{}');
|
||||
return raw && typeof raw === 'object' ? raw : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeViewSession<T extends object>(view: string, state: T) {
|
||||
try {
|
||||
sessionStorage.setItem(PREFIX + view, JSON.stringify(state));
|
||||
} catch {
|
||||
// storage blocked: the view simply starts fresh
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user