From b275d4baa5f8ad45ea40ff0507ab19f13423272a Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 13 Apr 2026 15:15:50 +0200 Subject: [PATCH] feat(dashboard): make widget groups reorderable via drag-and-drop --- .../src/components/dashboard/WidgetGroup.tsx | 44 +++++- frontend/src/pages/Dashboard.tsx | 126 ++++++++++++++---- 2 files changed, 136 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/dashboard/WidgetGroup.tsx b/frontend/src/components/dashboard/WidgetGroup.tsx index 0b58aec..ae08a38 100644 --- a/frontend/src/components/dashboard/WidgetGroup.tsx +++ b/frontend/src/components/dashboard/WidgetGroup.tsx @@ -1,37 +1,60 @@ import React from 'react'; import { Box, Typography, IconButton } from '@mui/material'; -import { Delete as DeleteIcon } from '@mui/icons-material'; +import { Delete as DeleteIcon, DragIndicator as DragIndicatorIcon } from '@mui/icons-material'; import { useDroppable } from '@dnd-kit/core'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; interface WidgetGroupProps { title: string; children: React.ReactNode; gridColumn?: string; groupId?: string; + sortableId?: string; editMode?: boolean; onDelete?: () => void; } -function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete }: WidgetGroupProps) { - const { setNodeRef, isOver } = useDroppable({ +function WidgetGroup({ title, children, groupId, sortableId, editMode, onDelete }: WidgetGroupProps) { + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: groupId ? `group-${groupId}` : 'group-default', disabled: !editMode, }); + const { + setNodeRef: setSortableRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ + id: sortableId ?? '', + disabled: !editMode || !sortableId, + }); + // Count non-null children to hide empty groups const validChildren = React.Children.toArray(children).filter(Boolean); if (validChildren.length === 0 && !editMode) return null; + const sortableStyle = sortableId ? { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 1 : undefined, + } : {}; + return ( + {editMode && sortableId && ( + + + + )} (BUILTIN_GROUPS.map(g => g.name)); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -185,8 +187,20 @@ function Dashboard() { } return merged; }); + + // Sync group order from preferences + const customGroupNames = (preferences.customGroups as { name: string; title: string }[] | undefined ?? []).map(g => g.name); + const allGroupNames = [...BUILTIN_GROUPS.map(g => g.name), ...customGroupNames]; + const savedGroupOrder = preferences.groupsOrder as string[] | undefined; + if (savedGroupOrder) { + const ordered = savedGroupOrder.filter((n: string) => allGroupNames.includes(n)); + const remaining = allGroupNames.filter(n => !ordered.includes(n)); + setLocalGroupOrder([...ordered, ...remaining]); + } else { + setLocalGroupOrder(allGroupNames); + } } - }, [preferences?.widgetOrder, preferences?.customGroups]); + }, [preferences?.widgetOrder, preferences?.customGroups, preferences?.groupsOrder]); // Flat map of all widget defs for cross-group lookup const allWidgetDefs = useMemo(() => { @@ -226,6 +240,9 @@ function Dashboard() { const { active, over } = event; if (!over) return; + // Skip group-level drags + if ((active.id as string).startsWith('group:')) return; + const activeWidget = active.id as string; const overId = over.id as string; @@ -260,15 +277,32 @@ function Dashboard() { const { active, over } = event; if (!over || active.id === over.id) return; - const activeWidget = active.id as string; - const overId = over.id as string; + const activeIdStr = active.id as string; + const overIdStr = over.id as string; + + // Group reorder + if (activeIdStr.startsWith('group:') && overIdStr.startsWith('group:')) { + const sourceName = activeIdStr.replace('group:', ''); + const targetName = overIdStr.replace('group:', ''); + const oldIndex = localGroupOrder.indexOf(sourceName); + const newIndex = localGroupOrder.indexOf(targetName); + if (oldIndex === -1 || newIndex === -1) return; + const newOrder = arrayMove(localGroupOrder, oldIndex, newIndex); + setLocalGroupOrder(newOrder); + preferencesApi.update({ widgetOrder: localOrder, groupsOrder: newOrder }).then(() => { + queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); + }); + return; + } + + const activeWidget = activeIdStr; const sourceGroup = findGroupForWidget(activeWidget); let targetGroup: string | undefined; - if (overId.startsWith('group-')) { - targetGroup = overId.replace('group-', ''); + if (overIdStr.startsWith('group-')) { + targetGroup = overIdStr.replace('group-', ''); } else { - targetGroup = findGroupForWidget(overId); + targetGroup = findGroupForWidget(overIdStr); } if (!sourceGroup || !targetGroup) return; @@ -277,7 +311,7 @@ function Dashboard() { // Same group reorder const order = localOrder[sourceGroup]; const oldIndex = order.indexOf(activeWidget); - const newIndex = order.indexOf(overId); + const newIndex = order.indexOf(overIdStr); if (oldIndex !== -1 && newIndex !== -1) { const newOrder = arrayMove(order, oldIndex, newIndex); setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder })); @@ -295,7 +329,7 @@ function Dashboard() { return current; }); }, 0); - }, [localOrder, queryClient, findGroupForWidget]); + }, [localOrder, localGroupOrder, queryClient, findGroupForWidget]); // ── Add custom group ── const handleAddGroup = useCallback(() => { @@ -309,8 +343,10 @@ function Dashboard() { const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }]; const updatedOrder = { ...localOrder, [name]: [] }; + const updatedGroupOrder = [...localGroupOrder, name]; setLocalOrder(updatedOrder); - preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => { + setLocalGroupOrder(updatedGroupOrder); + preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => { queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); }); setNewGroupTitle(''); @@ -334,16 +370,20 @@ function Dashboard() { } const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName); + const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName); setLocalOrder(updatedOrder); - preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => { + setLocalGroupOrder(updatedGroupOrder); + preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => { queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); }); - }, [localOrder, preferences?.customGroups, queryClient]); + }, [localOrder, localGroupOrder, preferences?.customGroups, queryClient]); // ── Reset to defaults ── const handleReset = useCallback(() => { + const defaultGroupOrder = BUILTIN_GROUPS.map(g => g.name); setLocalOrder(DEFAULT_ORDER); - preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [] }).then(() => { + setLocalGroupOrder(defaultGroupOrder); + preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [], groupsOrder: defaultGroupOrder }).then(() => { queryClient.invalidateQueries({ queryKey: ['user-preferences'] }); }); setResetOpen(false); @@ -374,7 +414,7 @@ function Dashboard() { if (!hasContent) return null; return ( - + {linksVisible && linkCollections.map((collection) => ( @@ -401,6 +441,7 @@ function Dashboard() { gridColumn="1 / -1" key={group} groupId={group} + sortableId={`group:${group}`} editMode={editMode} onDelete={isCustomGroup ? () => handleDeleteGroup(group) : undefined} > @@ -420,18 +461,22 @@ function Dashboard() { ); }; - // Compute the full group list: built-in + custom groups from preferences + // Compute the full group list: built-in + custom groups, ordered by localGroupOrder const groupOrder = useMemo(() => { - const groups = [...BUILTIN_GROUPS]; + const allGroups = [...BUILTIN_GROUPS]; if (preferences?.customGroups) { for (const cg of preferences.customGroups as { name: string; title: string }[]) { - if (!groups.some((g) => g.name === cg.name)) { - groups.push(cg); + if (!allGroups.some((g) => g.name === cg.name)) { + allGroups.push(cg); } } } - return groups; - }, [preferences?.customGroups]); + const orderedGroups = localGroupOrder + .map(name => allGroups.find(g => g.name === name)) + .filter((g): g is { name: string; title: string } => !!g); + const remaining = allGroups.filter(g => !localGroupOrder.includes(g.name)); + return [...orderedGroups, ...remaining]; + }, [preferences?.customGroups, localGroupOrder]); return ( @@ -470,20 +515,43 @@ function Dashboard() { )} - {groupOrder.map((g) => renderGroup(g.name, g.title))} + `group:${g.name}`)} + strategy={verticalListSortingStrategy} + > + {groupOrder.map((g) => renderGroup(g.name, g.title))} + {activeId ? ( - - {(() => { - for (const defs of Object.values(widgetDefs)) { - const def = defs.find((d) => d.key === activeId); - if (def?.component) return def.component; - } - return null; - })()} - + activeId.startsWith('group:') ? ( + + + + {groupOrder.find(g => `group:${g.name}` === activeId)?.title ?? activeId} + + + + ) : ( + + {(() => { + for (const defs of Object.values(widgetDefs)) { + const def = defs.find((d) => d.key === activeId); + if (def?.component) return def.component; + } + return null; + })()} + + ) ) : null}