feat: contact folder drag-and-drop, mobile dropdown, folder-in-folder move
- Folders are draggable: drag folder into another folder (parent_id update) - Circular reference prevention: isDescendantOrSelf() check - Root drop zone: drag folder to root unparents it (parent_id=null) - MoreVertical button: opacity-60 for mobile visibility (was opacity-0) - Dropdown: viewport-clamped positioning + maxHeight with scroll - ContactList already had draggable contacts (no changes needed)
This commit is contained in:
@@ -8,11 +8,25 @@ import {
|
||||
useDeleteContactFolder,
|
||||
useMoveContactToFolder,
|
||||
} from '@/api/hooks';
|
||||
import { buildFolderTree, type ContactFolderTreeNode } from '@/api/contactFolders';
|
||||
import { buildFolderTree, type ContactFolderTreeNode, type ContactFolder } from '@/api/contactFolders';
|
||||
import { ChevronRight, Folder, MoreVertical, Palette, Pencil, Pin, Plus, Tag, Trash2, Users } from 'lucide-react';
|
||||
|
||||
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`;
|
||||
|
||||
/**
|
||||
* Returns true if `descendantId` is a descendant of (or equal to) `ancestorId`
|
||||
* in the given flat folder list. Used to prevent circular folder moves.
|
||||
*/
|
||||
function isDescendantOrSelf(folders: ContactFolder[], descendantId: string, ancestorId: string): boolean {
|
||||
if (descendantId === ancestorId) return true;
|
||||
let current = folders.find((f) => f.id === descendantId);
|
||||
while (current && current.parent_id) {
|
||||
if (current.parent_id === ancestorId) return true;
|
||||
current = folders.find((f) => f.id === current!.parent_id!);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface ContactFolderTreeProps {
|
||||
selectedFilter: ContactFilter;
|
||||
onSelect: (filter: ContactFilter) => void;
|
||||
@@ -83,13 +97,14 @@ function FolderDropdown({
|
||||
];
|
||||
|
||||
const top = state.anchorRect.bottom + 4;
|
||||
const left = Math.max(4, state.anchorRect.right - 160);
|
||||
const left = Math.max(4, Math.min(state.anchorRect.right - 160, window.innerWidth - 170));
|
||||
const maxHeight = Math.min(300, window.innerHeight - top - 8);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
|
||||
style={{ top, left }}
|
||||
style={{ top, left, maxHeight: maxHeight > 0 ? maxHeight : undefined, overflowY: 'auto' }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
@@ -138,6 +153,11 @@ function FolderTreeItem({
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/folder-id', node.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragOver={(e) => onDragOver(e, node.id)}
|
||||
onDragLeave={() => onDragLeave(node.id)}
|
||||
onDrop={(e) => onDrop(e, node.id)}
|
||||
@@ -164,7 +184,7 @@ function FolderTreeItem({
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onMoreClick(e, node.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5"
|
||||
className="opacity-60 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5"
|
||||
title="Optionen"
|
||||
aria-label="Optionen"
|
||||
>
|
||||
@@ -272,6 +292,19 @@ export function ContactFolderTree({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolderId(null);
|
||||
|
||||
// 1) Folder-to-folder drop: move dragged folder into target folder
|
||||
const draggedFolderId = e.dataTransfer.getData('text/folder-id');
|
||||
if (draggedFolderId) {
|
||||
// Prevent dropping a folder into itself or any of its descendants
|
||||
if (isDescendantOrSelf(folderList, folderId, draggedFolderId)) {
|
||||
return;
|
||||
}
|
||||
updateFolderMut.mutate({ id: draggedFolderId, data: { parent_id: folderId } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Contact-to-folder drop
|
||||
const contactId = e.dataTransfer.getData('text/plain');
|
||||
if (!contactId) return;
|
||||
moveContactMut.mutate({ contactId, folderId });
|
||||
@@ -279,6 +312,13 @@ export function ContactFolderTree({
|
||||
|
||||
const handleRootDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Folder-to-root: unparent the dragged folder
|
||||
const draggedFolderId = e.dataTransfer.getData('text/folder-id');
|
||||
if (draggedFolderId) {
|
||||
updateFolderMut.mutate({ id: draggedFolderId, data: { parent_id: null } });
|
||||
return;
|
||||
}
|
||||
// Contact-to-root: remove from any folder
|
||||
const contactId = e.dataTransfer.getData('text/plain');
|
||||
if (!contactId) return;
|
||||
moveContactMut.mutate({ contactId, folderId: null });
|
||||
|
||||
Reference in New Issue
Block a user