From 5716d91115d59269c4dc04a163cbe8ba53cc0255 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Sat, 18 Jul 2026 07:38:44 +0300 Subject: [PATCH] feat(folders): drag-and-drop reorder for all folders --- components/settings/folder-settings.tsx | 87 +++++++++++++++++++++++-- lib/utils.ts | 17 +++-- locales/en/common.json | 2 + locales/he/common.json | 2 + package-lock.json | 67 +++++++++++++++---- package.json | 3 + stores/email-store.ts | 40 ++++++++++++ 7 files changed, 196 insertions(+), 22 deletions(-) diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index c4f09aec..80b1b0db 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -17,7 +17,16 @@ import { type LucideIcon, } from 'lucide-react'; import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils'; -import { ChevronRight, ChevronDown } from 'lucide-react'; +import { ChevronRight, ChevronDown, GripVertical } from 'lucide-react'; +import { + DndContext, closestCenter, PointerSensor, KeyboardSensor, + useSensor, useSensors, type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, verticalListSortingStrategy, useSortable, + arrayMove, sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const; @@ -102,10 +111,47 @@ function IconPicker({ currentIcon, onSelect, onClose }: { ); } +/** + * Wraps a folder row with a drag handle so it can be reordered within its + * sibling group. The handle carries the dnd-kit listeners; the rest of the row + * (buttons, inline editors) stays fully interactive. + */ +function SortableFolderRow({ id, title, children }: { id: string; title: string; children: React.ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + position: isDragging ? 'relative' : undefined, + }; + return ( +
+ +
{children}
+
+ ); +} + export function FolderSettings() { const t = useTranslations('settings.folders'); const { client } = useAuthStore(); - const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore(); + const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes } = useEmailStore(); + + const sensors = useSensors( + // Small activation distance so clicking the row's buttons still works. + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const { folderIcons, setFolderIcon } = useSettingsStore(); const { isFeatureEnabled } = usePolicyStore(); const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled'); @@ -129,6 +175,31 @@ export function FolderSettings() { const ownMailboxes = mailboxes.filter(mb => !mb.isShared); const folderTree = buildMailboxTree(ownMailboxes); + // Reorder folders within a sibling group (same parent). Drops onto a folder + // in a different group are ignored — this reorders, it doesn't reparent. + const handleFolderDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !client) return; + + const groups: MailboxNode[][] = []; + const collectGroups = (nodes: MailboxNode[]) => { + groups.push(nodes); + nodes.forEach(n => { if (n.children.length > 0) collectGroups(n.children); }); + }; + collectGroups(folderTree); + + const group = groups.find(g => g.some(n => n.id === active.id)); + if (!group) return; + const oldIndex = group.findIndex(n => n.id === active.id); + const newIndex = group.findIndex(n => n.id === over.id); + if (newIndex < 0) return; // dropped outside the active folder's sibling group + + const orderedIds = arrayMove(group, oldIndex, newIndex).map(n => n.id); + reorderMailboxes(client, orderedIds).catch(() => { + toast.error(t('reorder_error')); + }); + }; + const getRoleMailboxId = (role: string): string => { const mb = ownMailboxes.find(m => m.role === role); return mb?.id ?? ''; @@ -385,6 +456,7 @@ export function FolderSettings() { return (
+
+
{/* Inline subfolder creation */} {renderCreateInline(mb.id, depth + 1)} {/* Render children if expanded */} {hasChildren && isExpanded && (
- {node.children.map(child => renderFolderNode(child))} + c.id)} strategy={verticalListSortingStrategy}> + {node.children.map(child => renderFolderNode(child))} +
)}
@@ -505,7 +580,11 @@ export function FolderSettings() {

{t('no_folders')}

) : ( - folderTree.map(node => renderFolderNode(node)) + + n.id)} strategy={verticalListSortingStrategy}> + {folderTree.map(node => renderFolderNode(node))} + + )} diff --git a/lib/utils.ts b/lib/utils.ts index 6d4c4978..3f3ecd9f 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -486,25 +486,28 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { return a.isShared ? 1 : -1; } - // 2. Priority: Role-based ordering (inbox first, trash last, etc.) + // 2. Priority: user-defined order. When a folder has been explicitly + // reordered its sortOrder is non-zero and takes precedence over the + // default role/name ordering below. Untouched folders keep sortOrder 0, + // so the default arrangement is unchanged until the user drags something. + if (a.sortOrder !== b.sortOrder) { + return a.sortOrder - b.sortOrder; + } + + // 3. Priority: Role-based ordering (inbox first, trash last, etc.) const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999; const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999; if (aPriority !== bPriority) { return aPriority - bPriority; } - // 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending + // 4. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending const aIsYear = /^\d{4}$/.test(a.name); const bIsYear = /^\d{4}$/.test(b.name); if (aIsYear && bIsYear) { return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023... } - // 4. Fallback: Server sortOrder - if (a.sortOrder !== b.sortOrder) { - return a.sortOrder - b.sortOrder; - } - // 5. Fallback: Alphabetical by name return a.name.localeCompare(b.name); }); diff --git a/locales/en/common.json b/locales/en/common.json index 946af3dd..193f1bfc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1571,6 +1571,8 @@ "role_none": "None", "create_folder": "Create Folder", "create_subfolder": "Create Subfolder", + "reorder": "Drag to reorder", + "reorder_error": "Failed to reorder folders", "subfolder_of": "Inside {name}", "subfolder_name": "Subfolder name", "new_folder_name": "Folder name", diff --git a/locales/he/common.json b/locales/he/common.json index ca0b9a38..8ddbb366 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1528,6 +1528,8 @@ "role_none": "אין", "create_folder": "צור תיקיה", "create_subfolder": "צור תיקיית משנה", + "reorder": "גרור לשינוי הסדר", + "reorder_error": "שינוי סדר התיקיות נכשל", "subfolder_of": "בתוך{name}", "subfolder_name": "שם תיקיית משנה", "new_folder_name": "שם התיקיה", diff --git a/package-lock.json b/package-lock.json index 566863ab..47234a7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.7.7", "license": "AGPL-3.0-only", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.13.24", "@tiptap/extension-color": "^3.22.4", "@tiptap/extension-image": "^3.22.4", @@ -571,6 +574,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -6223,7 +6279,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7904,16 +7959,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index d7baba27..5fee18aa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.13.24", "@tiptap/extension-color": "^3.22.4", "@tiptap/extension-image": "^3.22.4", diff --git a/stores/email-store.ts b/stores/email-store.ts index 96625bfc..33d13867 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -230,6 +230,7 @@ interface EmailStore { renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise; deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise; setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise; + reorderMailboxes: (client: IJMAPClient, orderedIds: string[]) => Promise; emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise; markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise; @@ -3227,6 +3228,45 @@ export const useEmailStore = create((set, get) => ({ } }, + reorderMailboxes: async (client, orderedIds) => { + // Assign a 1-based sortOrder to the given sibling group in its new order. + // sortOrder is the primary sort key (see buildMailboxTree), so this pins + // the folders' arrangement in the sidebar and settings. + const updates = orderedIds.map((id, idx) => ({ id, sortOrder: idx + 1 })); + const applyLocal = (list: Mailbox[]) => + list.map(mb => { + const u = updates.find(x => x.id === mb.id); + return u ? { ...mb, sortOrder: u.sortOrder } : mb; + }); + // Optimistic local update so the reorder is reflected immediately. + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: applyLocal(state.accountMailboxes[viewingId] ?? []), + }, + })); + } else { + set({ mailboxes: applyLocal(get().mailboxes) }); + } + try { + const effectiveClient = resolveActionClient(client); + for (const u of updates) { + await effectiveClient.updateMailbox(u.id, { sortOrder: u.sortOrder }); + } + } catch (error) { + set({ error: error instanceof Error ? error.message : 'Failed to reorder folders' }); + // Re-sync from server so local state doesn't drift from persisted order. + if (viewingId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } + throw error; + } + }, + emptyMailbox: async (client, mailboxId) => { try { set({ isLoading: true, error: null });