playlists(#37): drag-and-drop reorder in the Playlists management tab

Videos within a playlist can now be dragged to reorder (desktop), on top of the
↑/↓ buttons kept for touch/accessibility. HTML5 DnD delegated on the list:
dragstart records the row's playlist + index, dragover highlights the drop
target (accent inset bar) and allows the drop, drop reorders via
reorderPlaylistItems and publishes optimistically. Drag is scoped to a single
playlist (can't cross playlists); dragging row dims to 0.5; grab/grabbing cursor.

Lint + build clean; profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-06 15:48:17 -04:00
1 parent 791296266a
commit 87e31d9853
2 files changed
+110

No files matched your search

+13
View File
@@ -3137,6 +3137,19 @@
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: grab;
}
.playlist-video:active {
cursor: grabbing;
}
.playlist-video--dragging {
opacity: 0.5;
}
.playlist-video--drop-target {
box-shadow: inset 0 var(--size-2px) 0 0 var(--color-accent);
}
.playlist-video__title {
@@ -79,6 +79,8 @@ export class ProfilePlaylistsController {
this.playlists = [];
this.expanded = new Set();
this.loading = false;
this._drag = null;
this._dropTarget = null;
this._onChanged = () => {
void this.refresh();
};
@@ -102,6 +104,16 @@ export class ProfilePlaylistsController {
this.listEl.addEventListener("click", (event) =>
this.handleListClick(event),
);
// Desktop drag-to-reorder for videos within a playlist. The ↑/↓ buttons
// stay as the accessible / touch-friendly path.
this.listEl.addEventListener("dragstart", (event) =>
this.handleDragStart(event),
);
this.listEl.addEventListener("dragover", (event) =>
this.handleDragOver(event),
);
this.listEl.addEventListener("drop", (event) => this.handleDrop(event));
this.listEl.addEventListener("dragend", () => this.clearDrag());
}
if (typeof document !== "undefined") {
document.addEventListener("bitvid:playlists-changed", this._onChanged);
@@ -268,6 +280,9 @@ export class ProfilePlaylistsController {
}
playlist.items.forEach((item, index) => {
const row = el("li", "playlist-video");
row.draggable = true;
row.dataset.plId = playlist.id;
row.dataset.plIndex = String(index);
const info = videoMap.get(item.value);
const dtag = item.value.split(":")[2] || item.value;
const label = info?.title || dtag;
@@ -338,6 +353,88 @@ export class ProfilePlaylistsController {
}
}
handleDragStart(event) {
const row = event.target?.closest?.(".playlist-video");
if (!row) {
return;
}
this._drag = {
playlistId: row.dataset.plId,
fromIndex: Number(row.dataset.plIndex),
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
try {
event.dataTransfer.setData("text/plain", String(this._drag.fromIndex));
} catch (error) {
// some browsers require setData(); ignore failures
}
}
row.classList.add("playlist-video--dragging");
}
handleDragOver(event) {
if (!this._drag) {
return;
}
const row = event.target?.closest?.(".playlist-video");
if (!row || row.dataset.plId !== this._drag.playlistId) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
if (this._dropTarget && this._dropTarget !== row) {
this._dropTarget.classList.remove("playlist-video--drop-target");
}
if (Number(row.dataset.plIndex) !== this._drag.fromIndex) {
row.classList.add("playlist-video--drop-target");
this._dropTarget = row;
}
}
handleDrop(event) {
const drag = this._drag;
const row = event.target?.closest?.(".playlist-video");
this.clearDrag();
if (!drag || !row || row.dataset.plId !== drag.playlistId) {
return;
}
event.preventDefault();
const toIndex = Number(row.dataset.plIndex);
if (
!Number.isInteger(drag.fromIndex) ||
!Number.isInteger(toIndex) ||
drag.fromIndex === toIndex
) {
return;
}
const playlist = this.playlists.find((p) => p.id === drag.playlistId);
if (!playlist) {
return;
}
const items = reorderPlaylistItems(playlist.items, drag.fromIndex, toIndex);
void this.publishUpdate({ ...playlist, items }, null);
}
clearDrag() {
this._drag = null;
this._dropTarget = null;
if (this.listEl) {
this.listEl
.querySelectorAll(
".playlist-video--dragging, .playlist-video--drop-target",
)
.forEach((node) => {
node.classList.remove(
"playlist-video--dragging",
"playlist-video--drop-target",
);
});
}
}
toggleExpand(id) {
if (this.expanded.has(id)) {
this.expanded.delete(id);