Files
webmail/components/settings/folder-settings.tsx
T

886 lines
33 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection, SettingItem, Select } from './settings-section';
import {
Plus, Pencil, Trash2, Check, X, FolderPlus, Folder,
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square,
type AppIcon,
} from '@/components/icons';
import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
import {
flattenVisibleTree, removeSubtree, getProjection, getDropPlan,
type FlatFolder,
} from '@/lib/folder-tree-dnd';
import { ChevronRight, ChevronDown, GripVertical } from '@/components/icons';
import {
DndContext, closestCenter, PointerSensor, KeyboardSensor,
useSensor, useSensors, MeasuringStrategy,
type DragEndEvent, type DragMoveEvent, type DragOverEvent, type DragStartEvent,
} from '@dnd-kit/core';
import {
SortableContext, verticalListSortingStrategy, useSortable,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { isFolderCountMode } from '@/lib/folder-counts';
// Must match the per-level indentation used in the row styles below: dragging
// a folder this many pixels right/left targets one nesting level deeper/higher.
const INDENTATION_WIDTH = 16;
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
// "Move to" value for the root of the folder tree.
const TOP_LEVEL = '__top__';
const ROLE_ICONS: Record<string, AppIcon> = {
inbox: Inbox,
drafts: FileText,
sent: Send,
trash: Trash,
junk: ShieldAlert,
archive: Archive,
shared: Users,
important: AlertTriangle,
memos: NotebookPen,
scheduled: CalendarClock,
snoozed: BellOff,
};
const ICON_CHOICES: { name: string; icon: AppIcon }[] = [
{ name: 'Folder', icon: Folder },
{ name: 'Star', icon: Star },
{ name: 'Heart', icon: Heart },
{ name: 'Bookmark', icon: Bookmark },
{ name: 'Tag', icon: Tag },
{ name: 'Flag', icon: Flag },
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Users', icon: Users },
{ name: 'Bell', icon: Bell },
{ name: 'Zap', icon: Zap },
{ name: 'Globe', icon: Globe },
{ name: 'Lock', icon: Lock },
{ name: 'Eye', icon: Eye },
{ name: 'MessageSquare', icon: MessageSquare },
{ name: 'Mail', icon: Mail },
{ name: 'Inbox', icon: Inbox },
{ name: 'Archive', icon: Archive },
{ name: 'FileText', icon: FileText },
];
function IconPicker({ currentIcon, onSelect, onClose }: {
currentIcon: string;
onSelect: (iconName: string) => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div
ref={ref}
className="absolute start-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
>
{ICON_CHOICES.map(({ name, icon: Icon }) => (
<button
key={name}
onClick={() => onSelect(name)}
className={cn(
"p-1.5 rounded-md transition-colors flex items-center justify-center",
currentIcon === name
? "bg-primary text-primary-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
title={name}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
);
}
/**
* 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, reorderMailboxes, moveMailbox, moveMailboxes } = 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, folderCountModes, updateSetting } = useSettingsStore();
const { isFeatureEnabled } = usePolicyStore();
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
useEffect(() => {
if (client && mailboxes.length === 0) {
fetchMailboxes(client);
}
}, [client, mailboxes.length, fetchMailboxes]);
const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [iconPickerId, setIconPickerId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Drag state: the tree renders as one flat sortable list; vertical movement
// picks the insertion point, horizontal movement the nesting depth. A drag
// can therefore both reorder folders and move them under a different parent
// (GitHub #855).
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [offsetLeft, setOffsetLeft] = useState(0);
// Folders ticked for a bulk move (#1173). Shift-click ticks or unticks the
// visible rows between the last clicked checkbox and this one.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState('');
const [isMoving, setIsMoving] = useState(false);
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const folderTree = buildMailboxTree(ownMailboxes);
const visibleItems = flattenVisibleTree(folderTree, expandedFolders);
// While dragging, the folder's subtree travels with it: hide it from the
// list so it can't become its own drop target.
const dragItems = activeDragId ? removeSubtree(visibleItems, activeDragId) : visibleItems;
const projected = activeDragId && overId
? getProjection(dragItems, activeDragId, overId, offsetLeft, INDENTATION_WIDTH)
: null;
const findNode = (nodes: MailboxNode[], id: string): MailboxNode | undefined => {
for (const n of nodes) {
if (n.id === id) return n;
const found = findNode(n.children, id);
if (found) return found;
}
return undefined;
};
const resetDragState = () => {
setActiveDragId(null);
setOverId(null);
setOffsetLeft(0);
};
const handleFolderDragStart = ({ active }: DragStartEvent) => {
setActiveDragId(String(active.id));
setOverId(String(active.id));
};
const handleFolderDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x);
const handleFolderDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null);
const handleFolderDragEnd = (event: DragEndEvent) => {
const { active, over, delta } = event;
resetDragState();
if (!over || !client) return;
const activeId = String(active.id);
const items = removeSubtree(flattenVisibleTree(folderTree, expandedFolders), activeId);
const projection = getProjection(items, activeId, String(over.id), delta.x, INDENTATION_WIDTH);
if (!projection) return;
const plan = getDropPlan(items, activeId, String(over.id), projection);
if (!plan) return;
if (!plan.parentChanged) {
if (active.id === over.id) return; // dropped back where it started
reorderMailboxes(client, plan.orderedSiblingIds).catch(() => {
toast.error(t('reorder_error'));
});
return;
}
const newParent = plan.parentId ? findNode(folderTree, plan.parentId) : null;
if (plan.parentId && !newParent) return;
if (newParent?.myRights && !newParent.myRights.mayCreateChild) {
toast.error(t('move_error'));
return;
}
// If the new parent is collapsed we can't see its children's positions, so
// only reparent (no sibling reorder) and expand it to show the result.
const siblingsVisible = !newParent
|| newParent.children.length === 0
|| expandedFolders.has(newParent.id);
if (newParent) {
setExpandedFolders(prev => new Set(prev).add(newParent.id));
}
moveMailbox(client, activeId, plan.parentId, siblingsVisible ? plan.orderedSiblingIds : undefined).catch(() => {
toast.error(t('move_error'));
});
};
const selectedCount = ownMailboxes.filter(mb => selectedIds.has(mb.id)).length;
// A ticked folder inside another ticked folder travels with it, so only the
// topmost ticked folders get a new parent; the rest keep their place.
const moveRoots: MailboxNode[] = [];
const collectMoveRoots = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (selectedIds.has(n.id)) moveRoots.push(n);
else collectMoveRoots(n.children);
}
};
collectMoveRoots(folderTree);
// A folder can't move into itself or below itself.
const blockedTargets = new Set<string>();
const blockSubtree = (n: MailboxNode) => {
blockedTargets.add(n.id);
n.children.forEach(blockSubtree);
};
moveRoots.forEach(blockSubtree);
const getFolderPath = (mb: { parentId?: string; name: string }): string => {
const parts = [mb.name];
const seen = new Set<string>();
let parentId = mb.parentId;
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
const parent = ownMailboxes.find(p => p.id === parentId);
if (!parent) break;
parts.unshift(parent.name);
parentId = parent.parentId;
}
return parts.join(' / ');
};
const moveTargetOptions = flattenMailboxTree(folderTree)
.filter(mb => !blockedTargets.has(mb.id) && !(mb.myRights && !mb.myRights.mayCreateChild))
.map(mb => ({ value: mb.id, label: getFolderPath(mb) }));
const handleSelectClick = (id: string, e: React.MouseEvent) => {
const tick = !selectedIds.has(id);
let ids = [id];
if (e.shiftKey && selectionAnchor) {
const order = visibleItems.filter(i => i.node.myRights?.mayRename).map(i => i.id);
const from = order.indexOf(selectionAnchor);
const to = order.indexOf(id);
if (from >= 0 && to >= 0) ids = order.slice(Math.min(from, to), Math.max(from, to) + 1);
}
setSelectedIds(prev => {
const next = new Set(prev);
for (const x of ids) {
if (tick) next.add(x);
else next.delete(x);
}
return next;
});
setSelectionAnchor(id);
};
const clearSelection = () => {
setSelectedIds(new Set());
setSelectionAnchor(null);
setMoveTarget('');
};
const handleMoveSelected = async () => {
if (!client || !moveTarget) return;
const parentId = moveTarget === TOP_LEVEL ? null : moveTarget;
const ids = moveRoots.filter(n => (n.parentId ?? null) !== parentId).map(n => n.id);
if (ids.length === 0) {
clearSelection();
return;
}
setIsMoving(true);
try {
const failed = await moveMailboxes(client, ids, parentId);
if (parentId) setExpandedFolders(prev => new Set(prev).add(parentId));
if (failed.length === 0) {
toast.success(t('folders_moved', { count: ids.length }));
clearSelection();
} else {
toast.error(t('move_partial_error', { count: failed.length }));
// Leave the refused folders ticked so they are easy to find.
setSelectedIds(new Set(failed));
}
} catch {
toast.error(t('move_error'));
} finally {
setIsMoving(false);
}
};
const getRoleMailboxId = (role: string): string => {
const mb = ownMailboxes.find(m => m.role === role);
return mb?.id ?? '';
};
const getIconForMailbox = (mb: { id: string; role?: string }): AppIcon => {
// Custom icon takes priority for non-role folders
const customIconName = folderIcons[mb.id];
if (customIconName) {
const found = ICON_CHOICES.find(c => c.name === customIconName);
if (found) return found.icon;
}
// Role folders get their role icon
if (mb.role && ROLE_ICONS[mb.role]) return ROLE_ICONS[mb.role];
return Folder;
};
const getIconName = (mb: { id: string; role?: string }): string => {
if (folderIcons[mb.id]) return folderIcons[mb.id];
if (mb.role && ROLE_ICONS[mb.role]) {
const entry = Object.entries(ROLE_ICONS).find(([r]) => r === mb.role);
if (entry) {
const found = ICON_CHOICES.find(c => c.icon === entry[1]);
if (found) return found.name;
}
}
return 'Folder';
};
const toggleExpanded = (id: string) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const startCreateSubfolder = (parentId: string) => {
setCreatingParentId(parentId);
setIsCreating(true);
setNewFolderName('');
setExpandedFolders(prev => new Set(prev).add(parentId));
};
const handleCreate = async () => {
if (!client || !newFolderName.trim()) return;
setIsLoading(true);
try {
if (!(await createMailbox(client, newFolderName.trim(), creatingParentId ?? undefined))) return;
setNewFolderName('');
setIsCreating(false);
setCreatingParentId(null);
toast.success(t('folder_created'));
} catch {
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
};
const handleRename = async (mailboxId: string) => {
if (!client || !editingName.trim()) return;
setIsLoading(true);
try {
if (!(await renameMailbox(client, mailboxId, editingName.trim()))) return;
setEditingId(null);
setEditingName('');
toast.success(t('folder_renamed'));
} catch {
toast.error(t('error_rename'));
} finally {
setIsLoading(false);
}
};
const handleDelete = async (mailboxId: string) => {
if (!client) return;
setIsLoading(true);
try {
if (!(await deleteMailbox(client, mailboxId))) return;
setDeletingId(null);
toast.success(t('folder_deleted'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
switch (jmapType) {
case 'mailboxHasChild':
toast.error(t('error_delete_has_children'));
break;
case 'mailboxHasEmail':
toast.error(t('error_delete_has_email'));
break;
default:
toast.error(t('error_delete'));
}
} finally {
setIsLoading(false);
}
};
const handleRoleChange = async (role: string, mailboxId: string) => {
if (!client) return;
setIsLoading(true);
try {
if (mailboxId === '') {
const current = ownMailboxes.find(m => m.role === role);
if (current) {
await setMailboxRole(client, current.id, null);
}
} else {
await setMailboxRole(client, mailboxId, role);
}
toast.success(t('role_updated'));
} catch {
toast.error(t('error_role'));
} finally {
setIsLoading(false);
}
};
const startEdit = (mb: { id: string; name: string }) => {
setEditingId(mb.id);
setEditingName(mb.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
const renderCreateInline = (parentId: string | null, depth: number) => {
if (!isCreating || creatingParentId !== parentId) return null;
const parentName = parentId ? ownMailboxes.find(m => m.id === parentId)?.name : null;
return (
<div
className="flex items-center gap-2 mt-1 p-2.5 bg-muted/30 rounded-md border border-border"
style={{ marginLeft: depth * 16 }}
>
<FolderPlus className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 flex flex-col gap-1">
{parentName && (
<span className="text-xs text-muted-foreground">
{t('subfolder_of', { name: parentName })}
</span>
)}
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') {
setIsCreating(false);
setNewFolderName('');
setCreatingParentId(null);
}
}}
placeholder={parentId ? t('subfolder_name') : t('new_folder_name')}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<button
onClick={handleCreate}
disabled={isLoading || !newFolderName.trim()}
className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('create')}
</button>
<button
onClick={() => {
setIsCreating(false);
setNewFolderName('');
setCreatingParentId(null);
}}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
};
const renderFolderNode = (item: FlatFolder): React.ReactNode => {
const mb = item.node;
const hasChildren = mb.children.length > 0;
const isExpanded = expandedFolders.has(item.id);
const isSelected = selectedIds.has(item.id);
// The dragged row previews its projected nesting depth, so a horizontal
// drag shows which parent the folder will land under.
const depth = activeDragId === item.id && projected ? projected.depth : item.depth;
const Icon = getIconForMailbox(mb);
if (editingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-2 py-2 px-3" style={{ paddingLeft: 12 + depth * 16 }}>
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(mb.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
<button
onClick={() => handleRename(mb.id)}
disabled={isLoading || !editingName.trim()}
className="p-1.5 text-primary hover:bg-accent rounded-md disabled:opacity-50"
title={t('rename')}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 text-muted-foreground hover:bg-accent rounded-md"
title={t('cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
if (deletingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20" style={{ marginLeft: depth * 16 }}>
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: mb.name })}
</p>
<button
onClick={() => handleDelete(mb.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
return (
<div key={mb.id}>
<SortableFolderRow id={mb.id} title={t('reorder')}>
<div
className={cn(
"flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50",
isSelected && "bg-muted",
)}
style={{ paddingLeft: 12 + depth * 16 }}
>
<div className="flex items-center gap-2.5 min-w-0">
{mb.myRights?.mayRename ? (
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t('select_folder', { name: mb.name })}
title={t('select_hint')}
// Keep Shift+click from selecting the page text in between.
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
onClick={(e) => handleSelectClick(mb.id, e)}
className={cn(
"p-0.5 rounded flex-shrink-0 transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected ? "text-primary" : "text-muted-foreground",
)}
>
{isSelected
? <CheckSquare className="w-4 h-4" />
: <Square className="w-4 h-4 opacity-60" />
}
</button>
) : (
<span className="w-5 flex-shrink-0" />
)}
{/* Expand/collapse toggle for folders with children */}
{hasChildren ? (
<button
onClick={() => toggleExpanded(mb.id)}
className="p-0.5 text-muted-foreground hover:text-foreground rounded transition-colors flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</button>
) : (
<span className="w-4.5 flex-shrink-0" />
)}
<div className="relative flex-shrink-0">
{folderIconsAllowed ? (
<button
onClick={() => setIconPickerId(iconPickerId === mb.id ? null : mb.id)}
className={cn(
"p-1 rounded-md transition-colors",
iconPickerId === mb.id
? "bg-accent"
: "hover:bg-accent"
)}
title={t('change_icon')}
>
<Icon className={cn(
"w-4 h-4",
mb.role ? "text-primary" : "text-muted-foreground"
)} />
</button>
) : (
<span className="p-1">
<Icon className={cn(
"w-4 h-4",
mb.role ? "text-primary" : "text-muted-foreground"
)} />
</span>
)}
{folderIconsAllowed && iconPickerId === mb.id && (
<IconPicker
currentIcon={getIconName(mb)}
onSelect={(iconName) => {
setFolderIcon(mb.id, iconName);
setIconPickerId(null);
}}
onClose={() => setIconPickerId(null)}
/>
)}
</div>
<span className="text-sm text-foreground truncate">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{t(`role_${mb.role}`)}
</span>
)}
{mb.unreadEmails > 0 && (
<span className="text-xs tabular-nums px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium flex-shrink-0">
{mb.unreadEmails}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<Select
value={folderCountModes?.[mb.id] ?? ''}
onChange={(value) => {
const { [mb.id]: _, ...rest } = folderCountModes ?? {};
updateSetting('folderCountModes', isFolderCountMode(value) ? { ...rest, [mb.id]: value } : rest);
}}
ariaLabel={t('count_mode', { name: mb.name })}
className="px-1.5 py-0.5 text-xs max-w-[9rem]"
options={[
{ value: '', label: t('count_mode_default') },
{ value: 'unread', label: t('count_mode_unread') },
{ value: 'all', label: t('count_mode_all') },
{ value: 'none', label: t('count_mode_none') },
]}
/>
{mb.myRights?.mayCreateChild && (
<button
onClick={() => startCreateSubfolder(mb.id)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('create_subfolder')}
>
<FolderPlus className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayRename && (
<button
onClick={() => startEdit(mb)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('rename')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayDelete && !mb.role && (
<button
onClick={() => setDeletingId(mb.id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</SortableFolderRow>
{/* Inline subfolder creation (children follow as flattened rows) */}
{renderCreateInline(mb.id, depth + 1)}
</div>
);
};
return (
<div className="space-y-8">
{/* Folder List - primary section */}
<SettingsSection id="folders" title={t('folder_list')} description={t('folder_list_description')}>
{selectedCount > 0 && (
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 p-2.5 rounded-md border border-border bg-background">
<span className="text-sm text-foreground me-auto">
{t('selected_count', { count: selectedCount })}
</span>
<Select
value={moveTarget}
onChange={setMoveTarget}
ariaLabel={t('move_to')}
disabled={isMoving}
className="min-w-0 max-w-full"
options={[
{ value: '', label: t('move_to') },
{ value: TOP_LEVEL, label: t('move_to_top_level') },
...moveTargetOptions,
]}
/>
<button
onClick={handleMoveSelected}
disabled={isMoving || !moveTarget}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('move')}
</button>
<button
onClick={clearSelection}
disabled={isMoving}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent disabled:opacity-50"
>
{t('clear_selection')}
</button>
</div>
)}
<div className="space-y-0.5">
{folderTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Folder className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
onDragStart={handleFolderDragStart}
onDragMove={handleFolderDragMove}
onDragOver={handleFolderDragOver}
onDragEnd={handleFolderDragEnd}
onDragCancel={resetDragState}
>
<SortableContext items={dragItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
{dragItems.map(item => renderFolderNode(item))}
</SortableContext>
</DndContext>
)}
</div>
{/* Create top-level folder */}
{renderCreateInline(null, 0)}
{!isCreating && (
<button
onClick={() => { setIsCreating(true); setCreatingParentId(null); setNewFolderName(''); }}
className="flex items-center gap-2 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-md transition-colors w-full border border-dashed border-primary/30 hover:border-primary/50"
>
<Plus className="w-4 h-4" />
{t('create_folder')}
</button>
)}
</SettingsSection>
{/* Standard Folder Roles - advanced section */}
<SettingsSection id="roles" advanced title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => {
// Offer the folders in sidebar order (the built tree: role priority,
// then name, children under their parent) rather than raw server
// order, so the dropdown reads like the folder list. (#984)
const orderedMailboxes = flattenMailboxTree(folderTree);
// Disambiguate duplicate folder names by appending parent path
const nameCounts = new Map<string, number>();
orderedMailboxes.forEach(mb => nameCounts.set(mb.name, (nameCounts.get(mb.name) || 0) + 1));
const getParentPath = (mb: { parentId?: string; name: string }) => {
if (!mb.parentId) return '';
const parent = ownMailboxes.find(p => p.id === mb.parentId);
return parent ? `${parent.name}/` : '';
};
return (
<SettingItem key={role} label={t(`role_${role}`)}>
<Select
value={getRoleMailboxId(role)}
onChange={(value) => handleRoleChange(role, value)}
options={[
{ value: '', label: t('role_none') },
...orderedMailboxes.map(mb => ({
value: mb.id,
label: (nameCounts.get(mb.name) || 0) > 1
? `${getParentPath(mb)}${mb.name} (${mb.id.slice(-6)})`
: mb.name,
})),
]}
/>
</SettingItem>
);
})}
</SettingsSection>
</div>
);
}