fix(playlists): what the first review of bulk actions found
An Esc during a pointer drag is spent on the drag and leaves the selection. A wheel over the rail re-reads the target each frame, so a drop lands on the row under the pointer. After a bulk removal the focus goes to the next row left, and to the list's menu when the list is emptied. remove-bulk names the videos that went, and its Undo brings back only those. An Undo of a move keeps each row (and when it was saved), leaves out a video that moved on from the target, closes up its place, and says how many came back. Delete with every tick hidden removes nothing and says why. A list gone before the lock answers 404, and a body without its videos 422. One removal path now: the single row goes through the bulk one, the same toast and undo, which also retires the add-at-position route. railDrop reuses the store and scrollParent, the drag image the overlay and the z-index token, and the sensors keep their number. The count is read out from a region that outlives the band. Tests fill the gaps the review named, each seen failing.
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { hasResumePoint } from "../../lib/api/feed";
|
||||
import {
|
||||
addPlaylistItem,
|
||||
movePlaylistItems,
|
||||
removePlaylistItem,
|
||||
removePlaylistItems,
|
||||
restorePlaylistItems,
|
||||
undoPlaylistMove,
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
DragOverlay,
|
||||
KeyboardCode,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
@@ -68,6 +75,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import Overlay from "../../components/ui/Overlay";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePageMenu } from "../../components/ui/pageMenuSlot";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
@@ -136,6 +144,15 @@ const moveTargetIcon = (pl: Playlist) =>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
);
|
||||
|
||||
/** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||
* No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency
|
||||
* list, whose size must not change. */
|
||||
const NO_KEY_STARTS = {
|
||||
start: [],
|
||||
cancel: [KeyboardCode.Esc],
|
||||
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
|
||||
};
|
||||
|
||||
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
|
||||
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
|
||||
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
|
||||
@@ -421,11 +438,13 @@ function PlaylistDetail() {
|
||||
// sort, filters and grouping. All three are this component's own state, which is what makes a
|
||||
// render-phase write legal here.
|
||||
const [viewFor, setViewFor] = useState(selectedId);
|
||||
const [bandWasUp, setBandWasUp] = useState(false);
|
||||
if (viewFor !== selectedId) {
|
||||
setViewFor(selectedId);
|
||||
setViewSort(STORED_SORT);
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
setBandWasUp(false);
|
||||
}
|
||||
useUrlMirror(
|
||||
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
|
||||
@@ -515,6 +534,7 @@ function PlaylistDetail() {
|
||||
);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
const bandRef = useRef<HTMLDivElement>(null);
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
// The band's own "Move to" list, the row menu's with no row behind it.
|
||||
const moveMenu = useContextMenu<"selection">();
|
||||
const tableColumns = canEdit
|
||||
@@ -541,6 +561,18 @@ function PlaylistDetail() {
|
||||
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
|
||||
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
|
||||
if (bandUp && !bandWasUp) setBandWasUp(true);
|
||||
// Out loud from a region that outlives the band: its news includes the band going.
|
||||
const selectionStatus = bandUp
|
||||
? [
|
||||
t("playlists.selectedCount", { count: selection.shownSelected.length }),
|
||||
...(selection.hiddenCount > 0
|
||||
? [t("playlists.selectedHidden", { count: selection.hiddenCount })]
|
||||
: []),
|
||||
].join(" · ")
|
||||
: bandWasUp
|
||||
? t("playlists.selectionCleared")
|
||||
: "";
|
||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||
// offers itself when the view is a pure reordering of the whole list.
|
||||
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
|
||||
@@ -557,6 +589,13 @@ function PlaylistDetail() {
|
||||
setFilters({});
|
||||
setGroupBy(false);
|
||||
}
|
||||
// From a button that goes with the view: a focus on it would fall to <body>, and the next Tab start
|
||||
// over at the top of the page.
|
||||
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (document.activeElement === e.currentTarget)
|
||||
(selectAllRef.current ?? renameTarget.current)?.focus();
|
||||
clearView();
|
||||
};
|
||||
function saveOrder() {
|
||||
order.set(shown); // onApply persists it
|
||||
clearView();
|
||||
@@ -565,10 +604,11 @@ function PlaylistDetail() {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
// The grip was focusable and called itself "reorder", but only a pointer could move it.
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
keyboardCodes: viewActive ? NO_KEY_STARTS : undefined,
|
||||
}),
|
||||
);
|
||||
// Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
|
||||
const pointerSensorOnly = sensors.slice(0, 1);
|
||||
|
||||
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
|
||||
const dndA11y = useSortableA11y((id) => {
|
||||
@@ -582,11 +622,6 @@ function PlaylistDetail() {
|
||||
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
function refreshAll(listId: number | null = selectedId) {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
}
|
||||
|
||||
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
|
||||
const [carried, setCarried] = useState<readonly string[] | null>(null);
|
||||
const carriedRef = useRef<readonly string[]>([]);
|
||||
@@ -643,82 +678,10 @@ function PlaylistDetail() {
|
||||
|
||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
||||
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||
* @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* step to the next item (or close) only when the removal really happened. */
|
||||
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
|
||||
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
|
||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||
if (listId == null) return false;
|
||||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
||||
// be removing from the list it was launched from while another one is selected.
|
||||
if (listId === selectedRef.current) {
|
||||
// A removal isn't itself on the reorder undo stack, but it no longer THROWS AWAY that history:
|
||||
// rebasing drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
|
||||
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
|
||||
// not shift it off by the rows added or moved meanwhile).
|
||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await removePlaylistItem(qc, listRef(listId), video.id);
|
||||
refreshAll(listId);
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
||||
// there is not even a row snapping back to show it) — unless `req` already put it in its error
|
||||
// dialog, which `notifyActionError` checks.
|
||||
notifyActionError(e, msg("playlists.removeFailed"));
|
||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||||
// removed while it was still in the playlist.
|
||||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
||||
refreshAll(listId);
|
||||
return false;
|
||||
}
|
||||
notify({
|
||||
level: "success",
|
||||
message: msg("playlists.removedToast", { title: raw(video.title ?? "") }),
|
||||
action: {
|
||||
label: msg("common.undo"),
|
||||
// ONE request that puts it back at its index (the server renumbers around it): an append
|
||||
// plus a whole-list reorder would land it at the end whenever the second call failed. The
|
||||
// index is worked out against the list as it is NOW, fetched fresh.
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
[video.id],
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
if (back.keys.length)
|
||||
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, msg("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
refreshAll(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
* @returns whether the write went through — the player needs to know, because it must step to the
|
||||
* next item (or close) only then. */
|
||||
function removeItem(video: Video, listId?: number | null): Promise<boolean> {
|
||||
return removeRows([video], tickOf(video), listId);
|
||||
}
|
||||
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
||||
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
||||
@@ -756,13 +719,29 @@ function PlaylistDetail() {
|
||||
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
|
||||
);
|
||||
|
||||
// Off the screen at once, and out of the cached list too, for the reason `removeItem` gives. Returns
|
||||
// the cached list as it stood: the place an Undo goes back to, and what a failure puts back.
|
||||
/** Off the screen at once. Returns the cached list as it stood: the place an Undo goes back to, and
|
||||
* what a failure puts back. */
|
||||
function takeOut(listId: number, ids: readonly string[]) {
|
||||
const gone = new Set(ids);
|
||||
const next = order.value.filter((v) => !gone.has(v.id));
|
||||
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
// Only the list ON SCREEN has rows to take out optimistically — the open player can be removing
|
||||
// from the list it was launched from while another one is selected.
|
||||
if (listId === selectedRef.current) {
|
||||
// Not itself on the reorder undo stack, but it no longer THROWS AWAY that history: rebasing
|
||||
// drops the rows from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => !gone.has(v.id));
|
||||
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
// The table goes with its last row: a focus in it or in the band would fall to <body>, and the
|
||||
// next Tab start over at the top of the page. The list's "…" takes it.
|
||||
const held = document.activeElement;
|
||||
if (!next.length && (tableRef.current?.contains(held) || bandRef.current?.contains(held)))
|
||||
renameTarget.current?.focus();
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure puts it back: `putBack`.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
|
||||
@@ -785,6 +764,36 @@ function PlaylistDetail() {
|
||||
}
|
||||
const titleOf = (rows: Video[], id: string | undefined) =>
|
||||
raw(rows.find((v) => v.id === id)?.title ?? "");
|
||||
// What an action on this one row hands a failure to tick again.
|
||||
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
|
||||
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
|
||||
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
|
||||
// in between shifts them (`reinsertAt`).
|
||||
const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
|
||||
qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) =>
|
||||
reinsertAt(
|
||||
storedBefore,
|
||||
ids,
|
||||
now.items.map((v) => v.id),
|
||||
),
|
||||
);
|
||||
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put
|
||||
// back (gone on from the target, or from the catalog), and silence would read as all of them.
|
||||
function sayWhatCameBack(asked: number, added: number) {
|
||||
if (added >= asked) return;
|
||||
notify({
|
||||
level: "warning",
|
||||
message: added
|
||||
? msg("playlists.undoSomeBack", { count: added })
|
||||
: msg("playlists.undoNoneBack"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
|
||||
* held, which a failure ticks again. */
|
||||
@@ -828,21 +837,11 @@ function PlaylistDetail() {
|
||||
: msg("playlists.movedManyToast", { count: left.length, name }),
|
||||
action: {
|
||||
label: msg("common.undo"),
|
||||
// The places worked out against the list as it is NOW, as in `removeItem`. A video back in
|
||||
// the source by then stays where it is, in both lists.
|
||||
// A video back in the source by then stays where it is, in both lists. One gone on from the
|
||||
// target stays out: the server says how many came back.
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
left,
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
void placesNow(listId, storedBefore, left)
|
||||
.then((back) => {
|
||||
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
|
||||
const keep = (list: string[]) => list.filter((id) => place.has(id));
|
||||
const undoMoved = keep(moved);
|
||||
@@ -852,7 +851,7 @@ function PlaylistDetail() {
|
||||
moved: undoMoved,
|
||||
already_there: undoHeld,
|
||||
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
|
||||
});
|
||||
}).then((r) => sayWhatCameBack(back.keys.length, r.added));
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(
|
||||
@@ -865,81 +864,89 @@ function PlaylistDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too. */
|
||||
async function removeRows(rows: Video[], ticked: readonly string[]) {
|
||||
if (selectedId == null || !rows.length) return;
|
||||
const listId = selectedId;
|
||||
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too: an
|
||||
* append plus a whole-list reorder would land them at the end whenever the second call failed.
|
||||
* `ticked`: the ones the selection held, which a failure ticks again.
|
||||
* @returns whether the write went through. */
|
||||
async function removeRows(
|
||||
rows: Video[],
|
||||
ticked: readonly string[],
|
||||
listIdArg?: number | null,
|
||||
): Promise<boolean> {
|
||||
// Defaulted here, not as `listIdArg = selectedRef.current` in the signature: the React Compiler
|
||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||
if (listId == null || !rows.length) return false;
|
||||
const ids = rows.map((v) => v.id);
|
||||
const list = listRef(listId);
|
||||
const before = takeOut(listId, ids);
|
||||
const failTitle = msg(
|
||||
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
|
||||
);
|
||||
let removed: number | null = null;
|
||||
let removed: string[] | null = null;
|
||||
try {
|
||||
removed = (await removePlaylistItems(qc, list, ids)).removed;
|
||||
} catch (e) {
|
||||
// Said here, or a removal fails in silence (inside the full-screen player there is not even a row
|
||||
// snapping back to show it) — unless `req` already put it in its error dialog, which
|
||||
// `notifyActionError` checks.
|
||||
notifyActionError(e, failTitle);
|
||||
}
|
||||
refreshAfterEdit(ids);
|
||||
if (removed == null) {
|
||||
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
|
||||
// was unreachable has a resync that fails too.
|
||||
putBack(listId, before, ticked);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (removed === 0) {
|
||||
// A video someone else took out meanwhile is not this removal's to bring back.
|
||||
const went = removed;
|
||||
if (!went.length) {
|
||||
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const storedBefore = before?.items.map((v) => v.id) ?? [];
|
||||
notify({
|
||||
level: "success",
|
||||
message:
|
||||
removed === 1 && rows.length === 1
|
||||
? msg("playlists.removedToast", { title: titleOf(rows, ids[0]) })
|
||||
: msg("playlists.removedManyToast", { count: removed }),
|
||||
went.length === 1 && rows.length === 1
|
||||
? msg("playlists.removedToast", { title: titleOf(rows, went[0]) })
|
||||
: msg("playlists.removedManyToast", { count: went.length }),
|
||||
action: {
|
||||
label: msg("common.undo"),
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.playlist(listId),
|
||||
queryFn: () => api.playlist(listId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
storedBefore,
|
||||
ids,
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
void placesNow(listId, storedBefore, went)
|
||||
.then((back) => {
|
||||
if (back.keys.length)
|
||||
return restorePlaylistItems(qc, list, back.keys, back.positions);
|
||||
return restorePlaylistItems(qc, list, back.keys, back.positions).then((r) =>
|
||||
sayWhatCameBack(back.keys.length, r.added),
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
msg(
|
||||
ids.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
||||
went.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => refreshAfterEdit(ids)),
|
||||
.finally(() => refreshAfterEdit(went)),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
|
||||
// start over at the top of the page: the header's box takes it, or, when the action leaves no row
|
||||
// and so no box, the list's "…".
|
||||
function leaveBand(emptied = false) {
|
||||
if (!bandRef.current?.contains(document.activeElement)) return;
|
||||
(emptied ? renameTarget.current : selectAllRef.current)?.focus();
|
||||
// start over at the top of the page: the header's box takes it (`takeOut` moves it on when the
|
||||
// action leaves no row and so no box).
|
||||
function leaveBand() {
|
||||
if (bandRef.current?.contains(document.activeElement)) selectAllRef.current?.focus();
|
||||
}
|
||||
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
|
||||
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
|
||||
function selectionRows() {
|
||||
const rows = shown.filter((v) => selection.isSelected(v.id));
|
||||
leaveBand(rows.length === items.length);
|
||||
leaveBand();
|
||||
selection.clear();
|
||||
return rows;
|
||||
}
|
||||
@@ -1039,6 +1046,12 @@ function PlaylistDetail() {
|
||||
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
||||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||||
// `playlists` is empty until the rail's list is in, too: no targets only once it says so.
|
||||
const noTargetsNote = listQuery.data
|
||||
? t("playlists.moveNoTargets")
|
||||
: listQuery.isError
|
||||
? t("playlists.moveTargetsFailed")
|
||||
: t("playlists.moveTargetsLoading");
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
// While the rows must stay put the others make no way: no row is "over" anything.
|
||||
const dragCollision: CollisionDetection = (args) =>
|
||||
@@ -1059,6 +1072,10 @@ function PlaylistDetail() {
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
|
||||
: base.onDragStart(args),
|
||||
onDragCancel: (args: Parameters<typeof base.onDragCancel>[0]) =>
|
||||
carriedRef.current.length > 1
|
||||
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||
: base.onDragCancel(args),
|
||||
onDragMove: () => {
|
||||
const id = railDropTarget();
|
||||
return id == null
|
||||
@@ -1067,7 +1084,9 @@ function PlaylistDetail() {
|
||||
},
|
||||
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
|
||||
droppedOnRef.current == null
|
||||
? base.onDragEnd(args)
|
||||
? carriedRef.current.length > 1
|
||||
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
|
||||
: base.onDragEnd(args)
|
||||
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
|
||||
},
|
||||
};
|
||||
@@ -1106,7 +1125,7 @@ function PlaylistDetail() {
|
||||
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
||||
icon: <History className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void moveRows([ctxRow], recentMoveTarget),
|
||||
onSelect: () => void moveRows([ctxRow], recentMoveTarget, tickOf(ctxRow)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -1121,7 +1140,7 @@ function PlaylistDetail() {
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
icon: moveTargetIcon(pl),
|
||||
onSelect: () => void moveRows([ctxRow], pl),
|
||||
onSelect: () => void moveRows([ctxRow], pl, tickOf(ctxRow)),
|
||||
})),
|
||||
}
|
||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||
@@ -1132,7 +1151,7 @@ function PlaylistDetail() {
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: !recentMoveTarget,
|
||||
disabled: true,
|
||||
note: t("playlists.moveNoTargets"),
|
||||
note: noTargetsNote,
|
||||
onSelect: () => {},
|
||||
},
|
||||
// Same gate as the drag handle; the reason goes under the first of the four rows.
|
||||
@@ -1167,7 +1186,7 @@ function PlaylistDetail() {
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
disabled: true,
|
||||
note: t("playlists.moveNoTargets"),
|
||||
note: noTargetsNote,
|
||||
onSelect: () => {},
|
||||
},
|
||||
];
|
||||
@@ -1289,6 +1308,9 @@ function PlaylistDetail() {
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pb-4">
|
||||
<div role="status" className="sr-only">
|
||||
{selectionStatus}
|
||||
</div>
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
@@ -1385,7 +1407,7 @@ function PlaylistDetail() {
|
||||
leaveBand();
|
||||
selection.clear();
|
||||
}}
|
||||
onClearView={viewActive ? clearView : undefined}
|
||||
onClearView={viewActive ? resetView : undefined}
|
||||
trailing={
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
@@ -1425,7 +1447,7 @@ function PlaylistDetail() {
|
||||
{viewActive && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={clearView}
|
||||
onClick={resetView}
|
||||
className="text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
@@ -1477,7 +1499,7 @@ function PlaylistDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={viewActive ? pointerSensorOnly : sensors}
|
||||
sensors={sensors}
|
||||
collisionDetection={dragCollision}
|
||||
// Not the list, under a drag that is over the rail: it would run away beside it.
|
||||
autoScroll={{ canScroll: () => !isOverRail() }}
|
||||
@@ -1501,7 +1523,7 @@ function PlaylistDetail() {
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<table ref={tableRef} className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={tableColumns}
|
||||
sort={viewSort}
|
||||
@@ -1539,8 +1561,15 @@ function PlaylistDetail() {
|
||||
return selection.clear();
|
||||
},
|
||||
removeSelected: () => {
|
||||
if (!selection.shownSelected.length) return false;
|
||||
removeSelected();
|
||||
if (!bandUp) return false;
|
||||
// Nothing happens to a row the reader cannot see, and neither to the
|
||||
// row under the keyboard, which no one selected.
|
||||
if (selection.shownSelected.length) removeSelected();
|
||||
else
|
||||
notify({
|
||||
level: "warning",
|
||||
message: msg("playlists.removeAllHidden"),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}
|
||||
@@ -1571,12 +1600,13 @@ function PlaylistDetail() {
|
||||
)}
|
||||
</div>
|
||||
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
|
||||
Above every layer (the tooltip's level), and no way back to fly: it drops or stays. Out
|
||||
of the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||
{createPortal(
|
||||
Above every layer (the tooltip's level, by its token: dnd-kit's `zIndex` takes a number
|
||||
only, and its `style` comes after it), and no way back to fly: it drops or stays. Out of
|
||||
the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
|
||||
<Overlay>
|
||||
<DragOverlay
|
||||
dropAnimation={null}
|
||||
zIndex={100}
|
||||
style={{ zIndex: "var(--z-index-tooltip)" }}
|
||||
className="pointer-events-none"
|
||||
modifiers={[beneathPointer]}
|
||||
>
|
||||
@@ -1589,9 +1619,8 @@ function PlaylistDetail() {
|
||||
: shown.find((v) => v.id === carried[0])?.title}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DragOverlay>
|
||||
</Overlay>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user