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,
|
useDeleteContactFolder,
|
||||||
useMoveContactToFolder,
|
useMoveContactToFolder,
|
||||||
} from '@/api/hooks';
|
} 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';
|
import { ChevronRight, Folder, MoreVertical, Palette, Pencil, Pin, Plus, Tag, Trash2, Users } from 'lucide-react';
|
||||||
|
|
||||||
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}` | `folder:${string}`;
|
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 {
|
export interface ContactFolderTreeProps {
|
||||||
selectedFilter: ContactFilter;
|
selectedFilter: ContactFilter;
|
||||||
onSelect: (filter: ContactFilter) => void;
|
onSelect: (filter: ContactFilter) => void;
|
||||||
@@ -83,13 +97,14 @@ function FolderDropdown({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const top = state.anchorRect.bottom + 4;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
|
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) => (
|
{items.map((item, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -138,6 +153,11 @@ function FolderTreeItem({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('text/folder-id', node.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
onDragOver={(e) => onDragOver(e, node.id)}
|
onDragOver={(e) => onDragOver(e, node.id)}
|
||||||
onDragLeave={() => onDragLeave(node.id)}
|
onDragLeave={() => onDragLeave(node.id)}
|
||||||
onDrop={(e) => onDrop(e, node.id)}
|
onDrop={(e) => onDrop(e, node.id)}
|
||||||
@@ -164,7 +184,7 @@ function FolderTreeItem({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onMoreClick(e, node.id); }}
|
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"
|
title="Optionen"
|
||||||
aria-label="Optionen"
|
aria-label="Optionen"
|
||||||
>
|
>
|
||||||
@@ -272,6 +292,19 @@ export function ContactFolderTree({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragOverFolderId(null);
|
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');
|
const contactId = e.dataTransfer.getData('text/plain');
|
||||||
if (!contactId) return;
|
if (!contactId) return;
|
||||||
moveContactMut.mutate({ contactId, folderId });
|
moveContactMut.mutate({ contactId, folderId });
|
||||||
@@ -279,6 +312,13 @@ export function ContactFolderTree({
|
|||||||
|
|
||||||
const handleRootDrop = (e: React.DragEvent) => {
|
const handleRootDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
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');
|
const contactId = e.dataTransfer.getData('text/plain');
|
||||||
if (!contactId) return;
|
if (!contactId) return;
|
||||||
moveContactMut.mutate({ contactId, folderId: null });
|
moveContactMut.mutate({ contactId, folderId: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user