feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)

- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
This commit is contained in:
2026-09-26 05:56:07 +02:00
parent b0e51c6f89
commit 1b946113c6
27 changed files with 1354 additions and 293 deletions
+64 -3
View File
@@ -3,7 +3,9 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends 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 import delete, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -285,16 +287,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
return out 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") @router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk( def playlist_add_bulk(
pid: int, pid: int,
body: PlexItemKeysIn, body: PlexItemAddBulkIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Append many items at once (a whole season/show). Already-present items are skipped; the input """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") 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) items = _items_by_rks(db, body.item_rating_keys)
existing = set( existing = set(
db.execute( db.execute(
@@ -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"]
+2 -2
View File
@@ -93,8 +93,8 @@ export default function ChannelDiscovery({
); );
const columns: Column<DiscoveredChannel>[] = [ const columns: Column<DiscoveredChannel>[] = [
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two // Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
// tabs' action clusters line up in the same place. // Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
{ {
key: "actions", key: "actions",
header: t("channels.cols.actions"), header: t("channels.cols.actions"),
@@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => {
expect(opened).toEqual(["UCb"]); 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<Ch>[] = [
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
{ key: "stored", header: "Stored", render: () => <i>12</i> },
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
];
const { getByTestId, getByText } = render(
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => 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);
});
});
@@ -28,6 +28,9 @@ interface ChannelLayoutGridProps<T> {
aboutKey?: string; aboutKey?: string;
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */ /** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string; 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; rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so /** 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. */ * 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<T>({
leadKey, leadKey,
aboutKey, aboutKey,
actionsKey, actionsKey,
moreKey,
rowClassName, rowClassName,
rowProps, rowProps,
emptyText, emptyText,
@@ -49,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
const { t } = useTranslation(); const { t } = useTranslation();
const spec = CHANNEL_LAYOUT_SPEC.cards; 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 primary = columns.filter((c) => c.cardPrimary);
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined; const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined; const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : 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. // 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)); const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
@@ -70,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
))} ))}
</div> </div>
{lead && <div className="shrink-0">{lead.render(row)}</div>} {lead && <div className="shrink-0">{lead.render(row)}</div>}
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
</div> </div>
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>} {about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
+36 -77
View File
@@ -16,8 +16,10 @@ import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable"; import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs"; import Tabs, { type TabDef } from "./ui/Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { ComfortPinMenu } from "./ComfortPinMenu"; import { pinChannel, useComfortPin } from "./comfortPin";
import { pinChannel } from "./comfortPin"; import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelTagActions } from "../lib/useChannelTagActions";
@@ -37,7 +39,6 @@ import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./confirmContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "./navigationContext"; import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
@@ -64,7 +65,6 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
export default function Channels() { export default function Channels() {
const me = useMe(); const me = useMe();
const canWrite = me.can_write; const canWrite = me.can_write;
const isAdmin = me.role === "admin";
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
const { openWizard: onOpenWizard } = useWizard(); const { openWizard: onOpenWizard } = useWizard();
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live // 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 { setFilters: setFeedFilters } = useFeedFiltersActions();
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App // 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. // 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 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 // 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 // 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 + // 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), 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({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
@@ -328,24 +293,19 @@ export default function Channels() {
const onView = (c: ManagedChannel) => const onView = (c: ManagedChannel) =>
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
const onUnsub = async (c: ManagedChannel) => { // The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
const ok = await confirm({ // layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
title: t("channels.row.unsubscribeOnYoutube"), // must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), // result is not a `Channel` row yet, so pinning or hiding one could only 404.
confirmLabel: t("channels.row.unsubscribeOnYoutube"), const rowMenu = useContextMenu<ManagedChannel>();
danger: true, const rowPin = useComfortPin(rowMenu.open);
}); const channelActions = useChannelActions({ onView, onOpenWizard });
if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id }); const menuRow = rowMenu.target;
}; const menuItems = menuRow
const onReset = async (c: ManagedChannel) => { ? channelActions.items(menuRow, {
const ok = await confirm({ pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
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 });
};
// Distinct priority levels present in the catalog → the priority column's filter options. // Distinct priority levels present in the catalog → the priority column's filter options.
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))] const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
.sort((a, b) => b - a) .sort((a, b) => b - a)
@@ -353,16 +313,21 @@ export default function Channels() {
const columns = buildChannelColumns({ const columns = buildChannelColumns({
t, t,
isAdmin,
canWrite,
prioOptions, prioOptions,
userTags, userTags,
bumpPriority, bumpPriority,
setPriority, setPriority,
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }), more: (c) => {
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }), const mine = rowMenu.open && rowMenu.target === c;
onReset, return (
onUnsub, <MoreButton
label={t("channels.row.menu")}
expanded={mine}
menuProps={rowMenu.buttonProps(c)}
className={mine ? undefined : "reveal"}
/>
);
},
onView, onView,
toggleTag, toggleTag,
createAndAttachTag, createAndAttachTag,
@@ -430,11 +395,6 @@ export default function Channels() {
/> />
); );
const hasLayout = view === "subscribed" || view === "discovery"; 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<ManagedChannel>();
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state // 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. // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = ( const sortControl = (
@@ -602,7 +562,7 @@ export default function Channels() {
emptyText={t("channels.hidden.empty")} emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")} actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />} actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={(id) => patch.mutate({ id, body: { hidden: false } })} onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/> />
</div> </div>
@@ -637,7 +597,9 @@ export default function Channels() {
onFiltersChange={setFilters} onFiltersChange={setFilters}
fixedLayout fixedLayout
emptyText={t("channels.empty")} 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 keyboardNav
/> />
) : ( ) : (
@@ -647,16 +609,13 @@ export default function Channels() {
rowKey={(c) => c.id} rowKey={(c) => c.id}
leadKey="priority" leadKey="priority"
aboutKey="about" aboutKey="about"
actionsKey="actions" moreKey="actions"
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps} rowProps={rowMenu.triggerProps}
rowClassName={() => "group"}
/> />
)} )}
<ComfortPinMenu <ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
menu={pinMenu}
target={(c) => pinChannel(c.id)}
title={(c) => c.title ?? t("channels.row.thisChannel")}
/>
</div> </div>
)} )}
</> </>
@@ -61,6 +61,20 @@ const client = () => new QueryClient({ defaultOptions: { queries: { retry: false
const movie = (id: string) => const movie = (id: string) =>
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; ({ 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", () => { describe("the Plex playlist page", () => {
it("puts back a row whose removal failed, and says why", async () => { it("puts back a row whose removal failed, and says why", async () => {
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ vi.spyOn(api, "plexPlaylist").mockResolvedValue({
@@ -69,13 +83,20 @@ describe("the Plex playlist page", () => {
items: [movie("1"), movie("2")], items: [movie("1"), movie("2")],
}); });
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist")); 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( render(
<QueryClientProvider client={client()}> <QueryClientProvider client={client()}>
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} /> <PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByText("Movie 1"); 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 waitFor(() => expect(errors()).toEqual(["gone playlist"]));
await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy(); expect(screen.getByText("Movie 1")).toBeTruthy();
@@ -90,8 +111,11 @@ describe("the Plex playlist page", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByText("Films"); await screen.findByText("Films");
fireEvent.click(screen.getByTitle("plex.playlist.rename")); await pickFromMenu(
const field = screen.getByDisplayValue("Films"); () => 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.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" }); fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
@@ -107,7 +131,11 @@ describe("the Plex playlist page", () => {
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={() => {}} /> <PlexPlaylistView playlistId={5} onBack={onBack} onPlay={() => {}} />
</QueryClientProvider>, </QueryClientProvider>,
); );
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()); await waitFor(() => expect(onBack).toHaveBeenCalled());
}); });
}); });
+1
View File
@@ -417,6 +417,7 @@ export default function PlexBrowse() {
playlistId={pid} playlistId={pid}
onBack={sub.back} onBack={sub.back}
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })} onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
onInfoCard={onInfo}
/> />
</Suspense> </Suspense>
); );
@@ -5,7 +5,7 @@
// (E9 review). The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list // (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. // nothing sums for says no runtime at all.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; 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"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => ({
@@ -60,6 +60,20 @@ async function meta(items: PlexCard[]) {
).textContent; ).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", () => { describe("PlexPlaylistView · the header's meta line", () => {
it("sums the known runtimes and counts an unknown one as nothing", async () => { 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( 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); const write = vi.spyOn(api, "plexRenamePlaylist").mockResolvedValue({} as never);
await meta([movie("1", 5400)]); await meta([movie("1", 5400)]);
for (const value of [null, " "]) { for (const value of [null, " "]) {
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.rename" })); await pickFromMenu(
const field = screen.getByRole("textbox", { name: "plex.playlist.rename" }); () => 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 } }); if (value !== null) fireEvent.change(field, { target: { value } });
fireEvent.keyDown(field, { key: "Enter" }); fireEvent.keyDown(field, { key: "Enter" });
await screen.findByRole("heading", { name: "Films" }); await screen.findByRole("heading", { name: "Films" });
@@ -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<typeof import("react-i18next")>()),
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<typeof import("../lib/notifications")>()),
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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
</QueryClientProvider>,
);
return { onPlay, onBack };
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[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<HTMLElement>('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<HTMLElement>(
'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" }),
),
);
});
});
+230 -69
View File
@@ -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 { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -21,10 +21,11 @@ import {
Play, Play,
Rows3, Rows3,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type PlexCard } from "../lib/api"; import { api, HttpError, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { navItemAttrs, useListNav } from "../lib/listNav";
import { toggleWatchState } from "../lib/plexWatch";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
@@ -32,6 +33,12 @@ import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { IconButton } from "./ui/IconButton"; 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 { Pressable } from "./ui/Pressable";
import { DragHandle } from "./ui/DragHandle"; import { DragHandle } from "./ui/DragHandle";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; 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 // 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 // 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. // 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 Layout = "accordion" | "tree";
type Season = { key: string; number: number | null; items: PlexCard[] }; type Season = { key: string; number: number | null; items: PlexCard[] };
type ShowBlock = Extract<Block, { kind: "show" }>;
/** 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 = type Block =
| { kind: "movie"; id: string; item: PlexCard } | { kind: "movie"; id: string; item: PlexCard }
| { | {
@@ -120,7 +140,11 @@ type Ctx = {
toggleShow: (k: string) => void; toggleShow: (k: string) => void;
toggleSeason: (k: string) => void; toggleSeason: (k: string) => void;
onPlay: (rk: 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<string, unknown>;
/** The row's index among the rows the keyboard walks (`navItemAttrs`). */
navIndex: (key: string) => number;
}; };
function Poster({ src, className }: { src: string; className: string }) { 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, opacity: isDragging ? 0.6 : 1,
}; };
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const target: MenuTarget = { kind: "item", key: ep.id, item: ep };
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`} {...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(ep.id), ep.title)}
className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
> >
<DragHandle <DragHandle
label={t("plex.playlist.reorderItem", { title: ep.title })} label={t("plex.playlist.reorderItem", { title: ep.title })}
@@ -161,7 +188,8 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
</span> </span>
<Pressable <Pressable
onClick={() => ctx.onPlay(ep.id)} onClick={() => 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"
> >
<div <div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`} className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
@@ -173,20 +201,13 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
</span> </span>
)} )}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />} {inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100"> <div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
<Play className="h-4 w-4 text-white" fill="currentColor" /> <Play className="h-4 w-4 text-white" fill="currentColor" />
</div> </div>
</div> </div>
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div> <div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
</Pressable> </Pressable>
<IconButton {ctx.more(target)}
variant="danger"
onClick={() => ctx.onRemove([ep.id])}
disabled={ctx.busy}
label={t("plex.playlist.removeItem", { title: ep.title })}
>
<X />
</IconButton>
</div> </div>
); );
} }
@@ -203,8 +224,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
opacity: isDragging ? 0.6 : 1, opacity: isDragging ? 0.6 : 1,
}; };
const dense = ctx.layout === "tree"; const dense = ctx.layout === "tree";
const target: MenuTarget = { kind: "item", key: item.id, item };
return ( return (
<div ref={setNodeRef} style={style} className={`${SURFACE.card} flex items-center gap-3 p-2`}> <div
ref={setNodeRef}
style={style}
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(item.id), item.title)}
className={`group ${SURFACE.card} flex items-center gap-3 p-2`}
>
<DragHandle <DragHandle
label={t("plex.playlist.reorderItem", { title: item.title })} label={t("plex.playlist.reorderItem", { title: item.title })}
{...attributes} {...attributes}
@@ -212,30 +240,33 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
/> />
<Pressable <Pressable
onClick={() => ctx.onPlay(item.id)} onClick={() => 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"
> >
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} /> <Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div> <div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div> <div className="text-xs text-muted">{item.year}</div>
</div> </div>
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover:bg-accent/15 group-hover:text-accent"> <div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover/thumb:bg-accent/15 group-hover/thumb:text-accent">
<Play className="h-4 w-4" fill="currentColor" /> <Play className="h-4 w-4" fill="currentColor" />
</div> </div>
</Pressable> </Pressable>
<IconButton {ctx.more(target)}
variant="danger"
onClick={() => ctx.onRemove([item.id])}
disabled={ctx.busy}
label={t("plex.playlist.removeItem", { title: item.title })}
>
<X />
</IconButton>
</div> </div>
); );
} }
function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; 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 { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: block.id, id: block.id,
@@ -248,15 +279,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
}; };
const open = ctx.expandedShows.has(block.showKey); const open = ctx.expandedShows.has(block.showKey);
const dense = ctx.layout === "tree"; const dense = ctx.layout === "tree";
const seasonLabel = (s: Season) => const seasonLabel = useSeasonLabel();
s.number != null const target: MenuTarget = { kind: "show", key: block.id, block };
? t("plex.playlist.season", { n: s.number })
: t("plex.playlist.seasonUnknown");
return ( return (
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}> <div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
{/* 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. */}
<div className="flex items-center gap-2.5 p-2"> <div
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(block.id), block.title)}
className="group flex items-center gap-2.5 p-2"
>
<DragHandle <DragHandle
label={t("plex.playlist.reorderItem", { title: block.title })} label={t("plex.playlist.reorderItem", { title: block.title })}
{...attributes} {...attributes}
@@ -290,20 +323,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
))} ))}
</Pressable> </Pressable>
)} )}
<Pressable onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1"> <Pressable
onClick={() => ctx.toggleShow(block.showKey)}
data-nav-open
className="min-w-0 flex-1"
>
<div className="truncate text-sm font-semibold">{block.title}</div> <div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("plex.playlist.episodes", { count: block.items.length })} {t("plex.playlist.episodes", { count: block.items.length })}
</div> </div>
</Pressable> </Pressable>
<IconButton {ctx.more(target)}
variant="danger"
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
label={t("plex.playlist.removeShow")}
>
<X />
</IconButton>
</div> </div>
{open && ( {open && (
@@ -315,9 +345,13 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{block.seasons.map((s) => { {block.seasons.map((s) => {
const sKey = `${block.showKey}:${s.key}`; const sKey = `${block.showKey}:${s.key}`;
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey); const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s };
return ( return (
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}> <div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
<div className="flex items-center gap-1.5 py-1 pl-1"> <div
{...ctx.trigger(sTarget)}
className="group flex items-center gap-1.5 py-1 pl-1"
>
{dense && ( {dense && (
<IconButton <IconButton
onClick={() => ctx.toggleSeason(sKey)} onClick={() => ctx.toggleSeason(sKey)}
@@ -330,15 +364,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<span className="text-2xs font-medium uppercase tracking-wide text-muted"> <span className="text-2xs font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length} {seasonLabel(s)} · {s.items.length}
</span> </span>
<IconButton <span className="ml-1">{ctx.more(sTarget)}</span>
variant="danger"
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
label={t("plex.playlist.removeSeason")}
className="ml-1"
>
<X />
</IconButton>
</div> </div>
{sOpen && ( {sOpen && (
<div <div
@@ -365,10 +391,13 @@ export default function PlexPlaylistView({
playlistId, playlistId,
onBack, onBack,
onPlay, onPlay,
onInfoCard,
}: { }: {
playlistId: number; playlistId: number;
onBack: () => void; onBack: () => void;
onPlay: (itemRk: string, queue: string[]) => 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 { t } = useTranslation();
const qc = useQueryClient(); 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 // 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 // `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. // `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 blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [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 // 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<MenuTarget>();
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: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
disabled: busy,
onSelect: () => remove(target),
});
const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] =>
first
? [
{
id: "play",
label,
icon: <Play className="w-4 h-4" />,
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<HTMLDivElement>(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 = { const ctx: Ctx = {
layout, layout,
busy, busy,
@@ -512,9 +641,43 @@ export default function PlexPlaylistView({
toggleShow, toggleShow,
toggleSeason, toggleSeason,
onPlay: (rk) => onPlay(rk, order), onPlay: (rk) => onPlay(rk, order),
onRemove, more: (target) => {
const mine = rowMenu.open && rowMenu.target?.key === target.key;
return (
<MoreButton
label={t("plex.playlist.rowMenu")}
expanded={mine}
menuProps={rowMenuButton(target)}
className={mine ? undefined : "reveal"}
/>
);
},
trigger: rowMenuTrigger,
navIndex: (key) => navIndexOf.get(key) ?? -1,
}; };
// --- The playlist's own menu: the header's "…" ---------------------------------------------------
const headerMenu = useContextMenu<number>();
const headerItems: ActionMenuItem[] = [
{
id: "rename",
label: t("plex.playlist.rename"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => {
setName(q.data?.title ?? "");
setRenaming(true);
},
},
{
id: "delete",
label: t("plex.playlist.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
onSelect: () => void del(),
},
];
const layoutOptions: ViewOption<Layout>[] = [ const layoutOptions: ViewOption<Layout>[] = [
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 }, { id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree }, { id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
@@ -570,18 +733,6 @@ export default function PlexPlaylistView({
onChange={setLayout} onChange={setLayout}
label={t("plex.playlist.layoutLabel")} label={t("plex.playlist.layoutLabel")}
/> />
<IconButton
ref={renameFocus.target}
variant="glass"
size="md"
onClick={() => {
setName(q.data?.title ?? "");
setRenaming(true);
}}
label={t("plex.playlist.rename")}
>
<Pencil />
</IconButton>
{items.length > 0 && ( {items.length > 0 && (
<Button <Button
onClick={() => onPlay(order[0]!, order)} onClick={() => onPlay(order[0]!, order)}
@@ -590,9 +741,16 @@ export default function PlexPlaylistView({
{t("plex.playlist.playAll")} {t("plex.playlist.playAll")}
</Button> </Button>
)} )}
<IconButton variant="glass" size="md" onClick={del} label={t("plex.playlist.delete")}> {/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
<Trash2 /> rename opened from there hands the focus back to it. */}
</IconButton> <MoreButton
ref={renameFocus.target}
variant="glass"
size="md"
label={t("plex.playlist.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps(playlistId)}
/>
</div> </div>
{q.isError && !q.data ? ( {q.isError && !q.data ? (
@@ -611,7 +769,7 @@ export default function PlexPlaylistView({
accessibility={dndA11y} accessibility={dndA11y}
> >
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}> <SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5"> <div ref={listRef} className="space-y-1.5">
{blocks.map((b) => {blocks.map((b) =>
b.kind === "movie" ? ( b.kind === "movie" ? (
<MovieRow key={b.id} item={b.item} ctx={ctx} /> <MovieRow key={b.id} item={b.item} ctx={ctx} />
@@ -624,6 +782,9 @@ export default function PlexPlaylistView({
</DndContext> </DndContext>
)} )}
</div> </div>
<ContextMenu menu={rowMenu} ariaLabel={t("plex.playlist.rowMenu")} items={menuItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("plex.playlist.menu")} items={headerItems} />
{titleActions.panel}
</div> </div>
); );
} }
+1 -76
View File
@@ -1,10 +1,9 @@
import { useTranslation } from "react-i18next"; 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 type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format"; import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip"; import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync"; 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 // 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). // channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
@@ -29,77 +28,3 @@ export function StoredCell({ c }: { c: ManagedChannel }) {
</span> </span>
); );
} }
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 (
<div className="inline-flex items-center gap-2">
{/* 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. */}
<Tooltip hint={backfillHint} always={!enabled}>
<IconButton
onClick={isAdmin ? onReset : onDeep}
aria-disabled={!enabled}
variant={requested ? "ghost-accent" : "ghost"}
label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
noTitle
>
<History />
</IconButton>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<IconButton
onClick={onHide}
label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
noTitle
>
{c.hidden ? <EyeOff /> : <Eye />}
</IconButton>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<IconButton
variant="danger"
onClick={onUnsubscribe}
label={t("channels.row.unsubscribeOnYoutube")}
noTitle
>
<UserMinus />
</IconButton>
</Tooltip>
)}
</div>
);
}
@@ -5,7 +5,7 @@
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change // "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
// which channels the top chips show. // which channels the top chips show.
import { describe, it, expect } from "vitest"; 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"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({ const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
@@ -71,3 +71,28 @@ describe("tagsColumnTest (item B — 'No tag')", () => {
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false); 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");
});
});
+17 -33
View File
@@ -1,4 +1,4 @@
import type { ComponentProps } from "react"; import type { ComponentProps, ReactNode } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
@@ -7,7 +7,7 @@ import { PriorityStepper } from "./ui/PriorityStepper";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell"; import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { ActionsCell, StoredCell } from "./channelCells"; import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync"; import { isFullySynced } from "./channelSync";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical /** 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). */ * column block lives outside the page component (its cell components are in channelCells). */
export interface ChannelColumnDeps { export interface ChannelColumnDeps {
t: TFunction; t: TFunction;
isAdmin: boolean;
canWrite: boolean;
prioOptions: { value: string; label: string }[]; prioOptions: { value: string; label: string }[];
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"]; userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
bumpPriority: (id: string, delta: number) => void; bumpPriority: (id: string, delta: number) => void;
setPriority: (id: string, value: number) => void; setPriority: (id: string, value: number) => void;
onHide: (c: ManagedChannel) => void; /** The row's "…" — the door to the channel's one action list (`useChannelActions`). */
onDeep: (c: ManagedChannel) => void; more: (c: ManagedChannel) => ReactNode;
onReset: (c: ManagedChannel) => void;
onUnsub: (c: ManagedChannel) => void;
onView: (c: ManagedChannel) => void; onView: (c: ManagedChannel) => void;
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void; toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
createAndAttachTag: (channelId: string, name: string) => void; createAndAttachTag: (channelId: string, name: string) => void;
@@ -72,16 +68,11 @@ export interface ChannelColumnDeps {
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] { export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const { const {
t, t,
isAdmin,
canWrite,
prioOptions, prioOptions,
userTags, userTags,
bumpPriority, bumpPriority,
setPriority, setPriority,
onHide, more,
onDeep,
onReset,
onUnsub,
onView, onView,
toggleTag, toggleTag,
createAndAttachTag, createAndAttachTag,
@@ -109,25 +100,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
/> />
), ),
}, },
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "5.5rem",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => onHide(c)}
onDeep={() => onDeep(c)}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
{ {
key: "channel", key: "channel",
header: t("channels.cols.channel"), header: t("channels.cols.channel"),
@@ -217,5 +189,17 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
/> />
), ),
}, },
{
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,
},
]; ];
} }
@@ -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<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
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> = {}): 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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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"));
});
});
@@ -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 = <History className="w-4 h-4" />;
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: <Tv className="w-4 h-4" />,
onSelect: () => onView(c),
},
...pin,
...[
{
id: "open-youtube",
label: t("channels.row.openOnYouTube"),
icon: <ExternalLink className="w-4 h-4" />,
// 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: <EyeOff className="w-4 h-4" />,
disabled: hide.isPending,
onSelect: () => hide.mutate(c),
},
...(me.can_write
? [
{
id: "unsubscribe",
label: t("channels.row.unsubscribeOnYoutube"),
title: t("channels.row.unsubscribeHint"),
icon: <UserMinus className="w-4 h-4" />,
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 };
}
@@ -14,7 +14,9 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
})); }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()), ...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -117,9 +119,39 @@ describe("usePlexPlaylistEdits · the removal", () => {
await waitFor(() => expect(hook.result.current.items).toHaveLength(2)); await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline")); 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(errors()).toEqual(["plex.playlist.removeFailed"]);
expect(ids(hook.result.current.items)).toEqual(["1", "2"]); expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
expect(hook.result.current.busy).toBe(false); 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"]));
}); });
}); });
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
/** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal. /** 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; if (busy || !keys.length) return;
setBusy(true); 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); const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id))); setItems((prev) => prev.filter((i) => !set.has(i.id)));
try { try {
@@ -91,10 +98,32 @@ export function usePlexPlaylistEdits(playlistId: number) {
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.playlist.removeFailed")); notifyActionError(e, t("plex.playlist.removeFailed"));
putBack(); putBack();
return;
} finally { } finally {
setBusy(false); 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 };
} }
+7 -6
View File
@@ -107,18 +107,18 @@
"fullySynced": "fully synced", "fullySynced": "fully synced",
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.", "fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
"backfillThis": "Backfill this channel", "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", "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.", "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.", "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.", "hideHint": "Hide this channel's videos from your Feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"unhide": "Unhide",
"hideFromFeed": "Hide from Feed", "hideFromFeed": "Hide from Feed",
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.", "unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
"unsubscribeOnYoutube": "Unsubscribe on YouTube", "unsubscribeOnYoutube": "Unsubscribe on YouTube",
"thisChannel": "This channel" "thisChannel": "This channel",
"menu": "Channel actions"
}, },
"notify": { "notify": {
"synced": "Synced {{count}} subscriptions", "synced": "Synced {{count}} subscriptions",
@@ -131,7 +131,8 @@
"connect": "Connect", "connect": "Connect",
"filteredByTag": "Feed filtered by tag: {{name}}", "filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "{{name}} reset — re-fetching now", "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.", "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.", "confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
+8 -2
View File
@@ -326,8 +326,14 @@
"renameFailed": "Couldn't rename the playlist.", "renameFailed": "Couldn't rename the playlist.",
"deleteFailed": "Couldn't delete the playlist.", "deleteFailed": "Couldn't delete the playlist.",
"reorderFailed": "Couldn't save the new order.", "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": { "people": {
"count_one": "{{count}} title", "count_one": "{{count}} title",
+1 -1
View File
@@ -45,7 +45,7 @@
"sound": "Sound on alerts", "sound": "Sound on alerts",
"soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).", "soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).",
"autoDismiss": "Auto-dismiss toasts", "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", "dismissAfter": "Dismiss after",
"sendTest": "Send a test notification", "sendTest": "Send a test notification",
"testTitle": "Test notification", "testTitle": "Test notification",
+7 -6
View File
@@ -107,18 +107,18 @@
"fullySynced": "teljesen szinkronban", "fullySynced": "teljesen szinkronban",
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.", "fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
"backfillThis": "Csatorna teljes letöltése", "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", "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.", "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.", "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.", "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", "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.", "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", "unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
"thisChannel": "Ez a csatorna" "thisChannel": "Ez a csatorna",
"menu": "Csatorna műveletei"
}, },
"notify": { "notify": {
"synced": "{{count}} feliratkozás szinkronizálva", "synced": "{{count}} feliratkozás szinkronizálva",
@@ -131,7 +131,8 @@
"connect": "Csatlakoztatás", "connect": "Csatlakoztatás",
"filteredByTag": "Feed szűrve címkére: {{name}}", "filteredByTag": "Feed szűrve címkére: {{name}}",
"resetDone": "{{name}} resetelve — újraletöltés folyamatban", "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.", "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.", "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.",
+8 -2
View File
@@ -326,8 +326,14 @@
"renameFailed": "Nem sikerült átnevezni a lejátszási listát.", "renameFailed": "Nem sikerült átnevezni a lejátszási listát.",
"deleteFailed": "Nem sikerült törölni 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.", "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": { "people": {
"count_one": "{{count}} cím", "count_one": "{{count}} cím",
+1 -1
View File
@@ -45,7 +45,7 @@
"sound": "Hang riasztásoknál", "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).", "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", "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", "dismissAfter": "Eltüntetés ennyi után",
"sendTest": "Tesztértesítés küldése", "sendTest": "Tesztértesítés küldése",
"testTitle": "Tesztértesítés", "testTitle": "Tesztértesítés",
+4 -1
View File
@@ -558,13 +558,16 @@ export const plexApi = {
method: "POST", method: "POST",
body: JSON.stringify({ title, item_rating_key }), 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: ( plexPlaylistAddBulk: (
id: number, id: number,
itemRks: string[], itemRks: string[],
positions?: number[],
): Promise<{ added: number; item_count: number }> => ): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, { req(`/api/plex/playlists/${id}/items/bulk`, {
method: "POST", method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks }), body: JSON.stringify({ item_rating_keys: itemRks, ...(positions ? { positions } : {}) }),
}), }),
plexPlaylistRemoveBulk: ( plexPlaylistRemoveBulk: (
id: number, id: number,
@@ -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);
});
});
+7 -1
View File
@@ -92,6 +92,10 @@ const HISTORY_KEY = LS.notifHistory;
const SETTINGS_KEY = LS.notifSettings; const SETTINGS_KEY = LS.notifSettings;
const MAX_HISTORY = 100; const MAX_HISTORY = 100;
const ERROR_TTL = 15000; 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 // 6b: per-account notification settings (persisted by the Settings panel into the
// server `preferences`, mirrored to localStorage so they apply before login). // server `preferences`, mirrored to localStorage so they apply before login).
@@ -269,7 +273,9 @@ export function notify(input: NotifyInput): number {
: level === "error" || level === "fatal" : level === "error" || level === "fatal"
? ERROR_TTL ? ERROR_TTL
: config.durationMs > 0 : config.durationMs > 0
? config.durationMs ? input.action
? Math.max(config.durationMs, ACTION_TTL_MIN)
: config.durationMs
: undefined; : undefined;
// Coalesce a rapid burst of identical notifications (same level+title+message within the // Coalesce a rapid burst of identical notifications (same level+title+message within the