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:
Agent Zero
2026-07-27 14:32:42 +02:00
parent 719ee251f2
commit 81ff27b76a
@@ -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 });