From 81ff27b76a4e82db63cf3bdc29fc4b2ef81372de Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Mon, 27 Jul 2026 14:32:42 +0200 Subject: [PATCH] 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) --- .../components/contacts/ContactFolderTree.tsx | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/contacts/ContactFolderTree.tsx b/frontend/src/components/contacts/ContactFolderTree.tsx index cce55ff..b4511da 100644 --- a/frontend/src/components/contacts/ContactFolderTree.tsx +++ b/frontend/src/components/contacts/ContactFolderTree.tsx @@ -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 (
0 ? maxHeight : undefined, overflowY: 'auto' }} > {items.map((item, i) => (