feat(queue): make queue tracks draggable via dnd-kit
Wire up @dnd-kit sortable context in QueuePanel so tracks can be reordered by dragging the grip handle, dispatching moveInQueue on drop.
This commit is contained in:
Generated
+56
@@ -8,6 +8,9 @@
|
|||||||
"name": "mcma-webui",
|
"name": "mcma-webui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@olly/modern-sk": "^0.1.5",
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
@@ -552,6 +555,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"test:watch": "rstest --watch"
|
"test:watch": "rstest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@olly/modern-sk": "^0.1.5",
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Icon } from '../common/Icon';
|
import { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
@@ -35,6 +51,19 @@ export function QueuePanel() {
|
|||||||
const isRadio = queue.source === 'radio';
|
const isRadio = queue.source === 'radio';
|
||||||
const sourceLabel = queue.sourceName ?? queue.source;
|
const sourceLabel = queue.sourceName ?? queue.source;
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||||
<div className="qd-inner">
|
<div className="qd-inner">
|
||||||
@@ -114,21 +143,36 @@ export function QueuePanel() {
|
|||||||
>
|
>
|
||||||
{t('queue.nextUp')}
|
{t('queue.nextUp')}
|
||||||
</span>
|
</span>
|
||||||
{queue.entries.map((entry, index) => (
|
<DndContext
|
||||||
<QueueRow
|
sensors={sensors}
|
||||||
key={`${entry.trackId}-${index}`}
|
collisionDetection={closestCenter}
|
||||||
entry={entry}
|
onDragEnd={handleDragEnd}
|
||||||
isCurrent={index === queue.currentIndex}
|
>
|
||||||
isPlaying={isPlaying}
|
<SortableContext
|
||||||
onPlay={() => dispatch(goToIndex(index))}
|
items={queue.entries.map((_, index) => String(index))}
|
||||||
onMoveNext={() =>
|
strategy={verticalListSortingStrategy}
|
||||||
dispatch(
|
>
|
||||||
moveInQueue({ from: index, to: queue.currentIndex + 1 }),
|
{queue.entries.map((entry, index) => (
|
||||||
)
|
<QueueRow
|
||||||
}
|
key={`${entry.trackId}-${index}`}
|
||||||
onRemove={() => dispatch(removeFromQueue(index))}
|
id={String(index)}
|
||||||
/>
|
entry={entry}
|
||||||
))}
|
isCurrent={index === queue.currentIndex}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onPlay={() => dispatch(goToIndex(index))}
|
||||||
|
onMoveNext={() =>
|
||||||
|
dispatch(
|
||||||
|
moveInQueue({
|
||||||
|
from: index,
|
||||||
|
to: queue.currentIndex + 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRemove={() => dispatch(removeFromQueue(index))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
||||||
@@ -147,6 +191,7 @@ export function QueuePanel() {
|
|||||||
* enrichment updates show. The currently-playing entry is outlined and shows
|
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||||
* a playing-bars indicator in place of the drag grip. */
|
* a playing-bars indicator in place of the drag grip. */
|
||||||
function QueueRow({
|
function QueueRow({
|
||||||
|
id,
|
||||||
entry,
|
entry,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -154,6 +199,7 @@ function QueueRow({
|
|||||||
onMoveNext,
|
onMoveNext,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
|
id: string;
|
||||||
entry: QueueEntry;
|
entry: QueueEntry;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -172,16 +218,33 @@ function QueueRow({
|
|||||||
? getTrackCoverUrl(resolved.trackId, token, true)
|
? getTrackCoverUrl(resolved.trackId, token, true)
|
||||||
: undefined);
|
: undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`qrow${isCurrent ? ' current' : ''}`}
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
|
||||||
onDoubleClick={onPlay}
|
onDoubleClick={onPlay}
|
||||||
title={t('queue.doubleClickPlay')}
|
title={t('queue.doubleClickPlay')}
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
<PlayingIndicator animate={isPlaying} />
|
<PlayingIndicator animate={isPlaying} />
|
||||||
) : (
|
) : (
|
||||||
<span className="grip">
|
<span className="grip" {...attributes} {...listeners}>
|
||||||
<Icon name="dots-six-vertical" />
|
<Icon name="dots-six-vertical" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -668,6 +668,12 @@
|
|||||||
);
|
);
|
||||||
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
|
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
|
||||||
}
|
}
|
||||||
|
.qrow.dragging {
|
||||||
|
z-index: 1;
|
||||||
|
cursor: grabbing;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
.qrow.current .qt .t {
|
.qrow.current .qt .t {
|
||||||
color: var(--lime);
|
color: var(--lime);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user