feat(playlists): hybrid table over the drag list, and guard its destructive paths

The column sort and the filters are a VIEW; the playlist's stored order only
changes when the user presses "Save this order", which still goes through the
undoable path. Picking a sort used to write straight to the server, so merely
looking at the list by title rewrote the playlist and marked a YouTube-linked
mirror dirty.

The table:
- playlistColumns.tsx holds the column defs; the body renders them as table rows
  and fills the grip cell itself, since the handle needs the row's sortable
  listeners that a render closure can't reach.
- The # column shows the STORED position, so a view-sort never renumbers the
  playlist under the reader.
- Saving is withheld while a filter is active (it would drop the hidden items),
  and drag is disabled under any view, with the reason spelled out rather than
  silently doing nothing.
- applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the
  channel grouping compose, and getting that wrong produces a plausible order
  that the save button would then persist.
- dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder",
  but only a pointer could move it.

The guards:
- Delete is ONE dialog with every outcome. It was two chained confirms where the
  second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the
  dialog deleted the playlist. ConfirmProvider gained useChoice() for it.
- ConfirmProvider focuses Cancel when an offered action is destructive; the
  confirm button took autoFocus unconditionally, so a stray Enter on an open
  dialog ran "Delete user" or "Clear audit log".
- Removing an item asks first, and rebases the undo history instead of throwing
  it away: useUndoable.rebase maps every snapshot, so the reorder history stays
  valid for the remaining items.
- A failed detail query showed "Loading…" forever; it now says so and offers a
  retry. Rename and reorder failures are surfaced instead of swallowed (reorder
  was a bare .then(), which also produced an unhandled rejection).
- Local reorder/remove no longer require YouTube write scope.
- playlistName() replaces the watch-later localizer copied into three files.
This commit is contained in:
2026-07-20 23:30:49 +02:00
parent 89000538d4
commit 83e673718f
13 changed files with 851 additions and 437 deletions
+2 -1
View File
@@ -17,5 +17,6 @@
"accessRequestsReview": "Review",
"demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top"
"backToTop": "Back to top",
"retry": "Retry"
}
+21 -7
View File
@@ -42,16 +42,10 @@
"pushDone": "“{{name}}” synced to YouTube ✓",
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
"pushFailed": "Couldn't sync to YouTube.",
"deleteOnYoutubeTitle": "Delete on YouTube too?",
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
"deleteOnYoutubeConfirm": "Delete on YouTube",
"deleteHereOnly": "Here only",
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
"sortLabel": "Sort",
"sortManual": "Custom order",
"sortTitle": "Title",
"sortDuration": "Duration",
"sortChannel": "Channel",
"dirAsc": "Ascending",
"dirDesc": "Descending",
"groupByChannel": "Group by channel",
@@ -59,5 +53,25 @@
"railSortName": "Name",
"railSortCount": "Item count",
"railSortDuration": "Total length",
"dirtyFirst": "Unsynced first"
"dirtyFirst": "Unsynced first",
"colTitle": "Title",
"colChannel": "Channel",
"colDuration": "Length",
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"confirmRemove": "Remove “{{title}}” from this playlist?",
"dragHandle": "Drag to reorder",
"dragHandleOf": "Reorder “{{title}}”",
"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.",
"clearView": "Reset view",
"saveOrder": "Save this order",
"saveOrderHint": "Store the order you see as the playlist's own order (undoable).",
"filteredCount": "{{count}} shown",
"noneMatchFilter": "No videos match the filter.",
"loadFailed": "Couldn't load this playlist.",
"reorderFailed": "Couldn't save the new order.",
"renameFailed": "Couldn't rename the playlist.",
"deleteFailed": "Couldn't delete the playlist.",
"confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone."
}
+2 -1
View File
@@ -17,5 +17,6 @@
"accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók",
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére"
"backToTop": "Vissza a tetejére",
"retry": "Újra"
}
+21 -7
View File
@@ -42,16 +42,10 @@
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
"deleteHereOnly": "Csak itt",
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
"sortLabel": "Rendezés",
"sortManual": "Egyéni sorrend",
"sortTitle": "Cím",
"sortDuration": "Hossz",
"sortChannel": "Csatorna",
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"groupByChannel": "Csoportosítás csatorna szerint",
@@ -59,5 +53,25 @@
"railSortName": "Név",
"railSortCount": "Elemszám",
"railSortDuration": "Összhossz",
"dirtyFirst": "Nem szinkronizáltak elöl"
"dirtyFirst": "Nem szinkronizáltak elöl",
"colTitle": "Cím",
"colChannel": "Csatorna",
"colDuration": "Hossz",
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
"dragHandle": "Húzd az átrendezéshez",
"dragHandleOf": "„{{title}}” átrendezése",
"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.",
"clearView": "Nézet visszaállítása",
"saveOrder": "Sorrend mentése",
"saveOrderHint": "A látott sorrend legyen a lista saját sorrendje (visszavonható).",
"filteredCount": "{{count}} látszik",
"noneMatchFilter": "Egy videó sem felel meg a szűrőnek.",
"loadFailed": "Nem sikerült betölteni ezt a listát.",
"reorderFailed": "Nem sikerült elmenteni az új sorrendet.",
"renameFailed": "Nem sikerült átnevezni a listát.",
"deleteFailed": "Nem sikerült törölni a listát.",
"confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza."
}