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:
@@ -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"]
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user