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 React from 'react';
import { Box, Typography, IconButton } from '@mui/material'; 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 { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface WidgetGroupProps { interface WidgetGroupProps {
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
gridColumn?: string; gridColumn?: string;
groupId?: string; groupId?: string;
sortableId?: string;
editMode?: boolean; editMode?: boolean;
onDelete?: () => void; onDelete?: () => void;
} }
function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete }: WidgetGroupProps) { function WidgetGroup({ title, children, groupId, sortableId, editMode, onDelete }: WidgetGroupProps) {
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef: setDropRef, isOver } = useDroppable({
id: groupId ? `group-${groupId}` : 'group-default', id: groupId ? `group-${groupId}` : 'group-default',
disabled: !editMode, disabled: !editMode,
}); });
const {
setNodeRef: setSortableRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId ?? '',
disabled: !editMode || !sortableId,
});
// Count non-null children to hide empty groups // Count non-null children to hide empty groups
const validChildren = React.Children.toArray(children).filter(Boolean); const validChildren = React.Children.toArray(children).filter(Boolean);
if (validChildren.length === 0 && !editMode) return null; 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 ( return (
<Box <Box
ref={setNodeRef} ref={setSortableRef}
style={sortableStyle}
sx={{ sx={{
position: 'relative', position: 'relative',
borderRadius: 2, borderRadius: 2,
p: 2.5, p: 2.5,
pt: 3.5, pt: 3.5,
gridColumn, gridColumn: '1 / -1',
bgcolor: isOver && editMode ? 'rgba(25, 118, 210, 0.04)' : 'rgba(0, 0, 0, 0.02)', bgcolor: isOver && editMode ? 'rgba(25, 118, 210, 0.04)' : 'rgba(0, 0, 0, 0.02)',
border: '1px solid', border: '1px solid',
borderColor: isOver && editMode ? 'primary.light' : 'rgba(0, 0, 0, 0.04)', borderColor: isOver && editMode ? 'primary.light' : 'rgba(0, 0, 0, 0.04)',
@@ -40,6 +63,7 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
}} }}
> >
<Box <Box
ref={setDropRef}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -9, top: -9,
@@ -53,6 +77,16 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
borderRadius: 1, 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 <Typography
sx={{ sx={{
fontSize: '0.6875rem', fontSize: '0.6875rem',

View File

@@ -29,6 +29,7 @@ import type { DragEndEvent, DragStartEvent, DragOverEvent } from '@dnd-kit/core'
import { import {
SortableContext, SortableContext,
rectSortingStrategy, rectSortingStrategy,
verticalListSortingStrategy,
arrayMove, arrayMove,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -100,6 +101,7 @@ function Dashboard() {
const [addGroupOpen, setAddGroupOpen] = useState(false); const [addGroupOpen, setAddGroupOpen] = useState(false);
const [newGroupTitle, setNewGroupTitle] = useState(''); const [newGroupTitle, setNewGroupTitle] = useState('');
const [resetOpen, setResetOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false);
const [localGroupOrder, setLocalGroupOrder] = useState<string[]>(BUILTIN_GROUPS.map(g => g.name));
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -185,8 +187,20 @@ function Dashboard() {
} }
return merged; 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 // Flat map of all widget defs for cross-group lookup
const allWidgetDefs = useMemo(() => { const allWidgetDefs = useMemo(() => {
@@ -226,6 +240,9 @@ function Dashboard() {
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
// Skip group-level drags
if ((active.id as string).startsWith('group:')) return;
const activeWidget = active.id as string; const activeWidget = active.id as string;
const overId = over.id as string; const overId = over.id as string;
@@ -260,15 +277,32 @@ function Dashboard() {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const activeWidget = active.id as string; const activeIdStr = active.id as string;
const overId = over.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); const sourceGroup = findGroupForWidget(activeWidget);
let targetGroup: string | undefined; let targetGroup: string | undefined;
if (overId.startsWith('group-')) { if (overIdStr.startsWith('group-')) {
targetGroup = overId.replace('group-', ''); targetGroup = overIdStr.replace('group-', '');
} else { } else {
targetGroup = findGroupForWidget(overId); targetGroup = findGroupForWidget(overIdStr);
} }
if (!sourceGroup || !targetGroup) return; if (!sourceGroup || !targetGroup) return;
@@ -277,7 +311,7 @@ function Dashboard() {
// Same group reorder // Same group reorder
const order = localOrder[sourceGroup]; const order = localOrder[sourceGroup];
const oldIndex = order.indexOf(activeWidget); const oldIndex = order.indexOf(activeWidget);
const newIndex = order.indexOf(overId); const newIndex = order.indexOf(overIdStr);
if (oldIndex !== -1 && newIndex !== -1) { if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(order, oldIndex, newIndex); const newOrder = arrayMove(order, oldIndex, newIndex);
setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder })); setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder }));
@@ -295,7 +329,7 @@ function Dashboard() {
return current; return current;
}); });
}, 0); }, 0);
}, [localOrder, queryClient, findGroupForWidget]); }, [localOrder, localGroupOrder, queryClient, findGroupForWidget]);
// ── Add custom group ── // ── Add custom group ──
const handleAddGroup = useCallback(() => { const handleAddGroup = useCallback(() => {
@@ -309,8 +343,10 @@ function Dashboard() {
const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }]; const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }];
const updatedOrder = { ...localOrder, [name]: [] }; const updatedOrder = { ...localOrder, [name]: [] };
const updatedGroupOrder = [...localGroupOrder, name];
setLocalOrder(updatedOrder); 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'] }); queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
}); });
setNewGroupTitle(''); setNewGroupTitle('');
@@ -334,16 +370,20 @@ function Dashboard() {
} }
const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName); const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName);
const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName);
setLocalOrder(updatedOrder); 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'] }); queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
}); });
}, [localOrder, preferences?.customGroups, queryClient]); }, [localOrder, localGroupOrder, preferences?.customGroups, queryClient]);
// ── Reset to defaults ── // ── Reset to defaults ──
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
const defaultGroupOrder = BUILTIN_GROUPS.map(g => g.name);
setLocalOrder(DEFAULT_ORDER); 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'] }); queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
}); });
setResetOpen(false); setResetOpen(false);
@@ -374,7 +414,7 @@ function Dashboard() {
if (!hasContent) return null; if (!hasContent) return null;
return ( 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) => ( {linksVisible && linkCollections.map((collection) => (
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}> <Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
<Box> <Box>
@@ -401,6 +441,7 @@ function Dashboard() {
gridColumn="1 / -1" gridColumn="1 / -1"
key={group} key={group}
groupId={group} groupId={group}
sortableId={`group:${group}`}
editMode={editMode} editMode={editMode}
onDelete={isCustomGroup ? () => handleDeleteGroup(group) : undefined} 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 groupOrder = useMemo(() => {
const groups = [...BUILTIN_GROUPS]; const allGroups = [...BUILTIN_GROUPS];
if (preferences?.customGroups) { if (preferences?.customGroups) {
for (const cg of preferences.customGroups as { name: string; title: string }[]) { for (const cg of preferences.customGroups as { name: string; title: string }[]) {
if (!groups.some((g) => g.name === cg.name)) { if (!allGroups.some((g) => g.name === cg.name)) {
groups.push(cg); allGroups.push(cg);
} }
} }
} }
return groups; const orderedGroups = localGroupOrder
}, [preferences?.customGroups]); .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 ( return (
<DashboardLayout> <DashboardLayout>
@@ -470,11 +515,33 @@ function Dashboard() {
</Box> </Box>
)} )}
<SortableContext
items={groupOrder.map(g => `group:${g.name}`)}
strategy={verticalListSortingStrategy}
>
{groupOrder.map((g) => renderGroup(g.name, g.title))} {groupOrder.map((g) => renderGroup(g.name, g.title))}
</SortableContext>
</Box> </Box>
<DragOverlay> <DragOverlay>
{activeId ? ( {activeId ? (
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' }}> <Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
{(() => { {(() => {
for (const defs of Object.values(widgetDefs)) { for (const defs of Object.values(widgetDefs)) {
@@ -484,6 +551,7 @@ function Dashboard() {
return null; return null;
})()} })()}
</Box> </Box>
)
) : null} ) : null}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>