feat: add drag-and-drop for tasks, workspace bullets, and list items
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
c0661cddae
commit
35d4657a25
9 files changed
+814
-51
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
Share,
|
||||
X,
|
||||
Hash,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||
@@ -86,6 +87,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const loadLists = async () => {
|
||||
@@ -185,7 +190,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
msg.event === 'LIST_CREATED' ||
|
||||
msg.event === 'LIST_DELETED' ||
|
||||
msg.event === 'LIST_SHARED' ||
|
||||
msg.event === 'LIST_UNSHARED'
|
||||
msg.event === 'LIST_UNSHARED' ||
|
||||
msg.event === 'ITEM_REORDERED'
|
||||
) {
|
||||
loadLists();
|
||||
}
|
||||
@@ -210,7 +216,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
msg.event === 'LIST_SHARED' ||
|
||||
msg.event === 'LIST_UNSHARED' ||
|
||||
msg.event === 'LIST_CREATED' ||
|
||||
msg.event === 'LIST_DELETED'
|
||||
msg.event === 'LIST_DELETED' ||
|
||||
msg.event === 'ITEM_REORDERED'
|
||||
) {
|
||||
loadLists();
|
||||
}
|
||||
@@ -282,6 +289,179 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
);
|
||||
};
|
||||
|
||||
const handleItemDragStart = (e: React.DragEvent, item: ListItem) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-list-item-id', item.id);
|
||||
e.dataTransfer.setData('text/plain', item.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingItemId(item.id);
|
||||
};
|
||||
|
||||
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingItemId || draggingItemId === targetItemId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
||||
|
||||
if (
|
||||
!dragOverItemTarget ||
|
||||
dragOverItemTarget.itemId !== targetItemId ||
|
||||
dragOverItemTarget.position !== position
|
||||
) {
|
||||
setDragOverItemTarget({ itemId: targetItemId, position });
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemDragLeave = (e: React.DragEvent, targetItemId: string) => {
|
||||
e.stopPropagation();
|
||||
if (dragOverItemTarget?.itemId === targetItemId) {
|
||||
setDragOverItemTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnItem = async (e: React.DragEvent, targetItem: ListItem, targetCategory: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-list-item-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingItemId;
|
||||
|
||||
const position = dragOverItemTarget?.position || 'after';
|
||||
setDraggingItemId(null);
|
||||
setDragOverItemTarget(null);
|
||||
setDragOverCategoryTarget(null);
|
||||
|
||||
if (!activeList || !sourceId || sourceId === targetItem.id) return;
|
||||
|
||||
triggerHaptic(15);
|
||||
|
||||
const sourceItem = activeList.items.find((i) => i.id === sourceId);
|
||||
if (!sourceItem) return;
|
||||
|
||||
const targetCatNormalized = targetCategory.trim();
|
||||
const isCategoryChanged = (sourceItem.category || '').toLowerCase() !== targetCatNormalized.toLowerCase();
|
||||
|
||||
let newCategory = sourceItem.category;
|
||||
let newContent = sourceItem.content;
|
||||
|
||||
if (isCategoryChanged) {
|
||||
newCategory = targetCatNormalized;
|
||||
if (!targetCatNormalized) {
|
||||
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
|
||||
} else if (newContent.includes('#')) {
|
||||
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
|
||||
const targetIndex = remainingItems.findIndex((i) => i.id === targetItem.id);
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1;
|
||||
const updatedSourceItem: ListItem = {
|
||||
...sourceItem,
|
||||
category: newCategory,
|
||||
content: newContent,
|
||||
};
|
||||
|
||||
const newItems = [...remainingItems];
|
||||
newItems.splice(insertIndex, 0, updatedSourceItem);
|
||||
|
||||
const reorderPayload: { id: string; sortOrder: number; category?: string; content?: string }[] = [];
|
||||
newItems.forEach((it, idx) => {
|
||||
it.sortOrder = idx;
|
||||
if (it.id === sourceId) {
|
||||
reorderPayload.push({
|
||||
id: it.id,
|
||||
sortOrder: idx,
|
||||
category: newCategory,
|
||||
content: newContent,
|
||||
});
|
||||
} else {
|
||||
reorderPayload.push({
|
||||
id: it.id,
|
||||
sortOrder: idx,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLists((prev) =>
|
||||
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
|
||||
);
|
||||
|
||||
try {
|
||||
await api.reorderListItems(activeList.id, reorderPayload);
|
||||
} catch {
|
||||
loadLists();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnCategoryHeader = async (e: React.DragEvent, targetCategory: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-list-item-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingItemId;
|
||||
|
||||
setDraggingItemId(null);
|
||||
setDragOverItemTarget(null);
|
||||
setDragOverCategoryTarget(null);
|
||||
|
||||
if (!activeList || !sourceId) return;
|
||||
|
||||
triggerHaptic(15);
|
||||
const sourceItem = activeList.items.find((i) => i.id === sourceId);
|
||||
if (!sourceItem) return;
|
||||
|
||||
const targetCatNormalized = targetCategory.trim();
|
||||
let newCategory = targetCatNormalized;
|
||||
let newContent = sourceItem.content;
|
||||
|
||||
if (!targetCatNormalized) {
|
||||
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
|
||||
} else if (newContent.includes('#')) {
|
||||
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
|
||||
}
|
||||
|
||||
const updatedSourceItem: ListItem = {
|
||||
...sourceItem,
|
||||
category: newCategory,
|
||||
content: newContent,
|
||||
};
|
||||
|
||||
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
|
||||
const newItems = [updatedSourceItem, ...remainingItems];
|
||||
|
||||
const reorderPayload = newItems.map((it, idx) => {
|
||||
it.sortOrder = idx;
|
||||
if (it.id === sourceId) {
|
||||
return {
|
||||
id: it.id,
|
||||
sortOrder: idx,
|
||||
category: newCategory,
|
||||
content: newContent,
|
||||
};
|
||||
}
|
||||
return { id: it.id, sortOrder: idx };
|
||||
});
|
||||
|
||||
setLists((prev) =>
|
||||
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
|
||||
);
|
||||
|
||||
try {
|
||||
await api.reorderListItems(activeList.id, reorderPayload);
|
||||
} catch {
|
||||
loadLists();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newListTitle.trim()) return;
|
||||
@@ -764,18 +944,63 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)}
|
||||
|
||||
{/* Lighter panel-surface card container with items */}
|
||||
<div className="panel-surface divide-y divide-border overflow-hidden">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingItemId) setDragOverCategoryTarget(group.category);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverCategoryTarget === group.category) setDragOverCategoryTarget(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnCategoryHeader(e, group.category)}
|
||||
className={`panel-surface divide-y divide-border overflow-hidden transition-all ${
|
||||
dragOverCategoryTarget === group.category && !dragOverItemTarget
|
||||
? 'ring-2 ring-accent/60 bg-accent/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const cat = getItemCategory(item);
|
||||
const displayContent = getItemDisplayContent(item);
|
||||
const isDragging = draggingItemId === item.id;
|
||||
const isTarget = dragOverItemTarget?.itemId === item.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
||||
onDrop={(e) => handleDropOnItem(e, item, group.category)}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
className="px-3 py-2.5 flex items-center justify-between gap-3 cursor-pointer transition-colors hover:bg-panel-hover group"
|
||||
className={`px-3 py-2.5 flex items-center justify-between gap-3 cursor-pointer transition-colors hover:bg-panel-hover group relative ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Drop insertion line indicator */}
|
||||
{isTarget && dragOverItemTarget.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 && dragOverItemTarget.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-2 min-w-0 flex-1">
|
||||
{/* Grip handle for dragging */}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleItemDragStart(e, item)}
|
||||
onDragEnd={() => {
|
||||
setDraggingItemId(null);
|
||||
setDragOverItemTarget(null);
|
||||
setDragOverCategoryTarget(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="opacity-0 group-hover:opacity-70 hover:opacity-100 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 transition-opacity"
|
||||
title="Ziehen zum Sortieren oder in andere Hashtag-Kategorie schieben"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
|
||||
{/* Eckiges Abhaken */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user