"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, type LucideIcon, } from 'lucide-react'; import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils'; 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; const ROLE_ICONS: Record = { 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: LucideIcon }[] = [ { 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(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 (
{ICON_CHOICES.map(({ name, icon: Icon }) => ( ))}
); } /** * 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, 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'); useEffect(() => { if (client && mailboxes.length === 0) { fetchMailboxes(client); } }, [client, mailboxes.length, fetchMailboxes]); const [isCreating, setIsCreating] = useState(false); const [creatingParentId, setCreatingParentId] = useState(null); const [newFolderName, setNewFolderName] = useState(''); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); const [deletingId, setDeletingId] = useState(null); const [iconPickerId, setIconPickerId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [expandedFolders, setExpandedFolders] = useState>(new Set()); 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 ?? ''; }; const getIconForMailbox = (mb: { id: string; role?: string }): LucideIcon => { // 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 { await createMailbox(client, newFolderName.trim(), creatingParentId ?? undefined); 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 { await renameMailbox(client, mailboxId, editingName.trim()); 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 { await deleteMailbox(client, mailboxId); 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 (
{parentName && ( {t('subfolder_of', { name: parentName })} )} 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} />
); }; const renderFolderNode = (node: MailboxNode): React.ReactNode => { const mb = node; const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); const depth = node.depth; const Icon = getIconForMailbox(mb); if (editingId === mb.id) { return (
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} />
); } if (deletingId === mb.id) { return (

{t('confirm_delete', { name: mb.name })}

); } return (
{/* Expand/collapse toggle for folders with children */} {hasChildren ? ( ) : ( )}
{folderIconsAllowed ? ( ) : ( )} {folderIconsAllowed && iconPickerId === mb.id && ( { setFolderIcon(mb.id, iconName); setIconPickerId(null); }} onClose={() => setIconPickerId(null)} /> )}
{mb.name} {mb.role && ( {t(`role_${mb.role}`)} )} {mb.unreadEmails > 0 && ( {mb.unreadEmails} )}
{mb.myRights?.mayCreateChild && ( )} {mb.myRights?.mayRename && ( )} {mb.myRights?.mayDelete && !mb.role && ( )}
{/* Inline subfolder creation */} {renderCreateInline(mb.id, depth + 1)} {/* Render children if expanded */} {hasChildren && isExpanded && (
c.id)} strategy={verticalListSortingStrategy}> {node.children.map(child => renderFolderNode(child))}
)}
); }; return (
{/* Folder List - primary section */}
{folderTree.length === 0 ? (

{t('no_folders')}

) : ( n.id)} strategy={verticalListSortingStrategy}> {folderTree.map(node => renderFolderNode(node))} )}
{/* Create top-level folder */} {renderCreateInline(null, 0)} {!isCreating && ( )}
{/* Standard Folder Roles - advanced section */} {STANDARD_ROLES.map((role) => { // Disambiguate duplicate folder names by appending parent path const nameCounts = new Map(); ownMailboxes.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 (