Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.1.1",
"@dnd-kit/core": "^7.1.0",
"@dnd-kit/sortable": "^7.1.0",
"@dnd-kit/utilities": "^3.2.2",
"@reduxjs/toolkit": "^2.8.2",
"axios": "^1.9.0",
"crypto-js": "^4.2.0",
Expand Down
226 changes: 144 additions & 82 deletions src/renderer/src/components/DeviceListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import IconButton from "@mui/material/IconButton";
import { motion, AnimatePresence } from "framer-motion";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, rectSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

import { AppDispatch } from "../store/store";
import {
Expand All @@ -28,7 +40,7 @@ import { shouldHideDevice } from "../deviceDefinitions";
import { useTranslation } from "../useTranslation";
import SwapVertIcon from "@mui/icons-material/SwapVert";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { AnyDevice } from "../../../api/types";
import { AnyDevice, DeviceStatusResponseBody } from "../../../api/types";

// Animation variants
const containerVariants = {
Expand Down Expand Up @@ -68,6 +80,10 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
const [reorderList, setReorderList] = useState<string[]>([]);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { pressDelay: 150, pressThreshold: 5 })
);

const orderedDevices = useMemo(() => {
const idToDevice = new Map<string, AnyDevice>();
Expand Down Expand Up @@ -177,10 +193,6 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
};
}, [draggingId]);

const handleReorder = (visibleOrder: string[]) => {
setReorderList(visibleOrder);
};

const moveIdInList = (list: string[], fromId: string, toId: string) => {
if (fromId === toId) return list;
const fromIndex = list.indexOf(fromId);
Expand All @@ -192,37 +204,34 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
return next;
};

const handleDragStart = (id: string) => (event: React.DragEvent<HTMLDivElement>) => {
setDraggingId(id);
const handleDragStart = (event: DragStartEvent) => {
if (!isReorderMode) return;
setDraggingId(String(event.active.id));
setDragOverId(null);
event.dataTransfer.effectAllowed = "move";
};

const handleDragOver = (overId: string) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (!draggingId || draggingId === overId) return;

// Only track hover state, don't update the list during drag
setDragOverId(overId);
};

const handleDragLeave = () => {
setDragOverId(null);
const handleDragOver = (event: DragOverEvent) => {
if (!isReorderMode) return;
const overId = event.over?.id;
if (!overId || !draggingId || draggingId === overId) return;
setDragOverId(String(overId));
};

const handleDrop = (overId: string) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();

if (draggingId && overId && draggingId !== overId) {
// Update the list only once on drop
setReorderList((prev) => moveIdInList(prev, draggingId, overId));
const handleDragEnd = (event: DragEndEvent) => {
if (!isReorderMode) return;
const { active, over } = event;
if (active?.id && over?.id) {
const activeId = String(active.id);
const overId = String(over.id);
if (activeId !== overId) {
setReorderList((prev) => moveIdInList(prev, activeId, overId));
}
}

setDraggingId(null);
setDragOverId(null);
};

const handleDragEnd = () => {
const handleDragCancel = () => {
setDraggingId(null);
setDragOverId(null);
};
Expand Down Expand Up @@ -322,64 +331,117 @@ export const DeviceListScreen: React.FC<{ onDeviceSelect: (deviceId: string) =>
</Box>
)}

<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<Box sx={gridSx}>
<AnimatePresence>
{(isReorderMode
<SortableContext
items={
isReorderMode
? reorderList
.map((deviceId) => filteredDeviceMap.get(deviceId))
.filter((device): device is AnyDevice => !!device)
: filteredDevices
).map((device) => (
<motion.div variants={itemVariants} layout key={device.deviceId}>
<Box
sx={{
position: "relative",
opacity: draggingId === device.deviceId ? 0.5 : 1,
transition: "opacity 0.2s ease",
outline: dragOverId === device.deviceId ? "2px solid" : "none",
outlineColor: "primary.main",
outlineOffset: "2px",
borderRadius: 2,
}}
draggable={isReorderMode}
onDragStart={isReorderMode ? handleDragStart(device.deviceId) : undefined}
onDragOver={isReorderMode ? handleDragOver(device.deviceId) : undefined}
onDragLeave={isReorderMode ? handleDragLeave : undefined}
onDrop={isReorderMode ? handleDrop(device.deviceId) : undefined}
onDragEnd={isReorderMode ? handleDragEnd : undefined}
>
{isReorderMode && (
<IconButton
size="small"
sx={{
position: "absolute",
top: 8,
right: 8,
bgcolor: "background.paper",
boxShadow: 1,
pointerEvents: "none",
}}
aria-label={t("Reorder")}
>
<DragIndicatorIcon fontSize="small" />
</IconButton>
)}
<DeviceCard
device={device}
status={statusMap[device.deviceId]}
onSelect={onDeviceSelect}
/>
</Box>
</motion.div>
))}
</AnimatePresence>
</Box>
</motion.div>
: filteredDevices.map((device) => device.deviceId)
}
strategy={rectSortingStrategy}
>
<motion.div variants={containerVariants} initial="hidden" animate="visible">
<Box sx={gridSx}>
<AnimatePresence>
{(isReorderMode
? reorderList
.map((deviceId) => filteredDeviceMap.get(deviceId))
.filter((device): device is AnyDevice => !!device)
: filteredDevices
).map((device) => (
<motion.div variants={itemVariants} layout key={device.deviceId}>
<SortableDeviceCard
device={device}
isReorderMode={isReorderMode}
draggingId={draggingId}
dragOverId={dragOverId}
t={t}
onSelect={onDeviceSelect}
statusMap={statusMap}
/>
</motion.div>
))}
</AnimatePresence>
</Box>
</motion.div>
</SortableContext>
</DndContext>
</Container>
);
};

type SortableDeviceCardProps = {
device: AnyDevice;
isReorderMode: boolean;
draggingId: string | null;
dragOverId: string | null;
t: (key: string) => string;
onSelect: (deviceId: string) => void;
statusMap: Record<string, DeviceStatusResponseBody | undefined>;
};

const SortableDeviceCard: React.FC<SortableDeviceCardProps> = ({
device,
isReorderMode,
draggingId,
dragOverId,
t,
onSelect,
statusMap,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
id: device.deviceId,
disabled: !isReorderMode,
});

const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition,
};

return (
<Box
ref={setNodeRef}
sx={{
position: "relative",
opacity: isDragging || draggingId === device.deviceId ? 0.5 : 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition draggingId === device.deviceId is redundant here. The isDragging boolean from the useSortable hook already correctly identifies if this is the item being dragged. Using isDragging alone is cleaner and more idiomatic when working with dnd-kit.

Suggested change
opacity: isDragging || draggingId === device.deviceId ? 0.5 : 1,
opacity: isDragging ? 0.5 : 1,

transition: "opacity 0.2s ease",
outline: dragOverId === device.deviceId || isOver ? "2px solid" : "none",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the opacity property, the condition dragOverId === device.deviceId is redundant. The isOver boolean from useSortable is the idiomatic way to check if a dragged item is currently over this sortable item. Simplifying this will make the code easier to maintain.

Suggested change
outline: dragOverId === device.deviceId || isOver ? "2px solid" : "none",
outline: isOver ? "2px solid" : "none",

outlineColor: "primary.main",
outlineOffset: "2px",
borderRadius: 2,
}}
style={style}
{...attributes}
{...listeners}
>
{isReorderMode && (
<IconButton
size="small"
sx={{
position: "absolute",
top: 8,
right: 8,
bgcolor: "background.paper",
boxShadow: 1,
pointerEvents: "none",
}}
aria-label={t("Reorder")}
>
<DragIndicatorIcon fontSize="small" />
</IconButton>
)}
<DeviceCard
device={device}
status={statusMap[device.deviceId]}
onSelect={onSelect}
/>
</Box>
);
};
Loading