feat(folders): drag-and-drop reorder for all folders

This commit is contained in:
Shuki Vaknin
2026-07-21 20:56:38 +02:00
committed by Linus Rath
parent 2f791318df
commit 5716d91115
7 changed files with 196 additions and 22 deletions
+83 -4
View File
@@ -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 (
<div ref={setNodeRef} style={style} className="flex items-stretch">
<button
type="button"
{...attributes}
{...listeners}
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-none focus:ring-2 focus:ring-ring flex-shrink-0"
title={title}
aria-label={title}
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
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 (
<div key={mb.id}>
<SortableFolderRow id={mb.id} title={t('reorder')}>
<div
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
style={{ paddingLeft: 12 + depth * 16 }}
@@ -482,12 +554,15 @@ export function FolderSettings() {
)}
</div>
</div>
</SortableFolderRow>
{/* Inline subfolder creation */}
{renderCreateInline(mb.id, depth + 1)}
{/* Render children if expanded */}
{hasChildren && isExpanded && (
<div>
{node.children.map(child => renderFolderNode(child))}
<SortableContext items={node.children.map(c => c.id)} strategy={verticalListSortingStrategy}>
{node.children.map(child => renderFolderNode(child))}
</SortableContext>
</div>
)}
</div>
@@ -505,7 +580,11 @@ export function FolderSettings() {
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
</div>
) : (
folderTree.map(node => renderFolderNode(node))
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleFolderDragEnd}>
<SortableContext items={folderTree.map(n => n.id)} strategy={verticalListSortingStrategy}>
{folderTree.map(node => renderFolderNode(node))}
</SortableContext>
</DndContext>
)}
</div>
+10 -7
View File
@@ -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);
});
+2
View File
@@ -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",
+2
View File
@@ -1528,6 +1528,8 @@
"role_none": "אין",
"create_folder": "צור תיקיה",
"create_subfolder": "צור תיקיית משנה",
"reorder": "גרור לשינוי הסדר",
"reorder_error": "שינוי סדר התיקיות נכשל",
"subfolder_of": "בתוך{name}",
"subfolder_name": "שם תיקיית משנה",
"new_folder_name": "שם התיקיה",
+56 -11
View File
@@ -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",
+3
View File
@@ -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",
+40
View File
@@ -230,6 +230,7 @@ interface EmailStore {
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
reorderMailboxes: (client: IJMAPClient, orderedIds: string[]) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number>;
@@ -3227,6 +3228,45 @@ export const useEmailStore = create<EmailStore>((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 });