diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py index 8786584d..687ff2ba 100644 --- a/backend/app/routes/plex/playlists.py +++ b/backend/app/routes/plex/playlists.py @@ -3,7 +3,9 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends -from pydantic import BaseModel +from typing import Annotated + +from pydantic import BaseModel, Field, model_validator from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session @@ -285,16 +287,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: return out +class PlexItemAddBulkIn(PlexItemKeysIn): + # Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the + # ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows + # at the end whenever the second call fails, and the order it sends would overwrite anything + # another tab changed in between. + positions: list[Annotated[int, Field(ge=0)]] | None = None + + @model_validator(mode="after") + def _one_position_per_key(self): + if self.positions is not None and len(self.positions) != len(self.item_rating_keys): + raise ValueError("positions must pair one-to-one with item_rating_keys") + return self + + +def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int: + """Insert new rows at their indices (ascending, so each index is where the row stood when the + rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item + already in the list does not move: the add stays idempotent.""" + rows = list( + db.execute( + select(PlexPlaylistItem) + .where(PlexPlaylistItem.playlist_id == pid) + .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) + ).scalars() + ) + present = {r.item_id for r in rows} + added = 0 + for at, it in sorted(placed, key=lambda p: p[0]): + if it.id in present: + continue + row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0) + db.add(row) + rows.insert(min(at, len(rows)), row) + present.add(it.id) + added += 1 + for i, r in enumerate(rows): + r.position = i + return added + + @router.post("/playlists/{pid}/items/bulk") def playlist_add_bulk( pid: int, - body: PlexItemKeysIn, + body: PlexItemAddBulkIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Append many items at once (a whole season/show). Already-present items are skipped; the input - order is preserved for the newly-added ones. Returns how many were added + the new total.""" + order is preserved for the newly-added ones. With `positions`, each key is put back at its index + instead of appended. Returns how many were added + the new total.""" pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") + if body.positions is not None: + by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)} + placed = [ + (at, by_rk[str(rk)]) + for rk, at in zip(body.item_rating_keys, body.positions, strict=True) + if str(rk) in by_rk + ] + added = _insert_at(db, pid, placed) + if added: + pl.updated_at = datetime.now(timezone.utc) + db.commit() + total = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid) + ) + or 0 + ) + return {"ok": True, "added": added, "item_count": total} items = _items_by_rks(db, body.item_rating_keys) existing = set( db.execute( diff --git a/backend/tests/test_plex_playlist_add_positions.py b/backend/tests/test_plex_playlist_add_positions.py new file mode 100644 index 00000000..4da18967 --- /dev/null +++ b/backend/tests/test_plex_playlist_add_positions.py @@ -0,0 +1,113 @@ +"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo". + +A removal can take one row, a season or a whole show, and a season's episodes need not sit together +(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED +rows, since positions have gaps after a removal. One request: an append plus a second reorder call +leaves the rows at the end whenever that call fails, and would overwrite another tab's changes. +""" + +from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem + + +def _seed(db, user, rks, *, positions=None, extra=("x", "y")): + lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + items = {} + for rk in [*rks, *extra]: + it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper()) + db.add(it) + items[rk] = it + pl = PlexPlaylist(user_id=user.id, title="L") + db.add(pl) + db.flush() + for i, rk in enumerate(rks): + pos = (positions or range(99))[i] + db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos)) + db.commit() + return pl + + +def _order(db, pid): + db.expire_all() + rows = db.execute( + PlexPlaylistItem.__table__.select() + .where(PlexPlaylistItem.playlist_id == pid) + .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) + ).all() + by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()} + return [by_id[r.item_id] for r in rows], [r.position for r in rows] + + +def _add(client, pid, keys, positions=None): + body = {"item_rating_keys": keys} + if positions is not None: + body["positions"] = positions + return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body) + + +def test_one_row_goes_back_to_its_index(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a", "b", "c"]) + r = _add(as_user(user), pl.id, ["x"], [1]) + assert r.status_code == 200 + assert r.json()["added"] == 1 + assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3]) + + +def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user): + # A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of + # a, x, b, y, c. Given in any order, each lands where it stood. + user = make_user() + pl = _seed(db, user, ["a", "b", "c"]) + _add(as_user(user), pl.id, ["y", "x"], [3, 1]) + assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"] + + +def test_indices_count_rows_not_stored_numbers(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20]) + _add(as_user(user), pl.id, ["x"], [2]) + assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3]) + + +def test_an_index_past_the_end_appends(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a"]) + _add(as_user(user), pl.id, ["x"], [50]) + assert _order(db, pl.id)[0] == ["a", "x"] + + +def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a", "b", "c"]) + r = _add(as_user(user), pl.id, ["c"], [0]) + assert r.json()["added"] == 0 + assert _order(db, pl.id)[0] == ["a", "b", "c"] + + +def test_without_positions_it_still_appends(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a", "b"]) + _add(as_user(user), pl.id, ["y", "x"]) + assert _order(db, pl.id)[0] == ["a", "b", "y", "x"] + + +def test_positions_must_pair_with_the_keys(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a"]) + assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422 + assert _add(as_user(user), pl.id, ["x"], [-1]).status_code == 422 + assert _order(db, pl.id)[0] == ["a"] + + +def test_remove_then_put_back_is_no_change(db, make_user, as_user): + user = make_user() + pl = _seed(db, user, ["a", "b", "c", "d"], extra=()) + client = as_user(user) + client.post( + f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]} + ) + assert _order(db, pl.id)[0] == ["a", "c"] + _add(client, pl.id, ["b", "d"], [1, 3]) + assert _order(db, pl.id)[0] == ["a", "b", "c", "d"] diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 20fd549d..f5a49278 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -93,8 +93,8 @@ export default function ChannelDiscovery({ ); const columns: Column[] = [ - // Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two - // tabs' action clusters line up in the same place. + // Subscribe first: it is this table's primary action, the one thing a discovery row is for (the + // Subscriptions table has no such button — its row ends in the channel's "…", E28 C1). { key: "actions", header: t("channels.cols.actions"), diff --git a/frontend/src/components/ChannelLayoutGrid.dom.test.tsx b/frontend/src/components/ChannelLayoutGrid.dom.test.tsx index ea5e8e27..7fdbb01f 100644 --- a/frontend/src/components/ChannelLayoutGrid.dom.test.tsx +++ b/frontend/src/components/ChannelLayoutGrid.dom.test.tsx @@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => { expect(opened).toEqual(["UCb"]); }); }); + +describe('ChannelLayoutGrid · the card\'s "…"', () => { + it("docks it in the heading row beside the name, not in the meta line", () => { + const columns: Column[] = [ + { key: "channel", header: "Channel", cardPrimary: true, render: (c) => {c.title} }, + { key: "stored", header: "Stored", render: () => 12 }, + { key: "actions", header: "", render: (c) => … }, + ]; + const { getByTestId, getByText } = render( + c.id} moreKey="actions" />, + ); + const heading = getByText("Alpha").closest("div")!.parentElement!; + expect(heading.contains(getByTestId("more-UCa"))).toBe(true); + // Exactly once: a slotted column is not also painted in the meta line. + expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index 79c9ecaa..dfde3c12 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -28,6 +28,9 @@ interface ChannelLayoutGridProps { aboutKey?: string; /** Column key rendered as the action cluster, pinned to the card's bottom edge. */ actionsKey?: string; + /** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which + * belongs beside the name it acts on, not under the card's last line. */ + moreKey?: string; rowClassName?: (row: T) => string; /** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so * a channel answers the right-click in the card layout exactly as it does in the table. */ @@ -42,6 +45,7 @@ export default function ChannelLayoutGrid({ leadKey, aboutKey, actionsKey, + moreKey, rowClassName, rowProps, emptyText, @@ -49,11 +53,12 @@ export default function ChannelLayoutGrid({ const { t } = useTranslation(); const spec = CHANNEL_LAYOUT_SPEC.cards; - const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]); + const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]); const primary = columns.filter((c) => c.cardPrimary); const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined; const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined; const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined; + const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined; // Everything else flows in the meta line: not the heading, not a slotted column. const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key)); @@ -70,6 +75,7 @@ export default function ChannelLayoutGrid({ ))} {lead &&
{lead.render(row)}
} + {more &&
{more.render(row)}
} {about &&
{about.render(row)}
}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index cfaa153a..3d84ee93 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -16,8 +16,10 @@ import Tooltip from "./ui/Tooltip"; import DataTable from "./ui/DataTable"; import Tabs, { type TabDef } from "./ui/Tabs"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; -import { ComfortPinMenu } from "./ComfortPinMenu"; -import { pinChannel } from "./comfortPin"; +import { pinChannel, useComfortPin } from "./comfortPin"; +import { ContextMenu } from "./ui/ContextMenu"; +import { MoreButton } from "./ui/MoreButton"; +import { useChannelActions } from "./useChannelActions"; import { useContextMenu } from "./ui/useContextMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useChannelTagActions } from "../lib/useChannelTagActions"; @@ -37,7 +39,6 @@ import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./ui/PageShell"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; -import { useConfirm } from "./confirmContext"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { useNavigationActions } from "./navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; @@ -64,7 +65,6 @@ const LAYOUT_ICON: Record = { export default function Channels() { const me = useMe(); const canWrite = me.can_write; - const isAdmin = me.role === "admin"; // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). const { openWizard: onOpenWizard } = useWizard(); // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live @@ -94,7 +94,6 @@ export default function Channels() { const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { t } = useTranslation(); const qc = useQueryClient(); - const confirm = useConfirm(); // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App // callback) now that Channels reads the feed filters + navigation straight from their providers. @@ -130,21 +129,6 @@ export default function Channels() { const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); - const patch = useMutation({ - mutationFn: (v: { - id: string; - body: { priority?: number; hidden?: boolean; deep_requested?: boolean }; - }) => api.updateChannel(v.id, v.body), - meta: { failTitle: "errors.writeFailed.channelSettings" }, - // Requesting full history may have just pulled in a channel's recent uploads, so - // refresh the per-user status and feed too — not only the channel list. - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.channels() }); - qc.invalidateQueries({ queryKey: qk.myStatus() }); - qc.invalidateQueries({ queryKey: qk.feed() }); - qc.invalidateQueries({ queryKey: qk.feedSummary() }); - }, - }); // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't // jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll // position). The new order takes effect next time the page loads. Press-and-hold is debounced + @@ -216,25 +200,6 @@ export default function Channels() { }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), }); - const unsubscribe = useMutation({ - mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), - onSuccess: (_d, v) => { - qc.invalidateQueries({ queryKey: qk.channels() }); - qc.invalidateQueries({ queryKey: qk.myStatus() }); - notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); - }, - onError: (e) => - notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), - }); - const resetBackfill = useMutation({ - mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), - onSuccess: (_d, v) => { - qc.invalidateQueries({ queryKey: qk.channels() }); - qc.invalidateQueries({ queryKey: qk.myStatus() }); - notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); - }, - onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), - }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { @@ -328,24 +293,19 @@ export default function Channels() { const onView = (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); - const onUnsub = async (c: ManagedChannel) => { - const ok = await confirm({ - title: t("channels.row.unsubscribeOnYoutube"), - message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), - confirmLabel: t("channels.row.unsubscribeOnYoutube"), - danger: true, - }); - if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id }); - }; - const onReset = async (c: ManagedChannel) => { - const ok = await confirm({ - title: t("channels.row.backfillThis"), - message: t("channels.confirmReset", { name: c.title ?? c.id }), - confirmLabel: t("channels.row.backfillThis"), - }); - if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id }); - }; - + // The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card + // layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…" + // must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery + // result is not a `Channel` row yet, so pinning or hiding one could only 404. + const rowMenu = useContextMenu(); + const rowPin = useComfortPin(rowMenu.open); + const channelActions = useChannelActions({ onView, onOpenWizard }); + const menuRow = rowMenu.target; + const menuItems = menuRow + ? channelActions.items(menuRow, { + pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")), + }) + : []; // Distinct priority levels present in the catalog → the priority column's filter options. const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))] .sort((a, b) => b - a) @@ -353,16 +313,21 @@ export default function Channels() { const columns = buildChannelColumns({ t, - isAdmin, - canWrite, prioOptions, userTags, bumpPriority, setPriority, - onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }), - onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }), - onReset, - onUnsub, + more: (c) => { + const mine = rowMenu.open && rowMenu.target === c; + return ( + + ); + }, onView, toggleTag, createAndAttachTag, @@ -430,11 +395,6 @@ export default function Channels() { /> ); const hasLayout = view === "subscribed" || view === "discovery"; - // The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and - // the card layout — they are two paints of the same rows, so a right-click must mean the same - // thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a - // `Channel` row yet, so pinning one could only 404. - const pinMenu = useContextMenu(); // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks. const sortControl = ( @@ -602,7 +562,7 @@ export default function Channels() { emptyText={t("channels.hidden.empty")} actionLabel={t("channels.hidden.unhide")} actionIcon={} - onAction={(id) => patch.mutate({ id, body: { hidden: false } })} + onAction={channelActions.unhide} onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} />
@@ -637,7 +597,9 @@ export default function Channels() { onFiltersChange={setFilters} fixedLayout emptyText={t("channels.empty")} - rowProps={pinMenu.triggerProps} + rowProps={rowMenu.triggerProps} + // `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch. + rowClassName={() => "group"} keyboardNav /> ) : ( @@ -647,16 +609,13 @@ export default function Channels() { rowKey={(c) => c.id} leadKey="priority" aboutKey="about" - actionsKey="actions" + moreKey="actions" emptyText={t("channels.empty")} - rowProps={pinMenu.triggerProps} + rowProps={rowMenu.triggerProps} + rowClassName={() => "group"} /> )} - pinChannel(c.id)} - title={(c) => c.title ?? t("channels.row.thisChannel")} - /> + )} diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx index e43a00e0..ce1b22fe 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -61,6 +61,20 @@ const client = () => new QueryClient({ defaultOptions: { queries: { retry: false const movie = (id: string) => ({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; +/** Pick `label` from the menu `open` opens. The surface is visibility:hidden in jsdom (floating-ui + * never positions it), so the row is matched on its text. */ +async function pickFromMenu(open: () => void, label: string) { + open(); + const row = await waitFor(() => { + const found = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === label); + expect(found).toBeTruthy(); + return found!; + }); + fireEvent.click(row); +} + describe("the Plex playlist page", () => { it("puts back a row whose removal failed, and says why", async () => { vi.spyOn(api, "plexPlaylist").mockResolvedValue({ @@ -69,13 +83,20 @@ describe("the Plex playlist page", () => { items: [movie("1"), movie("2")], }); vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist")); + // The row's menu reads the comfort shelf (its pin row) once it opens: answered here, or its + // failed fetch lands its notice in whichever test runs next. + vi.spyOn(api, "comfortPins").mockResolvedValue([] as never); render( {}} onPlay={() => {}} /> , ); await screen.findByText("Movie 1"); - fireEvent.click(screen.getByLabelText("plex.playlist.removeItem:Movie 1")); + // E28 C1: the row's X went into its menu (right-click here, the "…" beside it on screen). + await pickFromMenu( + () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), + "plex.playlist.removeFromPlaylist", + ); await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(screen.getByText("Movie 1")).toBeTruthy(); @@ -90,8 +111,11 @@ describe("the Plex playlist page", () => { , ); await screen.findByText("Films"); - fireEvent.click(screen.getByTitle("plex.playlist.rename")); - const field = screen.getByDisplayValue("Films"); + await pickFromMenu( + () => fireEvent.click(screen.getByRole("button", { name: "plex.playlist.menu" })), + "plex.playlist.rename", + ); + const field = await screen.findByDisplayValue("Films"); fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.keyDown(field, { key: "Enter" }); await waitFor(() => expect(errors()).toEqual(["gone playlist"])); @@ -107,7 +131,11 @@ describe("the Plex playlist page", () => { {}} /> , ); - fireEvent.click(await screen.findByTitle("plex.playlist.delete")); + await pickFromMenu( + async () => + fireEvent.click(await screen.findByRole("button", { name: "plex.playlist.menu" })), + "plex.playlist.delete", + ); await waitFor(() => expect(onBack).toHaveBeenCalled()); }); }); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4ad5e188..1731923b 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -417,6 +417,7 @@ export default function PlexBrowse() { playlistId={pid} onBack={sub.back} onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })} + onInfoCard={onInfo} /> ); diff --git a/frontend/src/components/PlexPlaylistView.header.dom.test.tsx b/frontend/src/components/PlexPlaylistView.header.dom.test.tsx index 405720d8..46201a64 100644 --- a/frontend/src/components/PlexPlaylistView.header.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.header.dom.test.tsx @@ -5,7 +5,7 @@ // (E9 review). The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list // nothing sums for says no runtime at all. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ @@ -60,6 +60,20 @@ async function meta(items: PlexCard[]) { ).textContent; } +/** Pick `label` from the menu `open` opens. The surface is visibility:hidden in jsdom (floating-ui + * never positions it), so the row is matched on its text. */ +async function pickFromMenu(open: () => void, label: string) { + open(); + const row = await waitFor(() => { + const found = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === label); + expect(found).toBeTruthy(); + return found!; + }); + fireEvent.click(row); +} + describe("PlexPlaylistView · the header's meta line", () => { it("sums the known runtimes and counts an unknown one as nothing", async () => { expect(await meta([movie("1", 5400), movie("2"), movie("3", 1800)])).toBe( @@ -80,8 +94,11 @@ describe("PlexPlaylistView · rename", () => { const write = vi.spyOn(api, "plexRenamePlaylist").mockResolvedValue({} as never); await meta([movie("1", 5400)]); for (const value of [null, " "]) { - fireEvent.click(screen.getByRole("button", { name: "plex.playlist.rename" })); - const field = screen.getByRole("textbox", { name: "plex.playlist.rename" }); + await pickFromMenu( + () => fireEvent.click(screen.getByRole("button", { name: "plex.playlist.menu" })), + "plex.playlist.rename", + ); + const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" }); if (value !== null) fireEvent.change(field, { target: { value } }); fireEvent.keyDown(field, { key: "Enter" }); await screen.findByRole("heading", { name: "Films" }); diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx new file mode 100644 index 00000000..b208e1c4 --- /dev/null +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -0,0 +1,253 @@ +// @vitest-environment jsdom +// +// E28 C1 on the Plex playlist page: a title, a show group and a season each open ONE menu (the "…" +// beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no +// confirm, since the toast's Undo puts the rows back; `j`/`k` walk the visible rows and Delete takes +// out the one the keyboard is on; the playlist's rename and delete sit in the header's "…". +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), + i18n: { language: "en" }, + }), +})); +const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) })); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: (...a: unknown[]) => h.notify(...a), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); + +import PlexPlaylistView from "./PlexPlaylistView"; +import { api, type PlexCard } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + h.notify.mockReset(); + h.confirm.mockReset(); + h.confirm.mockImplementation(async () => true); +}); + +const movie = (id: string) => + ({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; +const ep = (id: string, season: number, n: number) => + ({ + id, + type: "episode", + title: `Ep ${id}`, + show_id: "S", + show_title: "The Show", + season_number: season, + episode_number: n, + thumb: "", + }) as unknown as PlexCard; + +function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void) { + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never); + vi.spyOn(api, "comfortPins").mockResolvedValue([] as never); + const onPlay = vi.fn(); + const onBack = vi.fn(); + render( + + + , + ); + return { onPlay, onBack }; +} + +const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +async function openMenu(open: () => void) { + open(); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} +async function pick(open: () => void, label: string) { + await openMenu(open); + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, + ); +} +const moreOf = (text: string) => + rowOf(text).querySelector('button[aria-label="plex.playlist.rowMenu"]')!; +const toast = () => + h.notify.mock.calls + .map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } }) + .filter((n) => n.message?.startsWith("plex.playlist.removedToast")) + .pop(); +const pressKey = (key: string) => + act(() => { + (document.activeElement ?? document.body).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + }); + +describe("PlexPlaylistView · a row's menu", () => { + it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => { + mount([movie("1"), ep("e1", 1, 1)]); + await screen.findByText("Movie 1"); + expect(screen.queryAllByRole("button", { name: /plex\.playlist\.remove/ })).toHaveLength(0); + const more = moreOf("Movie 1"); + expect(more.classList.contains("reveal")).toBe(true); + expect(rowOf("Movie 1").classList.contains("group")).toBe(true); + expect(moreOf("The Show")).toBeTruthy(); + }); + + it("lists the title's own actions, then the removal", async () => { + const info = vi.fn(); + mount([movie("1")], info); + await screen.findByText("Movie 1"); + await openMenu(() => fireEvent.click(moreOf("Movie 1"))); + expect(menuRows()).toEqual([ + "plex.play", + "plex.markWatched", + "plex.playlistAdd.manage", + "plex.info.title", + "common.copy.title", + "home.pin.add", + "plex.playlist.removeFromPlaylist", + ]); + expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true"); + }); + + it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => { + mount([movie("1"), movie("2"), movie("3")]); + await screen.findByText("Movie 2"); + vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never); + const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never); + await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist"); + await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2")); + expect(h.confirm).not.toHaveBeenCalled(); + await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2")); + act(() => toast()!.action!.onClick()); + expect(add).toHaveBeenCalledWith(5, ["2"], [1]); + }); + + it("plays and removes a whole show from its group's menu", async () => { + const { onPlay } = mount([movie("1"), ep("e1", 1, 1), ep("e2", 2, 1)]); + await screen.findByText("The Show"); + await openMenu(() => fireEvent.click(moreOf("The Show"))); + expect(menuRows()).toEqual([ + "plex.playlist.playShow", + "common.copy.title", + "plex.playlist.removeShow", + ]); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "plex.playlist.playShow")!, + ); + await waitFor(() => expect(onPlay).toHaveBeenCalledWith("e1", ["1", "e1", "e2"])); + const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); + await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow"); + await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"])); + await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show")); + }); + + it("removes one season from its label's menu", async () => { + mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]); + fireEvent.click(await screen.findByText("The Show")); // open the group + const seasonLabel = await screen.findByText(/plex\.playlist\.season · 2/); + const more = seasonLabel.parentElement!.querySelector( + 'button[aria-label="plex.playlist.rowMenu"]', + )!; + const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); + await openMenu(() => fireEvent.click(more)); + expect(menuRows()).toEqual(["plex.playlist.playSeason", "plex.playlist.removeSeason"]); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "plex.playlist.removeSeason")!, + ); + await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e2", "e3"])); + }); +}); + +describe("PlexPlaylistView · the keyboard", () => { + it("walks the rows with j and takes out the walked one on Delete", async () => { + mount([movie("1"), movie("2"), movie("3")]); + await screen.findByText("Movie 3"); + vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never); + pressKey("j"); + pressKey("j"); + expect(document.activeElement).toBe(rowOf("Movie 2")); + pressKey("Delete"); + await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2")); + await waitFor(() => expect(screen.queryByText("Movie 2")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(rowOf("Movie 3"))); + }); + + it("walks an open show's episodes after its header, and Delete on the header takes the show", async () => { + mount([ep("e1", 1, 1), ep("e2", 1, 2), movie("9")]); + fireEvent.click(await screen.findByText("The Show")); + await screen.findByText("Ep e2"); + pressKey("j"); + expect(document.activeElement).toBe(rowOf("The Show")); + pressKey("j"); + expect(document.activeElement).toBe(rowOf("Ep e1")); + pressKey("k"); + const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); + pressKey("Delete"); + await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"])); + }); +}); + +describe("PlexPlaylistView · the header's '…'", () => { + it("holds rename and delete; the pencil and the trash are gone", async () => { + const { onBack } = mount([movie("1")]); + await screen.findByText("Movie 1"); + expect(screen.queryByRole("button", { name: "plex.playlist.rename" })).toBeNull(); + expect(screen.queryByRole("button", { name: "plex.playlist.delete" })).toBeNull(); + const del = vi.spyOn(api, "plexDeletePlaylist").mockResolvedValue({ deleted: 5 }); + const more = screen.getByRole("button", { name: "plex.playlist.menu" }); + await openMenu(() => fireEvent.click(more)); + expect(menuRows()).toEqual(["plex.playlist.rename", "plex.playlist.delete"]); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "plex.playlist.delete")!, + ); + // Deleting the playlist itself keeps its confirm: it has no undo. + await waitFor(() => + expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })), + ); + await waitFor(() => expect(del).toHaveBeenCalledWith(5)); + await waitFor(() => expect(onBack).toHaveBeenCalled()); + }); + + it("hands the focus back to the '…' when a rename opened from it is left", async () => { + mount([movie("1")]); + await screen.findByText("Movie 1"); + const more = screen.getByRole("button", { name: "plex.playlist.menu" }); + act(() => more.focus()); + await pick(() => fireEvent.click(more), "plex.playlist.rename"); + const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" }); + fireEvent.keyDown(field, { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "plex.playlist.menu" }), + ), + ); + }); +}); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index f7597d73..1ae90291 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; @@ -21,10 +21,11 @@ import { Play, Rows3, Trash2, - X, } from "lucide-react"; import { api, HttpError, type PlexCard } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; +import { navItemAttrs, useListNav } from "../lib/listNav"; +import { toggleWatchState } from "../lib/plexWatch"; import { formatRuntime } from "../lib/format"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./confirmContext"; @@ -32,6 +33,12 @@ import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import { IconButton } from "./ui/IconButton"; +import { MoreButton } from "./ui/MoreButton"; +import { ContextMenu } from "./ui/ContextMenu"; +import { useContextMenu } from "./ui/useContextMenu"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { useCopyItems } from "./copyItems"; +import { usePlexCardActions } from "./usePlexCardActions"; import { Pressable } from "./ui/Pressable"; import { DragHandle } from "./ui/DragHandle"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; @@ -51,9 +58,22 @@ import { PageHeading } from "./ui/Headings"; // list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their // show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a // PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue. +// +// E28 C1: every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on +// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no +// confirm: the toast's Undo puts the rows back where they stood. `j`/`k` walk the visible rows and +// Delete takes out the one the keyboard is on; the playlist's own rename and delete sit in the +// header's "…". type Layout = "accordion" | "tree"; type Season = { key: string; number: number | null; items: PlexCard[] }; +type ShowBlock = Extract; +/** What a row's menu acts on. `key` is the row's identity across renders (the objects are rebuilt + * on every one), so "is THIS row's menu the open one" compares keys. */ +type MenuTarget = + | { kind: "item"; key: string; item: PlexCard } + | { kind: "show"; key: string; block: ShowBlock } + | { kind: "season"; key: string; block: ShowBlock; season: Season }; type Block = | { kind: "movie"; id: string; item: PlexCard } | { @@ -120,7 +140,11 @@ type Ctx = { toggleShow: (k: string) => void; toggleSeason: (k: string) => void; onPlay: (rk: string) => void; - onRemove: (keys: string[]) => void; + /** The row's "…" and its right-click / long-press / Shift+F10 — the page's one menu, on `target`. */ + more: (target: MenuTarget) => ReactNode; + trigger: (target: MenuTarget) => Record; + /** The row's index among the rows the keyboard walks (`navItemAttrs`). */ + navIndex: (key: string) => number; }; function Poster({ src, className }: { src: string; className: string }) { @@ -145,11 +169,14 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean opacity: isDragging ? 0.6 : 1, }; const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + const target: MenuTarget = { kind: "item", key: ep.id, item: ep }; return (
ctx.onPlay(ep.id)} - className="group flex min-w-0 flex-1 items-center gap-2.5" + data-nav-open + className="group/thumb flex min-w-0 flex-1 items-center gap-2.5" >
)} {inProgress &&
} -
+
{ep.title}
- ctx.onRemove([ep.id])} - disabled={ctx.busy} - label={t("plex.playlist.removeItem", { title: ep.title })} - > - - + {ctx.more(target)}
); } @@ -203,8 +224,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { opacity: isDragging ? 0.6 : 1, }; const dense = ctx.layout === "tree"; + const target: MenuTarget = { kind: "item", key: item.id, item }; return ( -
+
ctx.onPlay(item.id)} - className="group flex min-w-0 flex-1 items-center gap-3" + data-nav-open + className="group/thumb flex min-w-0 flex-1 items-center gap-3" >
{item.title}
{item.year}
-
+
- ctx.onRemove([item.id])} - disabled={ctx.busy} - label={t("plex.playlist.removeItem", { title: item.title })} - > - - + {ctx.more(target)}
); } -function ShowGroup({ block, ctx }: { block: Extract; ctx: Ctx }) { +/** A season's name, as its label and in the toast that says it went. */ +function useSeasonLabel() { + const { t } = useTranslation(); + return (s: Season) => + s.number != null + ? t("plex.playlist.season", { n: s.number }) + : t("plex.playlist.seasonUnknown"); +} + +function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: block.id, @@ -248,15 +279,17 @@ function ShowGroup({ block, ctx }: { block: Extract; ct }; const open = ctx.expandedShows.has(block.showKey); const dense = ctx.layout === "tree"; - const seasonLabel = (s: Season) => - s.number != null - ? t("plex.playlist.season", { n: s.number }) - : t("plex.playlist.seasonUnknown"); + const seasonLabel = useSeasonLabel(); + const target: MenuTarget = { kind: "show", key: block.id, block }; return (
- {/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */} -
+ {/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */} +
; ct ))} )} - ctx.toggleShow(block.showKey)} className="min-w-0 flex-1"> + ctx.toggleShow(block.showKey)} + data-nav-open + className="min-w-0 flex-1" + >
{block.title}
{t("plex.playlist.episodes", { count: block.items.length })}
- ctx.onRemove(block.items.map((i) => i.id))} - disabled={ctx.busy} - label={t("plex.playlist.removeShow")} - > - - + {ctx.more(target)}
{open && ( @@ -315,9 +345,13 @@ function ShowGroup({ block, ctx }: { block: Extract; ct {block.seasons.map((s) => { const sKey = `${block.showKey}:${s.key}`; const sOpen = !dense || !ctx.collapsedSeasons.has(sKey); + const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s }; return (
-
+
{dense && ( ctx.toggleSeason(sKey)} @@ -330,15 +364,7 @@ function ShowGroup({ block, ctx }: { block: Extract; ct {seasonLabel(s)} · {s.items.length} - ctx.onRemove(s.items.map((i) => i.id))} - disabled={ctx.busy} - label={t("plex.playlist.removeSeason")} - className="ml-1" - > - - + {ctx.more(sTarget)}
{sOpen && (
void; onPlay: (itemRk: string, queue: string[]) => void; + /** A title's media-info page, from its menu's "Details". */ + onInfoCard?: (card: PlexCard) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -390,7 +419,8 @@ export default function PlexPlaylistView({ // The query, the optimistic copy and the two writes that edit it (reorder, remove) live in // `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches // `commit` cannot be, since dnd-kit's sensors do not activate under jsdom. - const { q, items, busy, invalidate, commit, onRemove } = usePlexPlaylistEdits(playlistId); + const { q, items, busy, invalidate, commit, onRemove, setStatus } = + usePlexPlaylistEdits(playlistId); const blocks = useMemo(() => buildBlocks(items), [items]); const order = useMemo(() => items.map((i) => i.id), [items]); // Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with @@ -504,6 +534,105 @@ export default function PlexPlaylistView({ } } + // --- The rows' ONE menu (E28 C1) ------------------------------------------------------------- + const rowMenu = useContextMenu(); + const copy = useCopyItems(); + const seasonLabel = useSeasonLabel(); + const titleActions = usePlexCardActions( + { + onOpen: (c) => onPlay(c.id, order), + onInfo: onInfoCard, + onToggleWatched: (c) => + void toggleWatchState(c, t, { + optimistic: (next) => setStatus(c.id, next), + resync: invalidate, + }), + }, + rowMenu.open, + ); + // What a row's removal takes out, and the name its toast says. + const removal = (target: MenuTarget): { keys: string[]; title: string } => + target.kind === "item" + ? { keys: [target.item.id], title: target.item.title } + : target.kind === "show" + ? { keys: target.block.items.map((i) => i.id), title: target.block.title } + : { + keys: target.season.items.map((i) => i.id), + title: `${target.block.title} · ${seasonLabel(target.season)}`, + }; + const remove = (target: MenuTarget) => { + const { keys, title } = removal(target); + void onRemove(keys, title); + }; + const removeRow = (target: MenuTarget, label: string): ActionMenuItem => ({ + id: "remove", + label, + icon: , + danger: true, + separatorBefore: true, + disabled: busy, + onSelect: () => remove(target), + }); + const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] => + first + ? [ + { + id: "play", + label, + icon: , + onSelect: () => onPlay(first.id, order), + }, + ] + : []; + const menuTarget = rowMenu.target; + const menuItems: ActionMenuItem[] = !menuTarget + ? [] + : menuTarget.kind === "item" + ? [ + ...titleActions.items(menuTarget.item), + removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")), + ] + : menuTarget.kind === "show" + ? [ + ...playRow(t("plex.playlist.playShow"), menuTarget.block.items[0]), + ...copy(menuTarget.block.title, null).map((it, i) => ({ + ...it, + separatorBefore: i === 0, + })), + removeRow(menuTarget, t("plex.playlist.removeShow")), + ] + : [ + ...playRow(t("plex.playlist.playSeason"), menuTarget.season.items[0]), + removeRow(menuTarget, t("plex.playlist.removeSeason")), + ]; + + // --- The keyboard's rows: every row on screen, in reading order --------------------------------- + // A title, a show's header and — while the show is open — its episodes; a season label is a + // heading, not a row (its "…" holds its actions). Delete takes out the row it is on. + const navRows: MenuTarget[] = []; + for (const b of blocks) { + if (b.kind === "movie") { + navRows.push({ kind: "item", key: b.item.id, item: b.item }); + continue; + } + navRows.push({ kind: "show", key: b.id, block: b }); + if (!expandedShows.has(b.showKey)) continue; + for (const se of b.seasons) { + if (layout === "tree" && collapsedSeasons.has(`${b.showKey}:${se.key}`)) continue; + for (const ep of se.items) navRows.push({ kind: "item", key: ep.id, item: ep }); + } + } + const navIndexOf = new Map(navRows.map((r, i) => [r.key, i])); + const listRef = useRef(null); + useListNav(listRef, { + enabled: items.length > 0, + onDelete: (i) => { + const row = navRows[i]; + if (row) remove(row); + }, + }); + + const { buttonProps: rowMenuButton, triggerProps: rowMenuTrigger } = rowMenu; const ctx: Ctx = { layout, busy, @@ -512,9 +641,43 @@ export default function PlexPlaylistView({ toggleShow, toggleSeason, onPlay: (rk) => onPlay(rk, order), - onRemove, + more: (target) => { + const mine = rowMenu.open && rowMenu.target?.key === target.key; + return ( + + ); + }, + trigger: rowMenuTrigger, + navIndex: (key) => navIndexOf.get(key) ?? -1, }; + // --- The playlist's own menu: the header's "…" --------------------------------------------------- + const headerMenu = useContextMenu(); + const headerItems: ActionMenuItem[] = [ + { + id: "rename", + label: t("plex.playlist.rename"), + icon: , + onSelect: () => { + setName(q.data?.title ?? ""); + setRenaming(true); + }, + }, + { + id: "delete", + label: t("plex.playlist.delete"), + icon: , + danger: true, + separatorBefore: true, + onSelect: () => void del(), + }, + ]; + const layoutOptions: ViewOption[] = [ { id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 }, { id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree }, @@ -570,18 +733,6 @@ export default function PlexPlaylistView({ onChange={setLayout} label={t("plex.playlist.layoutLabel")} /> - { - setName(q.data?.title ?? ""); - setRenaming(true); - }} - label={t("plex.playlist.rename")} - > - - {items.length > 0 && ( )} - - - + {/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a + rename opened from there hands the focus back to it. */} +
{q.isError && !q.data ? ( @@ -611,7 +769,7 @@ export default function PlexPlaylistView({ accessibility={dndA11y} > b.id)} strategy={verticalListSortingStrategy}> -
+
{blocks.map((b) => b.kind === "movie" ? ( @@ -624,6 +782,9 @@ export default function PlexPlaylistView({ )}
+ + + {titleActions.panel}
); } diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index 02b3a1e1..88381aee 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -1,10 +1,9 @@ import { useTranslation } from "react-i18next"; -import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; +import { Check } from "lucide-react"; import type { ManagedChannel } from "../lib/api"; import { formatNumber } from "../lib/format"; import Tooltip from "./ui/Tooltip"; import { isFullySynced } from "./channelSync"; -import { IconButton } from "./ui/IconButton"; // The Channels-manager cells that are components in their own right (they read i18n), split out of // channelColumns so that file holds only the column factory and its predicates (Fast Refresh). @@ -29,77 +28,3 @@ export function StoredCell({ c }: { c: ManagedChannel }) { ); } - -export function ActionsCell({ - c, - isAdmin, - canWrite, - onHide, - onDeep, - onReset, - onUnsubscribe, -}: { - c: ManagedChannel; - isAdmin: boolean; - canWrite: boolean; - onHide: () => void; - onDeep: () => void; - onReset: () => void; - onUnsubscribe: () => void; -}) { - const { t } = useTranslation(); - // Admins get a "reset" trigger that re-fetches the channel from scratch regardless of - // state — always actionable. Regular users get the per-user "request full history" opt-in, - // which only applies until the back-catalog is in (or already coming for everyone). - const done = c.backfill_done; - const requested = !done && c.deep_requested; - const optInActionable = !done && !c.deep_in_queue; - const backfillHint = isAdmin - ? t("channels.row.resetHint") - : c.deep_in_queue - ? t("channels.row.queuedByOtherHint") - : requested - ? t("channels.row.queuedRequestedHint") - : done - ? t("channels.row.fullHint") - : t("channels.row.backfillThisHint"); - const enabled = isAdmin || optInActionable; - return ( -
- {/* Unavailable with a reason (queued by someone else, already full): aria-disabled keeps it - focusable, so the hint that says why is reachable by keyboard too. */} - - - - - - - - {c.hidden ? : } - - - {canWrite && ( - - - - - - )} -
- ); -} diff --git a/frontend/src/components/channelColumns.dom.test.tsx b/frontend/src/components/channelColumns.dom.test.tsx index 2c6b810c..7e96ad00 100644 --- a/frontend/src/components/channelColumns.dom.test.tsx +++ b/frontend/src/components/channelColumns.dom.test.tsx @@ -5,7 +5,7 @@ // "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change // which channels the top chips show. import { describe, it, expect } from "vitest"; -import { applySyncFilter, tagsColumnTest } from "./channelColumns"; +import { applySyncFilter, buildChannelColumns, tagsColumnTest } from "./channelColumns"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; const ch = (over: Partial): ManagedChannel => ({ @@ -71,3 +71,28 @@ describe("tagsColumnTest (item B — 'No tag')", () => { expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false); }); }); + +describe("buildChannelColumns — the row's end", () => { + it('ends every row in the channel\'s "…", with no action buttons before it', () => { + const more = (c: ManagedChannel) => `more:${c.id}`; + const noop = () => {}; + const cols = buildChannelColumns({ + t: ((k: string) => k) as never, + prioOptions: [], + userTags: [], + bumpPriority: noop, + setPriority: noop, + more, + onView: noop, + toggleTag: noop, + createAndAttachTag: noop, + onFilterByTag: noop, + suggestionsFor: () => [], + onAcceptSuggestion: noop, + onDismissSuggestion: noop, + }); + expect(cols[cols.length - 1]!.key).toBe("actions"); + expect(cols.filter((c) => c.key === "actions")).toHaveLength(1); + expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz"); + }); +}); diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index 28520157..c320f1c8 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import type { TFunction } from "i18next"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; @@ -7,7 +7,7 @@ import { PriorityStepper } from "./ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; -import { ActionsCell, StoredCell } from "./channelCells"; +import { StoredCell } from "./channelCells"; import { isFullySynced } from "./channelSync"; /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical @@ -49,16 +49,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { * column block lives outside the page component (its cell components are in channelCells). */ export interface ChannelColumnDeps { t: TFunction; - isAdmin: boolean; - canWrite: boolean; prioOptions: { value: string; label: string }[]; userTags: ComponentProps["userTags"]; bumpPriority: (id: string, delta: number) => void; setPriority: (id: string, value: number) => void; - onHide: (c: ManagedChannel) => void; - onDeep: (c: ManagedChannel) => void; - onReset: (c: ManagedChannel) => void; - onUnsub: (c: ManagedChannel) => void; + /** The row's "…" — the door to the channel's one action list (`useChannelActions`). */ + more: (c: ManagedChannel) => ReactNode; onView: (c: ManagedChannel) => void; toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void; createAndAttachTag: (channelId: string, name: string) => void; @@ -72,16 +68,11 @@ export interface ChannelColumnDeps { export function buildChannelColumns(deps: ChannelColumnDeps): Column[] { const { t, - isAdmin, - canWrite, prioOptions, userTags, bumpPriority, setPriority, - onHide, - onDeep, - onReset, - onUnsub, + more, onView, toggleTag, createAndAttachTag, @@ -109,25 +100,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column ), }, - { - key: "actions", - header: t("channels.cols.actions"), - align: "left", - nowrap: true, - width: "5.5rem", - cardLabel: false, - render: (c) => ( - onHide(c)} - onDeep={() => onDeep(c)} - onReset={() => onReset(c)} - onUnsubscribe={() => onUnsub(c)} - /> - ), - }, { key: "channel", header: t("channels.cols.channel"), @@ -217,5 +189,17 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column ), }, + { + key: "actions", + header: "", + align: "right", + // Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse. + width: "3.25rem", + nowrap: true, + cardLabel: false, + // The row's full-history, hide and unsubscribe buttons went into the menu (E28 C1): the row + // keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…". + render: more, + }, ]; } diff --git a/frontend/src/components/useChannelActions.dom.test.tsx b/frontend/src/components/useChannelActions.dom.test.tsx new file mode 100644 index 00000000..09f220da --- /dev/null +++ b/frontend/src/components/useChannelActions.dom.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +// +// A subscribed channel's ONE action list (E28 C1): what the manager row's "…" (and its right-click, +// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history, +// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed). +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const h = vi.hoisted(() => ({ + notify: vi.fn(), + confirm: vi.fn(async () => true), + role: "user", + canWrite: true, +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: (...a: unknown[]) => h.notify(...a), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); +vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) })); + +import { useChannelActions } from "./useChannelActions"; +import { api, type ManagedChannel } from "../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + h.notify.mockReset(); + h.confirm.mockReset(); + h.confirm.mockImplementation(async () => true); + h.role = "user"; + h.canWrite = true; +}); + +const ch = (over: Partial = {}): ManagedChannel => ({ + id: "UC1", + title: "Chan", + handle: "chan", + thumbnail_url: null, + subscriber_count: null, + video_count: null, + description: null, + stored_videos: 0, + last_video_at: null, + total_duration_seconds: 0, + priority: 0, + hidden: false, + deep_requested: false, + deep_in_queue: false, + tag_ids: [], + details_synced: false, + recent_synced: false, + backfill_done: false, + ...over, +}); + +const onView = vi.fn(); +function mount() { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useChannelActions({ onView, onOpenWizard: () => {} }), { wrapper }); +} +const pin = [{ id: "pin", label: "pin", onSelect: () => {} }]; +const ids = (items: { id: string }[]) => items.map((i) => i.id); +const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin }); +const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!; + +describe("useChannelActions — the menu", () => { + it("lists open, pin, the YouTube + copy group, full history, hide and unsubscribe", () => { + const items = rowsFor(ch()); + expect(ids(items)).toEqual([ + "open", + "pin", + "open-youtube", + "copy-text", + "copy-link", + "backfill", + "hide", + "unsubscribe", + ]); + // Groups: YouTube/copy, the channel's own settings, then the destructive row alone. + expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([ + "open-youtube", + "backfill", + "unsubscribe", + ]); + expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true }); + expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name"); + }); + + it("has no unsubscribe without the YouTube write scope", () => { + h.canWrite = false; + expect(ids(rowsFor(ch()))).not.toContain("unsubscribe"); + }); + + it("opens the channel page, and YouTube in a new tab inside the click", () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + row(ch(), "open").onSelect!(); + expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" })); + const yt = row(ch(), "open-youtube"); + expect("inGesture" in yt && yt.inGesture).toBe(true); + yt.onSelect!(); + expect(open).toHaveBeenCalledWith( + "https://www.youtube.com/@chan", + "_blank", + "noopener,noreferrer", + ); + }); +}); + +describe("useChannelActions — full history", () => { + it("asks for it, and takes a pending ask back", async () => { + const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never); + const ask = row(ch(), "backfill"); + expect(ask.label).toBe("channels.row.backfillThis"); + act(() => ask.onSelect!()); + await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true })); + const cancel = row(ch({ deep_requested: true }), "backfill"); + expect(cancel.label).toBe("channels.row.cancelBackfill"); + act(() => cancel.onSelect!()); + await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false })); + }); + + it("stays in place, off, with the reason, once it is in or on its way for everyone", () => { + expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({ + disabled: true, + note: "channels.row.fullHint", + }); + expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({ + disabled: true, + note: "channels.row.queuedByOtherHint", + }); + }); + + it("gives an admin the reset, behind a confirm that names the reset", async () => { + h.role = "admin"; + const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never); + const it_ = row(ch({ backfill_done: true }), "backfill"); + expect(it_).toMatchObject({ label: "channels.row.resetBackfill" }); + expect(it_.disabled).toBeFalsy(); + await act(async () => it_.onSelect!()); + expect(h.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: "channels.row.resetBackfill", + confirmLabel: "channels.row.resetBackfill", + }), + ); + await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1")); + }); +}); + +describe("useChannelActions — hide and unsubscribe", () => { + it("hides without a confirm, and the toast's Undo shows the channel again", async () => { + const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never); + const { result } = mount(); + act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!()); + await waitFor(() => expect(h.notify).toHaveBeenCalled()); + expect(h.confirm).not.toHaveBeenCalled(); + expect(upd).toHaveBeenCalledWith("UC1", { hidden: true }); + const toast = h.notify.mock.calls[0]![0] as { + message: string; + action: { onClick: () => void }; + }; + expect(toast.message).toBe("channels.notify.hidden"); + act(() => toast.action.onClick()); + await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false })); + }); + + it("unsubscribes only after the danger confirm says yes", async () => { + const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never); + const { result } = mount(); + const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!; + h.confirm.mockImplementation(async () => false); + await act(async () => unsubRow().onSelect!()); + expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); + expect(unsub).not.toHaveBeenCalled(); + h.confirm.mockImplementation(async () => true); + await act(async () => unsubRow().onSelect!()); + await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1")); + }); +}); diff --git a/frontend/src/components/useChannelActions.tsx b/frontend/src/components/useChannelActions.tsx new file mode 100644 index 00000000..c32d3085 --- /dev/null +++ b/frontend/src/components/useChannelActions.tsx @@ -0,0 +1,185 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { EyeOff, ExternalLink, History, Tv, UserMinus } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type ManagedChannel } from "../lib/api"; +import { channelYouTubeUrl } from "../lib/format"; +import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { useMe } from "../lib/useMe"; +import { useConfirm } from "./confirmContext"; +import { useCopyItems } from "./copyItems"; +import { type ActionMenuItem } from "./ui/ActionMenu"; + +// A subscribed channel's ONE action list (E28 C1): the manager row's "…", its right-click, long-press +// and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at +// or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the +// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. + +export function useChannelActions({ + onView, + onOpenWizard, +}: { + onView: (c: ManagedChannel) => void; + onOpenWizard: () => void; +}) { + const qc = useQueryClient(); + const { t } = useTranslation(); + const confirm = useConfirm(); + const me = useMe(); + const isAdmin = me.role === "admin"; + const copy = useCopyItems(); + const nameOf = (c: ManagedChannel) => c.title ?? t("channels.row.thisChannel"); + + const refresh = () => { + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.feedSummary() }); + }; + + const patch = useMutation({ + mutationFn: (v: { id: string; body: { hidden?: boolean; deep_requested?: boolean } }) => + api.updateChannel(v.id, v.body), + meta: { failTitle: "errors.writeFailed.channelSettings" }, + onSuccess: refresh, + }); + // Hiding moves the channel to the Hidden tab, out of sight: the toast's Undo is the way back from + // where the reader is, so hiding needs no confirm (the same bargain as a playlist row's Remove). + const hide = useMutation({ + mutationFn: (c: ManagedChannel) => api.updateChannel(c.id, { hidden: true }), + meta: { failTitle: "errors.writeFailed.hideChannel" }, + onSuccess: (_d, c) => { + refresh(); + notify({ + level: "success", + message: t("channels.notify.hidden", { name: nameOf(c) }), + action: { + label: t("common.undo"), + onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }), + }, + }); + }, + }); + const unsubscribe = useMutation({ + mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id), + onSuccess: (_d, c) => { + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) }); + }, + onError: (e) => + notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), + }); + const resetBackfill = useMutation({ + mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id), + onSuccess: (_d, c) => { + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) }); + }, + onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), + }); + + async function onUnsubscribe(c: ManagedChannel) { + const ok = await confirm({ + title: t("channels.row.unsubscribeOnYoutube"), + message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.unsubscribeOnYoutube"), + danger: true, + }); + if (ok) unsubscribe.mutate(c); + } + async function onReset(c: ManagedChannel) { + const ok = await confirm({ + title: t("channels.row.resetBackfill"), + message: t("channels.confirmReset", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.resetBackfill"), + }); + if (ok) resetBackfill.mutate(c); + } + + /** The full-history row. An admin re-fetches any channel from scratch (a confirm: it spends + * quota). A reader asks for the back-catalogue, or takes the ask back; once it is in, or someone + * else's ask already brings it, the row stays in place, off, and says why. */ + function backfillItem(c: ManagedChannel): ActionMenuItem { + const icon = ; + if (isAdmin) + return { + id: "backfill", + label: t("channels.row.resetBackfill"), + title: t("channels.row.resetHint"), + icon, + disabled: resetBackfill.isPending, + onSelect: () => void onReset(c), + }; + const requested = !c.backfill_done && c.deep_requested; + const note = c.backfill_done + ? t("channels.row.fullHint") + : c.deep_in_queue && !requested + ? t("channels.row.queuedByOtherHint") + : undefined; + return { + id: "backfill", + label: requested ? t("channels.row.cancelBackfill") : t("channels.row.backfillThis"), + title: requested ? t("channels.row.queuedRequestedHint") : t("channels.row.backfillThisHint"), + icon, + disabled: !!note || patch.isPending, + note, + onSelect: () => patch.mutate({ id: c.id, body: { deep_requested: !requested } }), + }; + } + + /** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read + * follows the caller's own menu). */ + function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] { + const url = channelYouTubeUrl(c.id, c.handle); + return [ + { + id: "open", + label: t("card.menu.openChannel"), + icon: , + onSelect: () => onView(c), + }, + ...pin, + ...[ + { + id: "open-youtube", + label: t("channels.row.openOnYouTube"), + icon: , + // A new tab only opens inside the click (a popup blocker refuses one a task later). + inGesture: true, + onSelect: () => void window.open(url, "_blank", "noopener,noreferrer"), + }, + ...copy(c.title, url, "name"), + ].map((it, i) => ({ ...it, separatorBefore: i === 0 })), + { ...backfillItem(c), separatorBefore: true }, + { + id: "hide", + label: t("channels.row.hideFromFeed"), + title: t("channels.row.hideHint"), + icon: , + disabled: hide.isPending, + onSelect: () => hide.mutate(c), + }, + ...(me.can_write + ? [ + { + id: "unsubscribe", + label: t("channels.row.unsubscribeOnYoutube"), + title: t("channels.row.unsubscribeHint"), + icon: , + danger: true, + separatorBefore: true, + onSelect: () => void onUnsubscribe(c), + }, + ] + : []), + ]; + } + + /** Unhide from the Hidden tab — the same flag, the same refresh. */ + const unhide = (id: string) => patch.mutate({ id, body: { hidden: false } }); + + return { items, unhide }; +} diff --git a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx index e28cdace..8be51dad 100644 --- a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx +++ b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx @@ -14,7 +14,9 @@ vi.mock("react-i18next", async (importOriginal) => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); const { notify } = vi.hoisted(() => ({ - notify: vi.fn((_n: { level: string; message: string }) => 0), + notify: vi.fn( + (_n: { level: string; message: string; action?: { label: string; onClick: () => void } }) => 0, + ), })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -117,9 +119,39 @@ describe("usePlexPlaylistEdits · the removal", () => { await waitFor(() => expect(hook.result.current.items).toHaveLength(2)); vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline")); - await act(() => hook.result.current.onRemove(["2"])); + await act(() => hook.result.current.onRemove(["2"], "Movie 2")); expect(errors()).toEqual(["plex.playlist.removeFailed"]); expect(ids(hook.result.current.items)).toEqual(["1", "2"]); expect(hook.result.current.busy).toBe(false); + // A failed removal offers no Undo: there is nothing to put back. + expect(notify.mock.calls.some(([n]) => n.action)).toBe(false); + }); + + // E28 C1: no confirm — the toast's Undo is the way back, and it puts each row where it stood. + it("says what went, and its Undo puts the rows back at their own indices in one request", async () => { + const { hook } = setup([movie("1"), movie("2"), movie("3"), movie("4")]); + await waitFor(() => expect(hook.result.current.items).toHaveLength(4)); + vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never); + const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never); + + // A season whose episodes do not sit together: rows 2 and 4. + await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1")); + const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!; + expect(toast.message).toBe("plex.playlist.removedToast"); + expect(toast.action!.label).toBe("common.undo"); + act(() => toast.action!.onClick()); + expect(add).toHaveBeenCalledTimes(1); + expect(add).toHaveBeenCalledWith(5, ["4", "2"], [3, 1]); + }); + + it("says so when the Undo fails", async () => { + const { hook } = setup([movie("1"), movie("2")]); + await waitFor(() => expect(hook.result.current.items).toHaveLength(2)); + vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never); + vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline")); + await act(() => hook.result.current.onRemove(["1"], "Movie 1")); + const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!; + act(() => toast.action!.onClick()); + await waitFor(() => expect(errors()).toEqual(["plex.playlist.undoRemoveFailed"])); }); }); diff --git a/frontend/src/components/usePlexPlaylistEdits.ts b/frontend/src/components/usePlexPlaylistEdits.ts index 809a29b8..c4bdf94a 100644 --- a/frontend/src/components/usePlexPlaylistEdits.ts +++ b/frontend/src/components/usePlexPlaylistEdits.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type PlexCard } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; +import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; /** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal. @@ -79,9 +80,15 @@ export function usePlexPlaylistEdits(playlistId: number) { }); } - async function onRemove(keys: string[]) { + /** Take `keys` out — one row, a season or a whole show — with no confirm: the toast's Undo puts + * each row back at the index it held (E28 C1, the same bargain as a YouTube playlist's rows). + * `title` names what went in that toast. */ + async function onRemove(keys: string[], title: string) { if (busy || !keys.length) return; setBusy(true); + // Indices over the list as it stands now, which is the server's order: a season's episodes need + // not sit together (a reorder inside the show interleaves them), so each key keeps its own. + const positions = keys.map((k) => items.findIndex((i) => i.id === k)); const set = new Set(keys); setItems((prev) => prev.filter((i) => !set.has(i.id))); try { @@ -91,10 +98,32 @@ export function usePlexPlaylistEdits(playlistId: number) { } catch (e) { notifyActionError(e, t("plex.playlist.removeFailed")); putBack(); + return; } finally { setBusy(false); } + const back = keys.filter((_, i) => positions[i]! >= 0); + notify({ + level: "success", + message: t("plex.playlist.removedToast", { title }), + action: { + label: t("common.undo"), + onClick: () => + void api + .plexPlaylistAddBulk( + playlistId, + back, + positions.filter((p) => p >= 0), + ) + .catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed"))) + .finally(invalidate), + }, + }); } - return { q, items, busy, invalidate, commit, onRemove }; + /** A watched toggle's optimistic step on the local copy (the query refetch then confirms it). */ + const setStatus = (id: string, status: string) => + setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status } : i))); + + return { q, items, busy, invalidate, commit, onRemove, setStatus }; } diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 844dca8f..739303cf 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -107,18 +107,18 @@ "fullySynced": "fully synced", "fullySyncedHint": "Recent uploads and the full back-catalog are both in.", "backfillThis": "Backfill this channel", - "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.", + "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search).", "resetBackfill": "Reset & re-fetch this channel", + "cancelBackfill": "Cancel the full-history request", "resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.", - "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", + "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", - "hiddenHint": "Hidden — this channel's videos are kept out of your Feed. Click to show them again.", "hideHint": "Hide this channel's videos from your Feed. It stays subscribed; this doesn't unsubscribe on YouTube.", - "unhide": "Unhide", "hideFromFeed": "Hide from Feed", "unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.", "unsubscribeOnYoutube": "Unsubscribe on YouTube", - "thisChannel": "This channel" + "thisChannel": "This channel", + "menu": "Channel actions" }, "notify": { "synced": "Synced {{count}} subscriptions", @@ -131,7 +131,8 @@ "connect": "Connect", "filteredByTag": "Feed filtered by tag: {{name}}", "resetDone": "{{name}} reset — re-fetching now", - "resetFailed": "Couldn't reset this channel" + "resetFailed": "Couldn't reset this channel", + "hidden": "“{{name}}” hidden from your Feed" }, "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your Feed, hide it instead.", "confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index cc3dd3ae..14eac6a7 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -326,8 +326,14 @@ "renameFailed": "Couldn't rename the playlist.", "deleteFailed": "Couldn't delete the playlist.", "reorderFailed": "Couldn't save the new order.", - "removeItem": "Remove “{{title}}” from the playlist", - "reorderItem": "Move “{{title}}”" + "reorderItem": "Move “{{title}}”", + "rowMenu": "Item actions", + "menu": "Playlist actions", + "playShow": "Play the show", + "playSeason": "Play the season", + "removeFromPlaylist": "Remove from the playlist", + "removedToast": "“{{title}}” removed from the playlist", + "undoRemoveFailed": "Couldn't put it back in the playlist." }, "people": { "count_one": "{{count}} title", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index be79c54f..0f448759 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -45,7 +45,7 @@ "sound": "Sound on alerts", "soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).", "autoDismiss": "Auto-dismiss toasts", - "autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time.", + "autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time; one with a button (Undo) stays at least 8 seconds.", "dismissAfter": "Dismiss after", "sendTest": "Send a test notification", "testTitle": "Test notification", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 38d3b6a0..32393364 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -107,18 +107,18 @@ "fullySynced": "teljesen szinkronban", "fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.", "backfillThis": "Csatorna teljes letöltése", - "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.", + "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés).", "resetBackfill": "Csatorna resetelése és újraletöltése", + "cancelBackfill": "A teljes letöltés kérésének visszavonása", "resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", - "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", + "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", - "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a Feededben. Kattints, hogy újra láthatóak legyenek.", "hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.", - "unhide": "Megjelenítés", "hideFromFeed": "Elrejtés a Feedből", "unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.", "unsubscribeOnYoutube": "Leiratkozás a YouTube-on", - "thisChannel": "Ez a csatorna" + "thisChannel": "Ez a csatorna", + "menu": "Csatorna műveletei" }, "notify": { "synced": "{{count}} feliratkozás szinkronizálva", @@ -131,7 +131,8 @@ "connect": "Csatlakoztatás", "filteredByTag": "Feed szűrve címkére: {{name}}", "resetDone": "{{name}} resetelve — újraletöltés folyamatban", - "resetFailed": "Nem sikerült resetelni a csatornát" + "resetFailed": "Nem sikerült resetelni a csatornát", + "hidden": "„{{name}}” elrejtve a Feedből" }, "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.", "confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 912dc15d..84c97630 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -326,8 +326,14 @@ "renameFailed": "Nem sikerült átnevezni a lejátszási listát.", "deleteFailed": "Nem sikerült törölni a lejátszási listát.", "reorderFailed": "Nem sikerült menteni az új sorrendet.", - "removeItem": "„{{title}}” eltávolítása a listáról", - "reorderItem": "„{{title}}” áthelyezése" + "reorderItem": "„{{title}}” áthelyezése", + "rowMenu": "Elem műveletei", + "menu": "Lista műveletei", + "playShow": "Sorozat lejátszása", + "playSeason": "Évad lejátszása", + "removeFromPlaylist": "Eltávolítás a listáról", + "removedToast": "„{{title}}” eltávolítva a listáról", + "undoRemoveFailed": "Nem sikerült visszatenni a listára." }, "people": { "count_one": "{{count}} cím", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 54b215ce..6f9df609 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -45,7 +45,7 @@ "sound": "Hang riasztásoknál", "soundHint": "Rövid hangjelzés, ha egy értesítés a figyelmedet igényli (pl. hibák, kérdések).", "autoDismiss": "Buborékok automatikus eltüntetése", - "autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak.", + "autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak; a gombos (Visszavonás) legalább 8 másodpercig marad.", "dismissAfter": "Eltüntetés ennyi után", "sendTest": "Tesztértesítés küldése", "testTitle": "Tesztértesítés", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index f9d1be7c..ed84d057 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -558,13 +558,16 @@ export const plexApi = { method: "POST", body: JSON.stringify({ title, item_rating_key }), }), + /** `positions` (one per key, an index over the ordered rows) puts removed rows back where they + * stood — the list page's Undo, in one request; without it the keys are appended. */ plexPlaylistAddBulk: ( id: number, itemRks: string[], + positions?: number[], ): Promise<{ added: number; item_count: number }> => req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", - body: JSON.stringify({ item_rating_keys: itemRks }), + body: JSON.stringify({ item_rating_keys: itemRks, ...(positions ? { positions } : {}) }), }), plexPlaylistRemoveBulk: ( id: number, diff --git a/frontend/src/lib/notifications.actionTtl.dom.test.ts b/frontend/src/lib/notifications.actionTtl.dom.test.ts new file mode 100644 index 00000000..b90014db --- /dev/null +++ b/frontend/src/lib/notifications.actionTtl.dom.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +// +// A toast with an action (the list page's "Undo") outlives a short toast setting: its button is the +// only way back, so it gets ACTION_TTL_MIN at least (owner, E28 S3e2). The setting still wins when it +// is longer, "never" still means never, and a plain toast keeps the reader's time. +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { + ACTION_TTL_MIN, + clearAll, + configureNotifications, + getActiveToasts, + notify, +} from "./notifications"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + clearAll(); + configureNotifications({ durationMs: 6000 }); + vi.useRealTimers(); +}); + +const undo = { label: "Undo", onClick: () => {} }; +const shown = (id: number) => getActiveToasts().some((n) => n.id === id); + +describe("an action toast's lifetime", () => { + it("stays for the floor when the reader's toasts last 3 s", () => { + configureNotifications({ durationMs: 3000 }); + const plain = notify({ message: "plain" }); + const withUndo = notify({ message: "removed", action: undo }); + vi.advanceTimersByTime(3001); + expect(shown(plain)).toBe(false); + expect(shown(withUndo)).toBe(true); + vi.advanceTimersByTime(ACTION_TTL_MIN - 3001); + expect(shown(withUndo)).toBe(false); + }); + + it("keeps a longer setting", () => { + configureNotifications({ durationMs: 12000 }); + const id = notify({ message: "removed", action: undo }); + vi.advanceTimersByTime(ACTION_TTL_MIN + 1); + expect(shown(id)).toBe(true); + vi.advanceTimersByTime(12000 - ACTION_TTL_MIN); + expect(shown(id)).toBe(false); + }); + + it("never dismisses when the reader chose never", () => { + configureNotifications({ durationMs: 0 }); + const id = notify({ message: "removed", action: undo }); + vi.advanceTimersByTime(60000); + expect(shown(id)).toBe(true); + }); +}); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index daa4cce8..96d0914b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -92,6 +92,10 @@ const HISTORY_KEY = LS.notifHistory; const SETTINGS_KEY = LS.notifSettings; const MAX_HISTORY = 100; const ERROR_TTL = 15000; +// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast +// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner, E28). +// The setting still decides for longer, and "never" still means never. +export const ACTION_TTL_MIN = 8000; // 6b: per-account notification settings (persisted by the Settings panel into the // server `preferences`, mirrored to localStorage so they apply before login). @@ -269,7 +273,9 @@ export function notify(input: NotifyInput): number { : level === "error" || level === "fatal" ? ERROR_TTL : config.durationMs > 0 - ? config.durationMs + ? input.action + ? Math.max(config.durationMs, ACTION_TTL_MIN) + : config.durationMs : undefined; // Coalesce a rapid burst of identical notifications (same level+title+message within the