feat(dashboard): make widget groups reorderable via drag-and-drop

This commit is contained in:
Matthias Hochmeister
2026-04-13 15:15:50 +02:00
parent dd5cd71fd1
commit b275d4baa5
2 changed files with 136 additions and 34 deletions

View File

@@ -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 (
<Box
ref={setNodeRef}
ref={setSortableRef}
style={sortableStyle}
sx={{
position: 'relative',
borderRadius: 2,
p: 2.5,
pt: 3.5,
gridColumn,
gridColumn: '1 / -1',
bgcolor: isOver && editMode ? 'rgba(25, 118, 210, 0.04)' : 'rgba(0, 0, 0, 0.02)',
border: '1px solid',
borderColor: isOver && editMode ? 'primary.light' : 'rgba(0, 0, 0, 0.04)',
@@ -40,6 +63,7 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
}}
>
<Box
ref={setDropRef}
sx={{
position: 'absolute',
top: -9,
@@ -53,6 +77,16 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
borderRadius: 1,
}}
>
{editMode && sortableId && (
<IconButton
size="small"
{...attributes}
{...listeners}
sx={{ p: 0, mr: 0.25, color: 'text.disabled', cursor: 'grab', '&:active': { cursor: 'grabbing' } }}
>
<DragIndicatorIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
<Typography
sx={{
fontSize: '0.6875rem',

View File

@@ -29,6 +29,7 @@ import type { DragEndEvent, DragStartEvent, DragOverEvent } from '@dnd-kit/core'
import {
SortableContext,
rectSortingStrategy,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { useAuth } from '../contexts/AuthContext';
@@ -100,6 +101,7 @@ function Dashboard() {
const [addGroupOpen, setAddGroupOpen] = useState(false);
const [newGroupTitle, setNewGroupTitle] = useState('');
const [resetOpen, setResetOpen] = useState(false);
const [localGroupOrder, setLocalGroupOrder] = useState<string[]>(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 (
<WidgetGroup title={title} gridColumn="1 / -1" key="information" groupId="information" editMode={editMode}>
<WidgetGroup title={title} gridColumn="1 / -1" key="information" groupId="information" sortableId="group:information" editMode={editMode}>
{linksVisible && linkCollections.map((collection) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box>
@@ -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 (
<DashboardLayout>
@@ -470,20 +515,43 @@ function Dashboard() {
</Box>
)}
{groupOrder.map((g) => renderGroup(g.name, g.title))}
<SortableContext
items={groupOrder.map(g => `group:${g.name}`)}
strategy={verticalListSortingStrategy}
>
{groupOrder.map((g) => renderGroup(g.name, g.title))}
</SortableContext>
</Box>
<DragOverlay>
{activeId ? (
<Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
{(() => {
for (const defs of Object.values(widgetDefs)) {
const def = defs.find((d) => d.key === activeId);
if (def?.component) return def.component;
}
return null;
})()}
</Box>
activeId.startsWith('group:') ? (
<Box sx={{
opacity: 0.7,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'primary.light',
borderRadius: 2,
p: 2,
pointerEvents: 'none',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'text.secondary' }}>
{groupOrder.find(g => `group:${g.name}` === activeId)?.title ?? activeId}
</Box>
</Box>
</Box>
) : (
<Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
{(() => {
for (const defs of Object.values(widgetDefs)) {
const def = defs.find((d) => d.key === activeId);
if (def?.component) return def.component;
}
return null;
})()}
</Box>
)
) : null}
</DragOverlay>
</DndContext>