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
+13 -41
View File
@@ -292,7 +292,7 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the # Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal). # frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"] MoveReason = Literal["same_playlist"]
class MoveRefused(Exception): class MoveRefused(Exception):
@@ -447,41 +447,6 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
db.flush() db.flush()
def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
"""Move one video from `src` into `dst`, in the caller's one transaction.
The client-side shape — add to the target, then remove from the source — leaves the video in
BOTH lists whenever the second call fails. Idempotent: a video already in the target is simply
dropped from the source (moving into a list that has it is a removal, not a duplicate). Raises
`MoveRefused` for the same list twice, or a video a concurrent edit already took out."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
row = queries.item(db, src.id, video_id)
if row is None:
raise MoveRefused("not_in_playlist")
dst_changed = False
if queries.item(db, dst.id, video_id) is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
row.playlist_id = dst.id
row.position = next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(row)
# The source always changed; the target only when the row landed in it. Touching a target
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
recompute_dirty(db, src)
touch(src)
if dst_changed:
recompute_dirty(db, dst)
touch(dst)
db.flush()
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int: def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply """Remove these videos from a playlist, in one transaction; a video not in it any more is simply
not counted. Says how many went.""" not counted. Says how many went."""
@@ -534,12 +499,14 @@ def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: li
def move_items( def move_items(
db: Session, src: Playlist, dst: Playlist, video_ids: list[str] db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
"""Move these videos from `src` into `dst`, in the source's order whatever order they were picked """Move these videos from `src` into `dst`, in the caller's one transaction, in the source's
in, as `move_item` does one. Returns the ones that landed in the target and the ones it already order whatever order they were picked in. Returns the ones that landed in the target and the ones
held, which only left the source: the Undo needs to tell them apart. A video a concurrent edit it already held, which only left the source: the Undo needs to tell them apart. A video a
already took out of the source is in neither.""" concurrent edit already took out of the source is in neither. Raises `MoveRefused` for the same
list twice."""
if src.id == dst.id: if src.id == dst.id:
raise MoveRefused("same_playlist") raise MoveRefused("same_playlist")
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id) lock_playlists(db, src.id, dst.id)
wanted = set(video_ids) wanted = set(video_ids)
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted] rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
@@ -553,13 +520,18 @@ def move_items(
db.delete(row) db.delete(row)
else: else:
moved.append(row.video_id) moved.append(row.video_id)
# Re-parented, as `move_item` says why: `added_at` survives the move. # Re-parented rather than deleted and inserted: `added_at` ("when I saved this") survives
# the move, and there is no window where the video belongs to neither list. `held` stays
# true: both lists are locked, and an insert into the target (another tab's add, a
# YouTube merge) waits for the lock, as its foreign key needs the target row.
row.playlist_id = dst.id row.playlist_id = dst.id
row.position = position row.position = position
position += 1 position += 1
if rows: if rows:
recompute_dirty(db, src) recompute_dirty(db, src)
touch(src) touch(src)
# Only when a row landed in it: touching a target that already had the video promoted an untouched
# list in the rail's "recently changed" sort.
if moved: if moved:
recompute_dirty(db, dst) recompute_dirty(db, dst)
touch(dst) touch(dst)
+7 -40
View File
@@ -421,44 +421,6 @@ def remove_item(
return {"ok": True, "playlist_id": pl.id, "video_id": video_id} return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
class MoveItemIn(BaseModel):
target_playlist_id: int
@router.post("/{playlist_id}/items/{video_id}/move")
def move_item(
playlist_id: int,
video_id: str,
body: MoveItemIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move one video from a playlist into another of the caller's playlists, atomically.
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
from the source — leaves the video in BOTH lists whenever the second call fails, which is
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
case. Idempotent: if the video is already in the target, the source row is simply dropped
(moving into a list that already has it is a removal, not a duplicate)."""
src = _own_playlist(db, user, playlist_id)
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
# the list on screen — which is still there.
dst = queries.own_playlist(db, user, body.target_playlist_id)
if dst is None:
raise not_found("move_target")
try:
service.move_item(db, src, dst, video_id)
except service.MoveRefused as e:
# A same-list move is a bad request; a video a concurrent edit (another tab, a YouTube
# re-pull) already took out is gone.
status = 400 if e.reason == "same_playlist" else 404
raise HTTPException(
status_code=status, detail={"code": "playlist_move", "reason": e.reason}
) from None
db.commit()
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
class VideoIdsIn(BaseModel): class VideoIdsIn(BaseModel):
video_ids: list[str] = [] video_ids: list[str] = []
@@ -542,8 +504,13 @@ def move_items(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Move many videos into another of the caller's playlists, in one transaction, for the reason """Move videos into another of the caller's playlists, atomically.
`move_item` gives. Says which landed there and which it already held, for the "Undo"."""
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
from the source — leaves a video in BOTH lists whenever the second call fails. A video the
target already has only leaves the source, and one a concurrent edit (another tab, a YouTube
re-pull) already took out is skipped. Says which landed there and which it already held, for
the "Undo"."""
src = _own_playlist(db, user, playlist_id) src = _own_playlist(db, user, playlist_id)
dst = _other_list(db, user, body.target_playlist_id) dst = _other_list(db, user, body.target_playlist_id)
try: try:
+7 -4
View File
@@ -51,7 +51,8 @@ def _ids(db, pl_id):
def _move(client, src, video_id, dst): def _move(client, src, video_id, dst):
return client.post( return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst} f"/api/playlists/{src}/items/move-bulk",
json={"video_ids": [video_id], "target_playlist_id": dst},
) )
@@ -67,7 +68,8 @@ def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
"ok": True, "ok": True,
"from_playlist_id": src.id, "from_playlist_id": src.id,
"to_playlist_id": dst.id, "to_playlist_id": dst.id,
"video_id": "v1", "moved": ["v1"],
"already_there": [],
} }
db.expire_all() db.expire_all()
assert _ids(db, src.id) == ["v2"] assert _ids(db, src.id) == ["v2"]
@@ -116,8 +118,9 @@ def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user)
dst = _playlist(db, user, "Target", []) dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id) r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404 assert r.status_code == 200
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"} assert (r.json()["moved"], r.json()["already_there"]) == ([], [])
assert _ids(db, dst.id) == []
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user): def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
+6 -3
View File
@@ -107,7 +107,8 @@ PLEX = PlexPlaylist.__table__.name
), ),
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}), lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
lambda c, pid, other: c.post( lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other} f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
), ),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}), lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
], ],
@@ -173,7 +174,8 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
), ),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"), lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post( lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other} f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
), ),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}), lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
# the ORM cascade deletes the items before the list row # the ORM cascade deletes the items before the list row
@@ -199,7 +201,8 @@ def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user
pid, other = _yt(db, user) pid, other = _yt(db, user)
statements.clear() statements.clear()
r = as_user(user).post( r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other} f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS) lock = _locked_before_items(statements, YT, YT_ITEMS)
+5 -6
View File
@@ -235,14 +235,13 @@ def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
service.add_item(db, dst, "b", mark_dirty=False) service.add_item(db, dst, "b", mark_dirty=False)
db.commit() db.commit()
with pytest.raises(service.MoveRefused, match="same_playlist"): with pytest.raises(service.MoveRefused, match="same_playlist"):
service.move_item(db, src, src, "a") service.move_items(db, src, src, ["a"])
service.move_item(db, src, dst, "a") assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
service.move_item(db, src, dst, "b") # already in the target: dropped from the source # already in the target: dropped from the source
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
db.commit() db.commit()
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"]) assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
with pytest.raises(service.MoveRefused, match="not_in_playlist"): assert service.move_items(db, src, dst, ["a"]) == ([], [])
service.move_item(db, src, dst, "a")
db.rollback()
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user): def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
+6 -1
View File
@@ -540,7 +540,10 @@ of a full-width panel and nothing on the right.
over the column for the rows shown. Shift+click selects a range, `x` toggles the row under the keyboard, over the column for the rows shown. Shift+click selects a range, `x` toggles the row under the keyboard,
Esc clears. A selected row wears the accent tint as well as its tick. An action covers the selected rows Esc clears. A selected row wears the accent tint as well as its tick. An action covers the selected rows
shown; a row a filter hides stays selected and comes back ticked; another list starts over. The row's own shown; a row a filter hides stays selected and comes back ticked; another list starts over. The row's own
menu still acts on that row alone. menu still acts on that row alone. While anything is selected the toolbar's second row is the action band:
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
Delete takes the selection when there is one, else the row under the keyboard, with no confirm.
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected. - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3] [WCAG 2.3.3]
@@ -840,3 +843,5 @@ of a full-width panel and nothing on the right.
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo | | 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | | 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | | 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. |
@@ -1,3 +1,4 @@
import type { RefObject } from "react";
import { Checkbox } from "./NativeControls"; import { Checkbox } from "./NativeControls";
/** A table row's selecting box, and the same box over the column for all the rows. The whole cell is /** A table row's selecting box, and the same box over the column for all the rows. The whole cell is
@@ -9,7 +10,9 @@ export function RowSelectBox({
mixed, mixed,
title, title,
onToggle, onToggle,
ref,
}: { }: {
ref?: RefObject<HTMLInputElement | null>;
label: string; label: string;
checked: boolean; checked: boolean;
mixed?: boolean; mixed?: boolean;
@@ -26,6 +29,7 @@ export function RowSelectBox({
className="-ml-2 grid min-h-6 w-9 cursor-pointer place-items-center touch-target" className="-ml-2 grid min-h-6 w-9 cursor-pointer place-items-center touch-target"
> >
<Checkbox <Checkbox
ref={ref}
aria-label={label} aria-label={label}
checked={checked} checked={checked}
mixed={mixed} mixed={mixed}
@@ -21,7 +21,7 @@ const ALLOWED = new Set([
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark // the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
// together; a direct api call skips the mark. // together; a direct api call skips the mark.
const WRITES = const WRITES =
/\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/; /\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|removeManyFromPlaylist|restoreToPlaylist|moveManyToPlaylist|undoPlaylistMove)\b/;
describe("a video's state, Watch-later and playlist-item writes", () => { describe("a video's state, Watch-later and playlist-item writes", () => {
it("go through features/video/videoWrites, never the api directly", () => { it("go through features/video/videoWrites, never the api directly", () => {
@@ -38,7 +38,8 @@ describe("a video's state, Watch-later and playlist-item writes", () => {
it("catches a write named without being called on the spot, and a playlist item's", () => { it("catches a write named without being called on the spot, and a playlist item's", () => {
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true); expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true); expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true); expect(WRITES.test("api.moveManyToPlaylist(a, ids, b)")).toBe(true);
expect(WRITES.test("api.undoPlaylistMove(a, b, m, t, p)")).toBe(true);
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false); expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
}); });
}); });
@@ -9,7 +9,10 @@ import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { import {
addPlaylistItem, addPlaylistItem,
movePlaylistItem, movePlaylistItems,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
removePlaylistItem, removePlaylistItem,
clearVideoProgress, clearVideoProgress,
writeVideoState, writeVideoState,
@@ -77,12 +80,44 @@ describe("videoWrites", () => {
expect(marks(qc)).toBeUndefined(); expect(marks(qc)).toBeUndefined();
}); });
it("marks a move into and out of the Watch later list", async () => { it("marks a move into and out of the Watch later list, by what the server moved", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never); vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue({
ok: true,
moved: ["X"],
already_there: ["Y"],
} as never);
const qc = new QueryClient(); const qc = new QueryClient();
await movePlaylistItem(qc, MINE, "X", WATCH_LATER); await movePlaylistItems(qc, MINE, ["X", "Y", "gone"], WATCH_LATER);
expect(marks(qc)).toEqual({ X: { saved: true }, Y: { saved: true } });
await movePlaylistItems(qc, WATCH_LATER, ["X", "Y", "gone"], MINE);
expect(marks(qc)).toEqual({ X: { saved: false }, Y: { saved: false } });
});
it("marks a bulk removal from Watch later, and its restore", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await removePlaylistItems(qc, WATCH_LATER, ["X"]);
expect(marks(qc)).toEqual({ X: { saved: false } });
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
expect(marks(qc)).toEqual({ X: { saved: true } }); expect(marks(qc)).toEqual({ X: { saved: true } });
await movePlaylistItem(qc, WATCH_LATER, "X", MINE); });
it("takes a move into Watch later back: only what landed there loses the mark", async () => {
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await undoPlaylistMove(qc, MINE, WATCH_LATER, {
moved: ["X"],
already_there: ["Y"],
positions: [0, 1],
});
expect(api.undoPlaylistMove).toHaveBeenCalledWith(
MINE.id,
WATCH_LATER.id,
["X"],
["Y"],
[0, 1],
);
expect(marks(qc)).toEqual({ X: { saved: false } }); expect(marks(qc)).toEqual({ X: { saved: false } });
}); });
+46 -5
View File
@@ -59,11 +59,52 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
}); });
} }
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */ /** Take videos out of a playlist, in one request. */
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) { export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
return api.moveToPlaylist(from.id, id, to.id).then((r) => { return api.removeManyFromPlaylist(list.id, ids).then((r) => {
markIfWatchLater(qc, from, id, false); for (const id of ids) markIfWatchLater(qc, list, id, false);
markIfWatchLater(qc, to, id, true);
return r; return r;
}); });
} }
/** Put removed videos back at their places, in one request. */
export function restorePlaylistItems(
qc: QueryClient,
list: ListRef,
ids: string[],
positions: number[],
) {
return api.restoreToPlaylist(list.id, ids, positions).then((r) => {
for (const id of ids) markIfWatchLater(qc, list, id, true);
return r;
});
}
/** Move videos from one playlist to another, in one request (see `api.moveManyToPlaylist`). Marks
* what the server says it moved: a video a concurrent edit already took out is in neither answer. */
export function movePlaylistItems(qc: QueryClient, from: ListRef, ids: string[], to: ListRef) {
return api.moveManyToPlaylist(from.id, ids, to.id).then((r) => {
for (const id of [...r.moved, ...r.already_there]) {
markIfWatchLater(qc, from, id, false);
markIfWatchLater(qc, to, id, true);
}
return r;
});
}
/** Take a move back: every video returns to `from`, and only the ones that landed in `to` leave it;
* it held the others before the move. */
export function undoPlaylistMove(
qc: QueryClient,
from: ListRef,
to: ListRef,
back: { moved: string[]; already_there: string[]; positions: number[] },
) {
return api
.undoPlaylistMove(from.id, to.id, back.moved, back.already_there, back.positions)
.then((r) => {
for (const id of back.moved) markIfWatchLater(qc, to, id, false);
for (const id of [...back.moved, ...back.already_there]) markIfWatchLater(qc, from, id, true);
return r;
});
}
+1 -1
View File
@@ -35,7 +35,7 @@
"listStep": "Next / previous item", "listStep": "Next / previous item",
"gridMove": "Move around the list", "gridMove": "Move around the list",
"open": "Open the current item", "open": "Open the current item",
"listRemove": "Remove the current item from the list", "listRemove": "Remove the selected rows, or else the current one",
"listSelect": "Select or deselect the current item", "listSelect": "Select or deselect the current item",
"listClearSelection": "Clear the selection", "listClearSelection": "Clear the selection",
"cap": { "cap": {
+18 -1
View File
@@ -94,7 +94,6 @@
"sync_all_busy": "Your playlists are already syncing with YouTube — try again when it finishes" "sync_all_busy": "Your playlists are already syncing with YouTube — try again when it finishes"
}, },
"moveErrors": { "moveErrors": {
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else",
"same_playlist": "That video is already in this playlist" "same_playlist": "That video is already in this playlist"
}, },
"railMenu": "Playlist actions", "railMenu": "Playlist actions",
@@ -104,6 +103,24 @@
"dragHandleOf": "Reorder “{{title}}”", "dragHandleOf": "Reorder “{{title}}”",
"selectRow": "Select “{{title}}”", "selectRow": "Select “{{title}}”",
"selectAllShown": "Select all the rows shown", "selectAllShown": "Select all the rows shown",
"selectedCount_one": "{{count}} selected",
"selectedCount_other": "{{count}} selected",
"selectedHidden_one": "{{count}} more hidden by the filter",
"selectedHidden_other": "{{count}} more hidden by the filter",
"clearSelection": "Clear selection",
"moveSelected": "Move to",
"removeSelected": "Remove",
"movedManyToast_one": "{{count}} video moved to “{{name}}”",
"movedManyToast_other": "{{count}} videos moved to “{{name}}”",
"removedManyToast_one": "{{count}} video removed from the playlist",
"removedManyToast_other": "{{count}} videos removed from the playlist",
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
"moveManyFailed": "Couldn't move the videos",
"removeManyFailed": "Couldn't remove the videos",
"undoMoveFailed": "Couldn't move the video back",
"undoMoveManyFailed": "Couldn't move the videos back",
"undoRemoveManyFailed": "Couldn't put the videos back",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.", "dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.", "dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
"clearView": "Reset view", "clearView": "Reset view",
+1 -1
View File
@@ -35,7 +35,7 @@
"listStep": "Következő / előző elem", "listStep": "Következő / előző elem",
"gridMove": "Mozgás a listában", "gridMove": "Mozgás a listában",
"open": "Az aktuális elem megnyitása", "open": "Az aktuális elem megnyitása",
"listRemove": "Az aktuális elem eltávolítása a listából", "listRemove": "A kijelölt sorok, vagy az aktuális eltávolítása",
"listSelect": "Az aktuális elem kijelölése vagy levétele", "listSelect": "Az aktuális elem kijelölése vagy levétele",
"listClearSelection": "A kijelölés törlése", "listClearSelection": "A kijelölés törlése",
"cap": { "cap": {
+18 -1
View File
@@ -94,7 +94,6 @@
"sync_all_busy": "A listáid épp szinkronizálnak a YouTube-bal — próbáld újra, ha végzett" "sync_all_busy": "A listáid épp szinkronizálnak a YouTube-bal — próbáld újra, ha végzett"
}, },
"moveErrors": { "moveErrors": {
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott",
"same_playlist": "Ez a videó már ebben a listában van" "same_playlist": "Ez a videó már ebben a listában van"
}, },
"railMenu": "Lejátszási lista műveletei", "railMenu": "Lejátszási lista műveletei",
@@ -104,6 +103,24 @@
"dragHandleOf": "„{{title}}” átrendezése", "dragHandleOf": "„{{title}}” átrendezése",
"selectRow": "„{{title}}” kijelölése", "selectRow": "„{{title}}” kijelölése",
"selectAllShown": "Az összes látható sor kijelölése", "selectAllShown": "Az összes látható sor kijelölése",
"selectedCount_one": "{{count}} kijelölve",
"selectedCount_other": "{{count}} kijelölve",
"selectedHidden_one": "további {{count}} a szűrő mögött",
"selectedHidden_other": "további {{count}} a szűrő mögött",
"clearSelection": "Kijelölés törlése",
"moveSelected": "Áthelyezés ide",
"removeSelected": "Eltávolítás",
"movedManyToast_one": "{{count}} videó áthelyezve ide: „{{name}}”",
"movedManyToast_other": "{{count}} videó áthelyezve ide: „{{name}}”",
"removedManyToast_one": "{{count}} videó eltávolítva a listáról",
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
"undoMoveManyFailed": "Nem sikerült visszahelyezni a videókat",
"undoRemoveManyFailed": "Nem sikerült visszatenni a videókat",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.", "dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.", "dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
"clearView": "Nézet visszaállítása", "clearView": "Nézet visszaállítása",
+49 -7
View File
@@ -77,16 +77,58 @@ export const playlistsApi = {
}), }),
removeFromPlaylist: (id: number, videoId: string) => removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }), req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
// One request on purpose: add-then-remove from the client leaves the video in BOTH lists when // One request on purpose: add-then-remove from the client leaves a video in BOTH lists when the
// the second call fails. The server does it in one transaction. // second call fails. The server does it in one transaction, and says which landed in the target
moveToPlaylist: ( // and which it already held: the Undo needs to tell them apart.
moveManyToPlaylist: (
id: number, id: number,
videoId: string, videoIds: string[],
targetPlaylistId: number, targetPlaylistId: number,
): Promise<{ ok: true; from_playlist_id: number; to_playlist_id: number; video_id: string }> => ): Promise<{
req(`/api/playlists/${id}/items/${videoId}/move`, { ok: true;
from_playlist_id: number;
to_playlist_id: number;
moved: string[];
already_there: string[];
}> =>
req(`/api/playlists/${id}/items/move-bulk`, {
method: "POST", method: "POST",
body: JSON.stringify({ target_playlist_id: targetPlaylistId }), body: JSON.stringify({ video_ids: videoIds, target_playlist_id: targetPlaylistId }),
}),
// The Undo of a move, back into `id`. `positions` pairs with `moved` + `alreadyThere`.
undoPlaylistMove: (
id: number,
fromPlaylistId: number,
moved: string[],
alreadyThere: string[],
positions: number[],
): Promise<{ ok: true; playlist_id: number; added: number }> =>
req(`/api/playlists/${id}/items/undo-move`, {
method: "POST",
body: JSON.stringify({
from_playlist_id: fromPlaylistId,
moved,
already_there: alreadyThere,
positions,
}),
}),
removeManyFromPlaylist: (
id: number,
videoIds: string[],
): Promise<{ ok: true; playlist_id: number; removed: number }> =>
req(`/api/playlists/${id}/items/remove-bulk`, {
method: "POST",
body: JSON.stringify({ video_ids: videoIds }),
}),
// The Undo of a removal, in one request; an index in the stored order per video.
restoreToPlaylist: (
id: number,
videoIds: string[],
positions: number[],
): Promise<{ ok: true; playlist_id: number; added: number }> =>
req(`/api/playlists/${id}/items/restore`, {
method: "POST",
body: JSON.stringify({ video_ids: videoIds, positions }),
}), }),
reorderPlaylist: (id: number, videoIds: string[]) => reorderPlaylist: (id: number, videoIds: string[]) =>
req(`/api/playlists/${id}/order`, { req(`/api/playlists/${id}/order`, {
+28 -2
View File
@@ -10,9 +10,15 @@ import { useActiveScopes } from "./keymap";
afterEach(cleanup); afterEach(cleanup);
function Rows({ selection }: { selection?: NavSelection }) { function Rows({
selection,
onDelete,
}: {
selection?: NavSelection;
onDelete?: (index: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, selection }); useListNav(ref, { enabled: true, selection, onDelete });
return ( return (
<div ref={ref}> <div ref={ref}>
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
@@ -39,6 +45,7 @@ function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const selection = (cleared = true) => ({ const selection = (cleared = true) => ({
toggle: vi.fn<(index: number) => void>(), toggle: vi.fn<(index: number) => void>(),
clear: vi.fn(() => cleared), clear: vi.fn(() => cleared),
removeSelected: vi.fn(() => false),
}); });
describe("useListNav · selecting", () => { describe("useListNav · selecting", () => {
@@ -108,6 +115,25 @@ describe("useListNav · selecting", () => {
expect(idle.clear).toHaveBeenCalledTimes(1); expect(idle.clear).toHaveBeenCalledTimes(1);
}); });
it("has Delete go to the selection first, wherever the focus is", () => {
const s = selection();
s.removeSelected.mockReturnValue(true);
const onDelete = vi.fn();
render(<Rows selection={s} onDelete={onDelete} />);
expect(press("Delete").defaultPrevented).toBe(true);
expect(s.removeSelected).toHaveBeenCalledTimes(1);
expect(onDelete).not.toHaveBeenCalled();
});
it("has Delete go on to the walked row when there is no selection", () => {
const s = selection();
const onDelete = vi.fn();
render(<Rows selection={s} onDelete={onDelete} />);
press("j");
press("Delete");
expect(onDelete).toHaveBeenCalledExactlyOnceWith(0);
});
it("leaves both keys alone in a list that takes no selection", () => { it("leaves both keys alone in a list that takes no selection", () => {
render(<Rows />); render(<Rows />);
press("j"); press("j");
+9
View File
@@ -45,6 +45,9 @@ interface NavList {
export interface NavSelection { export interface NavSelection {
readonly toggle: (index: number) => void; readonly toggle: (index: number) => void;
readonly clear: () => boolean; readonly clear: () => boolean;
/** Delete: takes the selection out, wherever the focus is. False when there is none, and Delete
* goes on to the item under the keyboard. */
readonly removeSelected: () => boolean;
} }
const lists = new Map<HTMLElement, NavList>(); const lists = new Map<HTMLElement, NavList>();
@@ -432,6 +435,12 @@ function onKey(e: KeyboardEvent): void {
// On the item root, like Enter — and only in a list that registered a remover. // On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => { remove: (ev) => {
const cur = current(); const cur = current();
for (const list of ordered()) {
if (!list.selection()?.removeSelected()) continue;
ev.preventDefault();
if (cur) refocusSlot(cur.list, cur.index, cur.item);
return;
}
const remove = cur?.list.remove(); const remove = cur?.list.remove();
if (!cur || !remove || document.activeElement !== cur.item) return; if (!cur || !remove || document.activeElement !== cur.item) return;
ev.preventDefault(); ev.preventDefault();
+10 -1
View File
@@ -51,6 +51,9 @@ export interface Column<T> {
// one beside a panel: a secondary column that gives its room back to the name (the owner, // one beside a panel: a secondary column that gives its room back to the name (the owner,
// 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule. // 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule.
tabletHidden?: boolean; tabletHidden?: boolean;
// …and one it leaves out while it is narrower than 24rem, a phone beside the nav rail: there the
// title kept 44 px. Only for a column whose job has another way (the grip: the row menu's moves).
phoneHidden?: boolean;
} }
// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and, // The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
@@ -71,14 +74,20 @@ const HIDE_BELOW = {
col: " hidden @5xl:table-column", col: " hidden @5xl:table-column",
cue: " @5xl:hidden", cue: " @5xl:hidden",
}, },
phone: {
cell: " hidden @sm:table-cell",
col: " hidden @sm:table-column",
cue: " @sm:hidden",
},
} as const; } as const;
export function columnHideClass( export function columnHideClass(
col: { narrowHidden?: boolean; tabletHidden?: boolean }, col: { narrowHidden?: boolean; tabletHidden?: boolean; phoneHidden?: boolean },
part: "cell" | "col" | "cue" = "cell", part: "cell" | "col" | "cue" = "cell",
): string { ): string {
if (col.narrowHidden) return HIDE_BELOW.narrow[part]; if (col.narrowHidden) return HIDE_BELOW.narrow[part];
if (col.tabletHidden) return HIDE_BELOW.tablet[part]; if (col.tabletHidden) return HIDE_BELOW.tablet[part];
if (col.phoneHidden) return HIDE_BELOW.phone[part];
return ""; return "";
} }
@@ -129,6 +129,22 @@ describe("useRowSelection", () => {
expect(result.current.shownSelected).toEqual(["c"]); expect(result.current.shownSelected).toEqual(["c"]);
}); });
it("counts the selected rows a filter hides", () => {
const { result, rerender } = setup();
act(() => result.current.toggle("a"));
act(() => result.current.toggle("c"));
expect(result.current.hiddenCount).toBe(0);
rerender({ all: ABCDE, shown: ["c", "d"], scope: 1 });
expect(result.current.hiddenCount).toBe(1);
});
it("ticks rows again on top of what is selected by then", () => {
const { result } = setup();
act(() => result.current.toggle("e"));
act(() => result.current.select(["a", "b"]));
expect(result.current.shownSelected).toEqual(["a", "b", "e"]);
});
it("says whether clearing did anything", () => { it("says whether clearing did anything", () => {
const { result } = setup(); const { result } = setup();
let cleared = true; let cleared = true;
+7
View File
@@ -59,8 +59,14 @@ export function useRowSelection(all: readonly string[], shown: readonly string[]
return had; return had;
} }
/** Tick these again: an action that took them off and failed. */
function select(ids: readonly string[]) {
setSelected((now) => new Set([...now, ...ids]));
}
return { return {
shownSelected, shownSelected,
hiddenCount: selected.size - shownSelected.length,
isSelected: (id: string) => selected.has(id), isSelected: (id: string) => selected.has(id),
headerState: headerState:
shownSelected.length === 0 shownSelected.length === 0
@@ -71,5 +77,6 @@ export function useRowSelection(all: readonly string[], shown: readonly string[]
toggle, toggle,
toggleAllShown, toggleAllShown,
clear, clear,
select,
}; };
} }
@@ -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 // 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. // optimistic change used to stay on screen for good.
it("puts back a row whose move failed", async () => { 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" }), new HttpError(404, "gone", { code: "not_found", reason: "move_target" }),
); );
mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 }); 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. // 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 () => { 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 }); mount({ 1: [vid("V", 0)], [WATCH_LATER]: [] }, { lastMovePlaylistId: WATCH_LATER });
await rowMenu("Video V", "playlists.moveToRecent"); await rowMenu("Video V", "playlists.moveToRecent");
await waitFor(() => await waitFor(() =>
@@ -1127,3 +1131,362 @@ describe("Playlists — selecting rows", () => {
expect(screen.queryAllByRole("checkbox", { name: /playlists\.select/ })).toEqual([]); 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 { hasResumePoint } from "../../lib/api/feed";
import { import {
addPlaylistItem, addPlaylistItem,
movePlaylistItem, movePlaylistItems,
removePlaylistItem, removePlaylistItem,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
clearVideoProgress, clearVideoProgress,
writeVideoState, writeVideoState,
} from "../../features/video/videoWrites"; } from "../../features/video/videoWrites";
@@ -83,6 +86,7 @@ import {
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns"; import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle"; import { DragHandle } from "../../components/ui/DragHandle";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
import { useOrderItems } from "../../components/ui/useOrderItems"; import { useOrderItems } from "../../components/ui/useOrderItems";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls"; import { CheckRow } from "../../components/ui/NativeControls";
@@ -93,6 +97,7 @@ import { PageHeading } from "../../components/ui/Headings";
import { useCopyItems } from "../../components/ui/copyItems"; import { useCopyItems } from "../../components/ui/copyItems";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import PlaylistsHeader from "./PlaylistsHeader"; import PlaylistsHeader from "./PlaylistsHeader";
import { SelectionBand } from "./SelectionBand";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a // The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list. // body that is still a drag-reorderable @dnd-kit list.
@@ -116,6 +121,13 @@ const idsKey = (items: Video[]) =>
.sort() .sort()
.join(","); .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({ function Row({
video, video,
index, index,
@@ -455,12 +467,24 @@ function PlaylistDetail() {
shown.map((v) => v.id), shown.map((v) => v.id),
selectedId, 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 const tableColumns = canEdit
? [ ? [
selectColumn({ selectColumn({
t, t,
state: selection.headerState, allBox: (
onToggleAll: selection.toggleAllShown, <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, isSelected: selection.isSelected,
onToggle: selection.toggle, onToggle: selection.toggle,
}), }),
@@ -469,6 +493,8 @@ function PlaylistDetail() {
: columns; : columns;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive; 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 // 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. // offers itself when the view is a pure reordering of the whole list.
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items); const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
@@ -637,42 +663,207 @@ function PlaylistDetail() {
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId, linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
); );
async function moveItemTo(video: Video, target: Playlist) { // Off the screen at once, and out of the cached list too, for the reason `removeItem` gives. Returns
if (selectedId == null) return; // the cached list as it stood: the place an Undo goes back to, and what a failure puts back.
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and function takeOut(listId: number, ids: readonly string[]) {
// rebasing keeps the undo stack valid for what's left instead of throwing it away. const gone = new Set(ids);
const next = items.filter((v) => v.id !== video.id); const next = order.value.filter((v) => !gone.has(v.id));
order.rebase((list) => list.filter((v) => v.id !== video.id)); order.rebase((list) => list.filter((v) => !gone.has(v.id)));
lastSetRef.current = `${selectedId}:${idsKey(next)}`; lastSetRef.current = `${listId}:${idsKey(next)}`;
// Outside the `try`: a `??` inside it makes the React Compiler skip the component. const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
const title = video.title ?? ""; qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
try { old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
// 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. return before;
await movePlaylistItem(qc, listRef(selectedId), video.id, target); }
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a // The write failed: the rows come back now (see `restoreFromCache`), ticked again if they were, so
// shortcut to failing again. // the reader can try once more without picking them all over.
setLastMoveTargetId(target.id); function putBack(listId: number, before: PlaylistDetail | undefined, ticked: readonly string[]) {
notify({ if (before) qc.setQueryData(qk.playlist(listId), before);
level: "success", restoreFromCache(listId);
message: msg("playlists.movedToast", { title: raw(title), name: playlistNameText(target) }), if (listId === selectedRef.current) selection.select(ticked);
}); }
} catch (e) { // BOTH lists of a move changed, and so did these videos' membership. `qk.playlist(id)` is an exact
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and // key, so it cannot cover the target: the whole `["playlist"]` prefix, as AddToPlaylist does.
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already function refreshAfterEdit(ids: readonly string[]) {
// 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.
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() }); 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 // 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) }), label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
icon: <History className="w-4 h-4" />, icon: <History className="w-4 h-4" />,
separatorBefore: true, separatorBefore: true,
onSelect: () => void moveItemTo(ctxRow, recentMoveTarget), onSelect: () => void moveRows([ctxRow], recentMoveTarget),
}, },
] ]
: []), : []),
@@ -804,13 +995,8 @@ function PlaylistDetail() {
items: moveTargets.map((pl) => ({ items: moveTargets.map((pl) => ({
id: `move-${pl.id}`, id: `move-${pl.id}`,
label: plName(pl), label: plName(pl),
icon: icon: moveTargetIcon(pl),
pl.source === "youtube" || pl.yt_playlist_id ? ( onSelect: () => void moveRows([ctxRow], pl),
<YouTubeIcon className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
onSelect: () => void moveItemTo(ctxRow, pl),
})), })),
} }
: // Kept visible and disabled rather than dropped: an action that vanishes teaches : // 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 headerPl = headerMenu.target;
const headerItems: ActionMenuItem[] = !headerPl const headerItems: ActionMenuItem[] = !headerPl
? [] ? []
@@ -1035,64 +1239,91 @@ function PlaylistDetail() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-1 flex-wrap"> {bandUp ? (
<span className="text-xs text-muted"> <SelectionBand
{t("playlists.itemCount", { count: items.length })} ref={bandRef}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`} count={selection.shownSelected.length}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} hidden={selection.hiddenCount}
</span> recentName={recentMoveTarget ? plName(recentMoveTarget) : null}
{(items.length > 1 || viewActive) && ( onMoveRecent={() => recentMoveTarget && moveSelected(recentMoveTarget)}
<> moveMenuProps={moveMenu.buttonProps("selection")}
<span aria-hidden="true" className="text-xs text-muted/40"> moveMenuOpen={moveMenu.open}
· onRemove={removeSelected}
</span> onClear={() => {
<CheckRow leaveBand();
checked={groupBy} selection.clear();
onChange={setGroupBy} }}
className="text-xs text-muted" onClearView={viewActive ? clearView : undefined}
> trailing={
{t("playlists.groupByChannel")} <UndoToolbar
</CheckRow> canUndo={order.canUndo}
{viewActive && ( canRedo={order.canRedo}
<Button onUndo={order.undo}
variant="link-muted" onRedo={order.redo}
onClick={clearView} />
className="text-xs underline underline-offset-2" }
/>
) : (
<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")} {t("playlists.groupByChannel")}
</Button> </CheckRow>
)} {viewActive && (
{canSaveOrder && ( <Button
<Button variant="link-muted"
variant="outline-accent" onClick={clearView}
size="xs" className="text-xs underline underline-offset-2"
onClick={saveOrder} >
icon={<Check className="w-3.5 h-3.5" />} {t("playlists.clearView")}
> </Button>
{t("playlists.saveOrder")} )}
</Button> {canSaveOrder && (
)} <Button
{/* ONE sentence that names the state. Three loose texts used to pile up here 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 (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, question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a 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. 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 Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */} still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>} {viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && ( {canEdit && (
<div className="ml-auto"> <div className="ml-auto">
<UndoToolbar <UndoToolbar
canUndo={order.canUndo} canUndo={order.canUndo}
canRedo={order.canRedo} canRedo={order.canRedo}
onUndo={order.undo} onUndo={order.undo}
onRedo={order.redo} onRedo={order.redo}
/> />
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1160,7 +1391,15 @@ function PlaylistDetail() {
const v = shownRef.current[i]; const v = shownRef.current[i];
if (v) selection.toggle(v.id); 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 : undefined
} }
@@ -1192,6 +1431,11 @@ function PlaylistDetail() {
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */} {/* 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={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} /> <ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
<ContextMenu
menu={moveMenu}
ariaLabel={t("playlists.moveSelected")}
items={moveMenuItems}
/>
<ContextMenu <ContextMenu
menu={pageMenu} menu={pageMenu}
ariaLabel={t("common.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; more: (v: Video) => ReactNode;
}): Column<Video>[] { }): Column<Video>[] {
const cols: 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", key: "pos",
header: "#", 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. */ * `playlistColumns`, whose array the view's filter and sort read: a tick must not re-run them. */
export function selectColumn({ export function selectColumn({
t, t,
state, allBox,
onToggleAll,
isSelected, isSelected,
onToggle, onToggle,
}: { }: {
t: TFunction; t: TFunction;
state: "none" | "some" | "all"; /** The box over the column. Built by the page, which holds a ref to it. */
onToggleAll: () => void; allBox: ReactNode;
isSelected: (id: string) => boolean; isSelected: (id: string) => boolean;
onToggle: (id: string, range: boolean) => void; onToggle: (id: string, range: boolean) => void;
}): Column<Video> { }): Column<Video> {
const allLabel = t("playlists.selectAllShown");
return { return {
key: "select", key: "select",
header: allLabel, header: t("playlists.selectAllShown"),
headerCell: ( headerCell: allBox,
<RowSelectBox
label={allLabel}
title={allLabel}
checked={state === "all"}
mixed={state === "some"}
onToggle={onToggleAll}
/>
),
width: "2.25rem", width: "2.25rem",
hideInCard: true, hideInCard: true,
render: (v) => ( render: (v) => (