feat(playlists): act on the selected rows from the toolbar, and undo either

While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
This commit is contained in:
2026-11-01 22:07:09 +01:00
parent 3b85881af4
commit efbbb23525
24 changed files with 1114 additions and 236 deletions
@@ -597,7 +597,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
// object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the
// optimistic change used to stay on screen for good.
it("puts back a row whose move failed", async () => {
vi.spyOn(api, "moveToPlaylist").mockRejectedValue(
vi.spyOn(api, "moveManyToPlaylist").mockRejectedValue(
new HttpError(404, "gone", { code: "not_found", reason: "move_target" }),
);
mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 });
@@ -611,7 +611,11 @@ describe("Playlists — a change the server refused is taken back on screen", ()
// Watch later's items ARE the bookmark: a video moved into it is saved wherever it is shown.
it("marks a video moved into Watch later as saved", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue(undefined as never);
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue({
ok: true,
moved: ["V"],
already_there: [],
} as never);
mount({ 1: [vid("V", 0)], [WATCH_LATER]: [] }, { lastMovePlaylistId: WATCH_LATER });
await rowMenu("Video V", "playlists.moveToRecent");
await waitFor(() =>
@@ -1127,3 +1131,362 @@ describe("Playlists — selecting rows", () => {
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]);
});
});
describe("Playlists — acting on the selected rows", () => {
// A move remembers its target on the server, and an answer from nowhere would be the last toast.
beforeEach(() => void vi.spyOn(api, "savePrefs").mockResolvedValue({} as never));
const box = (title: string) =>
screen.findByRole("checkbox", { name: `playlists.selectRow:${title}` });
const tick = async (...titles: string[]) => {
for (const title of titles) fireEvent.click(await box(title));
};
const ticked = () =>
screen
.getAllByRole("checkbox", { name: /playlists\.selectRow/ })
.filter((b) => (b as HTMLInputElement).checked)
.map((b) => b.getAttribute("aria-label"));
const abc: Record<number, Video[]> = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)], 2: [] };
const toast = () =>
h.notify.mock.calls[h.notify.mock.calls.length - 1]![0] as unknown as {
level: string;
message: unknown;
action?: { onClick: () => void };
};
const button = (name: string) => screen.findByRole("button", { name });
const moved = (ids: string[], already_there: string[] = []) => ({
ok: true,
from_playlist_id: 1,
to_playlist_id: 2,
moved: ids,
already_there,
});
const press = (key: string) =>
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
it("shows the band in place of the meta line only while a row is selected", async () => {
mount(abc);
await box("Video A");
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(screen.getByText("playlists.groupByChannel")).toBeTruthy();
await tick("Video A");
expect(await screen.findByText("playlists.selectedCount")).toBeTruthy();
expect(screen.queryByText("playlists.groupByChannel")).toBeNull();
fireEvent.click(await button("playlists.clearSelection"));
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
});
it("offers the last target as one click, and every other list in the menu", async () => {
mount({ ...abc, 3: [] }, { lastMovePlaylistId: 2 });
await tick("Video A");
expect(await button("playlists.moveToRecent")).toBeTruthy();
fireEvent.click(await button("playlists.moveSelected"));
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
"List 2",
"List 3",
]),
);
});
it("offers no one-click button before the first move", async () => {
mount(abc);
await tick("Video A");
await button("playlists.moveSelected");
expect(screen.queryByRole("button", { name: "playlists.moveToRecent" })).toBeNull();
});
it("says why there is nowhere to move to", async () => {
mount({ 1: [vid("A", 0)] });
await tick("Video A");
fireEvent.click(await button("playlists.moveSelected"));
await waitFor(() => {
const row = screen.getByRole("menuitem", { hidden: true });
expect(row.textContent).toContain("playlists.moveNoTargets");
expect((row as HTMLButtonElement).disabled).toBe(true);
});
});
it("moves the selected rows in one request, and clears the selection", async () => {
const lists = { ...abc };
const move = vi.spyOn(api, "moveManyToPlaylist").mockImplementation((async () => {
lists[1] = lists[1]!.filter((v) => v.id === "B");
return moved(["A", "C"]);
}) as never);
mount(lists, { lastMovePlaylistId: 2 });
await tick("Video C", "Video A");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(move).toHaveBeenCalledTimes(1));
expect(move.mock.calls[0]![0]).toBe(1);
expect([...move.mock.calls[0]![1]].sort()).toEqual(["A", "C"]);
expect(move.mock.calls[0]![2]).toBe(2);
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B"]));
await waitFor(() => expect(toast().level).toBe("success"));
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(toast().message).toEqual(
msg("playlists.movedManyToast", { count: 2, name: raw("List 2") }),
);
});
it("undoes a move back to the places, leaving alone what the target held before", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A"], ["C"]) as never);
const undo = vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video C");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(undo).toHaveBeenCalledWith(1, 2, ["A"], ["C"], [0, 2]));
});
it("leaves out of the undo a video that is back in the source by then", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"], ["C"]) as never);
const undo = vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B", "Video C");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(undo).toHaveBeenCalledWith(1, 2, ["A"], ["C"], [0, 2]));
});
it("says when the undo of a move failed", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
vi.spyOn(api, "undoPlaylistMove").mockRejectedValue(new HttpError(500, "boom"));
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().action).toBeTruthy());
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("C", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(toast().level).toBe("error"));
});
it("puts the rows back, still selected, when the move failed", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockRejectedValue(new HttpError(500, "boom"));
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video C");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(toast().level).toBe("error"));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]));
await waitFor(() =>
expect(ticked()).toEqual(["playlists.selectRow:Video A", "playlists.selectRow:Video C"]),
);
});
it("says so when none of them was in the list any more, with no Undo to offer", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved([]) as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(toast().message).toEqual(msg("playlists.moveNoneLeft"));
expect(toast().action).toBeUndefined();
});
it("counts what really moved, not what was asked for", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
mount(abc, { lastMovePlaylistId: 2 });
await tick("Video A", "Video B", "Video C");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(toast().message).toEqual(
msg("playlists.movedManyToast", { count: 2, name: raw("List 2") }),
);
});
it("removes the selected rows in one request, with an Undo back to the places", async () => {
const lists = { ...abc };
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
lists[1] = lists[1]!.filter((v) => v.id === "B");
return { ok: true, playlist_id: 1, removed: 2 };
}) as never);
const restore = vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
mount(lists);
await tick("Video A", "Video C");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
expect([...remove.mock.calls[0]![1]].sort()).toEqual(["A", "C"]);
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B"]));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(toast().message).toEqual(msg("playlists.removedManyToast", { count: 2 }));
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("B", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["A", "C"], [0, 2]));
});
it("puts the rows back, still selected, when the removal failed", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockRejectedValue(new HttpError(500, "boom"));
mount(abc);
await tick("Video B");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().level).toBe("error"));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]));
await waitFor(() => expect(ticked()).toEqual(["playlists.selectRow:Video B"]));
});
it("names the one video when one was removed", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
mount(abc);
await tick("Video B");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(toast().message).toEqual(msg("playlists.removedToast", { title: raw("Video B") }));
});
it("has Delete take the selection when there is one, with the focus on a box", async () => {
const remove = vi
.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 2 } as never);
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
mount(abc);
await tick("Video A", "Video B");
(await box("Video B")).focus();
await act(async () => press("Delete"));
await waitFor(() => expect(remove).toHaveBeenCalledTimes(1));
expect(single).not.toHaveBeenCalled();
});
it("has Delete take the walked row when nothing is selected", async () => {
const remove = vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({} as never);
const single = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
mount(abc);
await box("Video A");
press("j");
await act(async () => press("Delete"));
await waitFor(() => expect(single).toHaveBeenCalledWith(1, "A"));
expect(remove).not.toHaveBeenCalled();
});
it("keeps a row's own menu on that row, and leaves the selection alone", async () => {
const lists = { ...abc };
const move = vi.spyOn(api, "moveManyToPlaylist").mockImplementation((async () => {
lists[1] = lists[1]!.filter((v) => v.id !== "B");
return moved(["B"]);
}) as never);
mount(lists, { lastMovePlaylistId: 2 });
await tick("Video A");
await rowMenu("Video B", "playlists.moveToRecent");
await waitFor(() => expect(move).toHaveBeenCalledWith(1, ["B"], 2));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video A", "Video C"]));
expect(ticked()).toEqual(["playlists.selectRow:Video A"]);
});
it("gives a single row's move the same Undo", async () => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["B"]) as never);
const undo = vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
mount(abc, { lastMovePlaylistId: 2 });
await rowMenu("Video B", "playlists.moveToRecent");
await waitFor(() => expect(toast().action).toBeTruthy());
expect(toast().message).toEqual(
msg("playlists.movedToast", { title: raw("Video B"), name: raw("List 2") }),
);
vi.spyOn(api, "playlist").mockResolvedValue(list(1, [vid("A", 0), vid("C", 0)]) as never);
toast().action!.onClick();
await waitFor(() => expect(undo).toHaveBeenCalledWith(1, 2, ["B"], [], [1]));
});
it("acts on the rows shown, says how many a filter hides, and keeps the view's reset", async () => {
const remove = vi
.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValue({ ok: true, playlist_id: 1, removed: 1 } as never);
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video B" },
});
expect(await screen.findByText("playlists.selectedHidden")).toBeTruthy();
expect(await button("playlists.clearView")).toBeTruthy();
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(remove).toHaveBeenCalledWith(1, ["B"]));
});
it("forgets the ticks a filter hid once it acted, too", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video B" },
});
await screen.findByText("playlists.selectedHidden");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().action).toBeTruthy());
expect(screen.queryByText("playlists.selectedHidden")).toBeNull();
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
});
it("offers nothing to act with while every selected row is hidden, only the way out", async () => {
mount(abc);
await tick("Video A");
fireEvent.click(screen.getAllByRole("button", { name: "datatable.filter" })[0]!);
fireEvent.change(await screen.findByPlaceholderText("playlists.colTitle"), {
target: { value: "Video B" },
});
expect(await screen.findByText("playlists.selectedHidden")).toBeTruthy();
expect(((await button("playlists.removeSelected")) as HTMLButtonElement).disabled).toBe(true);
expect(((await button("playlists.moveSelected")) as HTMLButtonElement).disabled).toBe(true);
expect(((await button("playlists.clearSelection")) as HTMLButtonElement).disabled).toBe(false);
});
it("hands the focus to the header's box when the band goes from under it", async () => {
mount(abc);
await tick("Video A");
const clear = await button("playlists.clearSelection");
clear.focus();
fireEvent.click(clear);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("checkbox", { name: "playlists.selectAllShown" }),
),
);
});
it("hands the focus to the list's menu when the band's action emptied the list", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
ok: true,
playlist_id: 1,
removed: 1,
} as never);
mount({ 1: [vid("A", 0)] });
await tick("Video A");
const remove = await button("playlists.removeSelected");
remove.focus();
fireEvent.click(remove);
await waitFor(() => expect(toast().action).toBeTruthy());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "playlists.railMenu" }));
});
it("leaves the focus alone when the band goes and it was elsewhere", async () => {
mount(abc);
await tick("Video A");
const row = await box("Video B");
row.focus();
await act(async () => press("Escape"));
expect(screen.queryByText("playlists.selectedCount")).toBeNull();
expect(document.activeElement).toBe(row);
});
it("drops the grip below a phone-wide table: the row menu keeps the moves", async () => {
mount(abc);
await box("Video A");
const grip = screen.getByRole("button", { name: "playlists.dragHandleOf:Video A" });
const cell = grip.closest("td")!;
expect(cell.classList.contains("hidden")).toBe(true);
expect(cell.classList.contains("@sm:table-cell")).toBe(true);
});
});
+341 -97
View File
@@ -6,8 +6,11 @@ import { qk } from "../../lib/queryKeys";
import { hasResumePoint } from "../../lib/api/feed";
import {
addPlaylistItem,
movePlaylistItem,
movePlaylistItems,
removePlaylistItem,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
clearVideoProgress,
writeVideoState,
} from "../../features/video/videoWrites";
@@ -83,6 +86,7 @@ import {
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
import { useOrderItems } from "../../components/ui/useOrderItems";
import { Input } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls";
@@ -93,6 +97,7 @@ import { PageHeading } from "../../components/ui/Headings";
import { useCopyItems } from "../../components/ui/copyItems";
import RemoteImage from "../../components/ui/RemoteImage";
import PlaylistsHeader from "./PlaylistsHeader";
import { SelectionBand } from "./SelectionBand";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -116,6 +121,13 @@ const idsKey = (items: Video[]) =>
.sort()
.join(",");
const moveTargetIcon = (pl: Playlist) =>
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
);
function Row({
video,
index,
@@ -455,12 +467,24 @@ function PlaylistDetail() {
shown.map((v) => v.id),
selectedId,
);
const selectAllRef = useRef<HTMLInputElement>(null);
const bandRef = useRef<HTMLDivElement>(null);
// The band's own "Move to" list, the row menu's with no row behind it.
const moveMenu = useContextMenu<"selection">();
const tableColumns = canEdit
? [
selectColumn({
t,
state: selection.headerState,
onToggleAll: selection.toggleAllShown,
allBox: (
<RowSelectBox
ref={selectAllRef}
label={t("playlists.selectAllShown")}
title={t("playlists.selectAllShown")}
checked={selection.headerState === "all"}
mixed={selection.headerState === "some"}
onToggle={selection.toggleAllShown}
/>
),
isSelected: selection.isSelected,
onToggle: selection.toggle,
}),
@@ -469,6 +493,8 @@ function PlaylistDetail() {
: columns;
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;
// 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);
@@ -637,42 +663,207 @@ function PlaylistDetail() {
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
);
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
// Outside the `try`: a `??` inside it makes the React Compiler skip the component.
const title = video.title ?? "";
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
await movePlaylistItem(qc, listRef(selectedId), video.id, target);
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
// shortcut to failing again.
setLastMoveTargetId(target.id);
notify({
level: "success",
message: msg("playlists.movedToast", { title: raw(title), name: playlistNameText(target) }),
});
} catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable.
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
// checks (a review found it reported twice).
notifyActionError(e, msg("playlists.moveFailed"));
restoreFromCache(selectedId); // the row is still in this list
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
// AddToPlaylist does for the same reason.
// 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.
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)}`;
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,
);
return before;
}
// The write failed: the rows come back now (see `restoreFromCache`), ticked again if they were, so
// the reader can try once more without picking them all over.
function putBack(listId: number, before: PlaylistDetail | undefined, ticked: readonly string[]) {
if (before) qc.setQueryData(qk.playlist(listId), before);
restoreFromCache(listId);
if (listId === selectedRef.current) selection.select(ticked);
}
// BOTH lists of a move changed, and so did these videos' membership. `qk.playlist(id)` is an exact
// key, so it cannot cover the target: the whole `["playlist"]` prefix, as AddToPlaylist does.
function refreshAfterEdit(ids: readonly string[]) {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
for (const id of ids) qc.invalidateQueries({ queryKey: qk.playlistMembership(id) });
}
const titleOf = (rows: Video[], id: string | undefined) =>
raw(rows.find((v) => v.id === id)?.title ?? "");
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
* held, which a failure ticks again. */
async function moveRows(rows: Video[], target: Playlist, ticked: readonly string[] = []) {
if (selectedId == null || !rows.length) return;
const listId = selectedId;
const ids = rows.map((v) => v.id);
const from = listRef(listId);
const before = takeOut(listId, ids);
const failTitle = msg(rows.length > 1 ? "playlists.moveManyFailed" : "playlists.moveFailed");
let done: Awaited<ReturnType<typeof movePlaylistItems>> | null = null;
try {
done = await movePlaylistItems(qc, from, ids, target);
} catch (e) {
// NOT a silent revert: a target that is gone answers 404, which `req` leaves to the caller. A
// fault `req` already put in its error dialog is not toasted again (`notifyActionError` checks).
notifyActionError(e, failTitle);
}
if (!done) {
putBack(listId, before, ticked);
refreshAfterEdit(ids);
return;
}
const { moved, already_there: alreadyThere } = done;
const left = [...moved, ...alreadyThere];
refreshAfterEdit(ids);
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a shortcut
// to failing again.
setLastMoveTargetId(target.id);
if (!left.length) {
notify({ level: "warning", message: msg("playlists.moveNoneLeft") });
return;
}
const name = playlistNameText(target);
const storedBefore = before?.items.map((v) => v.id) ?? [];
notify({
level: "success",
message:
left.length === 1
? msg("playlists.movedToast", { title: titleOf(rows, left[0]), name })
: 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.
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),
);
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);
const undoHeld = keep(alreadyThere);
if (back.keys.length)
return undoPlaylistMove(qc, from, target, {
moved: undoMoved,
already_there: undoHeld,
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
});
})
.catch((e: unknown) =>
notifyActionError(
e,
msg(left.length > 1 ? "playlists.undoMoveManyFailed" : "playlists.undoMoveFailed"),
),
)
.finally(() => refreshAfterEdit(left)),
},
});
}
/** 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;
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;
try {
removed = (await removePlaylistItems(qc, list, ids)).removed;
} catch (e) {
notifyActionError(e, failTitle);
}
refreshAfterEdit(ids);
if (removed == null) {
putBack(listId, before, ticked);
return;
}
if (removed === 0) {
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
return;
}
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 }),
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),
);
if (back.keys.length)
return restorePlaylistItems(qc, list, back.keys, back.positions);
})
.catch((e: unknown) =>
notifyActionError(
e,
msg(
ids.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
),
),
)
.finally(() => refreshAfterEdit(ids)),
},
});
}
// 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();
}
// 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);
selection.clear();
return rows;
}
function moveSelected(target: Playlist) {
const rows = selectionRows();
void moveRows(
rows,
target,
rows.map((v) => v.id),
);
}
function removeSelected() {
const rows = selectionRows();
void removeRows(
rows,
rows.map((v) => v.id),
);
}
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
@@ -790,7 +981,7 @@ function PlaylistDetail() {
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
icon: <History className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void moveItemTo(ctxRow, recentMoveTarget),
onSelect: () => void moveRows([ctxRow], recentMoveTarget),
},
]
: []),
@@ -804,13 +995,8 @@ function PlaylistDetail() {
items: moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
onSelect: () => void moveItemTo(ctxRow, pl),
icon: moveTargetIcon(pl),
onSelect: () => void moveRows([ctxRow], pl),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
@@ -843,6 +1029,24 @@ function PlaylistDetail() {
: []),
];
const moveMenuItems: ActionMenuItem[] = moveTargets.length
? moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon: moveTargetIcon(pl),
onSelect: () => moveSelected(pl),
}))
: [
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
disabled: true,
note: t("playlists.moveNoTargets"),
onSelect: () => {},
},
];
const headerPl = headerMenu.target;
const headerItems: ActionMenuItem[] = !headerPl
? []
@@ -1035,64 +1239,91 @@ function PlaylistDetail() {
</div>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<CheckRow
checked={groupBy}
onChange={setGroupBy}
className="text-xs text-muted"
>
{t("playlists.groupByChannel")}
</CheckRow>
{viewActive && (
<Button
variant="link-muted"
onClick={clearView}
className="text-xs underline underline-offset-2"
{bandUp ? (
<SelectionBand
ref={bandRef}
count={selection.shownSelected.length}
hidden={selection.hiddenCount}
recentName={recentMoveTarget ? plName(recentMoveTarget) : null}
onMoveRecent={() => recentMoveTarget && moveSelected(recentMoveTarget)}
moveMenuProps={moveMenu.buttonProps("selection")}
moveMenuOpen={moveMenu.open}
onRemove={removeSelected}
onClear={() => {
leaveBand();
selection.clear();
}}
onClearView={viewActive ? clearView : undefined}
trailing={
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
}
/>
) : (
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{filterActive &&
` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<CheckRow
checked={groupBy}
onChange={setGroupBy}
className="text-xs text-muted"
>
{t("playlists.clearView")}
</Button>
)}
{canSaveOrder && (
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
icon={<Check className="w-3.5 h-3.5" />}
>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
{t("playlists.groupByChannel")}
</CheckRow>
{viewActive && (
<Button
variant="link-muted"
onClick={clearView}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</Button>
)}
{canSaveOrder && (
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
icon={<Check className="w-3.5 h-3.5" />}
>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a
warning, no Save button and no visible change, which reads as a malfunction.
Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
@@ -1160,7 +1391,15 @@ function PlaylistDetail() {
const v = shownRef.current[i];
if (v) selection.toggle(v.id);
},
clear: selection.clear,
clear: () => {
leaveBand();
return selection.clear();
},
removeSelected: () => {
if (!selection.shownSelected.length) return false;
removeSelected();
return true;
},
}
: undefined
}
@@ -1192,6 +1431,11 @@ function PlaylistDetail() {
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
<ContextMenu
menu={moveMenu}
ariaLabel={t("playlists.moveSelected")}
items={moveMenuItems}
/>
<ContextMenu
menu={pageMenu}
ariaLabel={t("common.pageMenu")}
@@ -0,0 +1,101 @@
import type { ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, X } from "lucide-react";
import { Button } from "../../components/ui/Button";
/** The playlist's second toolbar row while rows are selected: how many, and what can be done to them.
* `count` is the rows shown, what an action covers; `hidden` the selected ones a filter hides, which
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on. */
export function SelectionBand({
ref,
count,
hidden,
recentName,
onMoveRecent,
moveMenuProps,
moveMenuOpen,
onRemove,
onClear,
onClearView,
trailing,
}: {
ref: Ref<HTMLDivElement>;
count: number;
hidden: number;
/** The list the last move went to: one click away, absent when there is none to repeat. */
recentName: string | null;
onMoveRecent: () => void;
moveMenuProps: Record<string, unknown>;
moveMenuOpen: boolean;
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
onClearView?: () => void;
trailing: ReactNode;
}) {
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-2 mt-1 flex-wrap">
<span role="status" className="text-xs">
<span className="font-medium">{t("playlists.selectedCount", { count })}</span>
{hidden > 0 && (
<span className="text-muted">
{" · "}
<span>{t("playlists.selectedHidden", { count: hidden })}</span>
</span>
)}
</span>
{recentName != null && (
<Button
variant="outline"
size="xs"
disabled={none}
onClick={onMoveRecent}
icon={<History className="w-3.5 h-3.5" />}
>
<span className="block max-w-48 truncate">
{t("playlists.moveToRecent", { name: recentName })}
</span>
</Button>
)}
<Button
variant="outline"
size="xs"
disabled={none}
aria-expanded={moveMenuOpen}
icon={<FolderInput className="w-3.5 h-3.5" />}
{...moveMenuProps}
>
{t("playlists.moveSelected")}
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</Button>
<Button
variant="outline-danger"
size="xs"
disabled={none}
onClick={onRemove}
icon={<X className="w-3.5 h-3.5" />}
>
{t("playlists.removeSelected")}
</Button>
<Button
variant="link-muted"
onClick={onClear}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearSelection")}
</Button>
{onClearView && (
<Button
variant="link-muted"
onClick={onClearView}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</Button>
)}
<div className="ml-auto">{trailing}</div>
</div>
);
}
@@ -31,7 +31,14 @@ export function playlistColumns({
more: (v: Video) => ReactNode;
}): Column<Video>[] {
const cols: Column<Video>[] = [
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
{
key: GRIP_KEY,
header: "",
width: "2.25rem",
render: () => null,
hideInCard: true,
phoneHidden: true,
},
{
key: "pos",
header: "#",
@@ -150,30 +157,20 @@ export function playlistColumns({
* `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({
t,
state,
onToggleAll,
allBox,
isSelected,
onToggle,
}: {
t: TFunction;
state: "none" | "some" | "all";
onToggleAll: () => void;
/** The box over the column. Built by the page, which holds a ref to it. */
allBox: ReactNode;
isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void;
}): Column<Video> {
const allLabel = t("playlists.selectAllShown");
return {
key: "select",
header: allLabel,
headerCell: (
<RowSelectBox
label={allLabel}
title={allLabel}
checked={state === "all"}
mixed={state === "some"}
onToggle={onToggleAll}
/>
),
header: t("playlists.selectAllShown"),
headerCell: allBox,
width: "2.25rem",
hideInCard: true,
render: (v) => (