feat(dashboard): make widget groups reorderable via drag-and-drop
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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,20 +515,43 @@ function Dashboard() {
|
|||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
|
activeId.startsWith('group:') ? (
|
||||||
{(() => {
|
<Box sx={{
|
||||||
for (const defs of Object.values(widgetDefs)) {
|
opacity: 0.7,
|
||||||
const def = defs.find((d) => d.key === activeId);
|
bgcolor: 'background.paper',
|
||||||
if (def?.component) return def.component;
|
border: '1px solid',
|
||||||
}
|
borderColor: 'primary.light',
|
||||||
return null;
|
borderRadius: 2,
|
||||||
})()}
|
p: 2,
|
||||||
</Box>
|
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}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|||||||
Reference in New Issue
Block a user