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 fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -285,16 +287,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
||||
return out
|
||||
|
||||
|
||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
|
||||
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
|
||||
# at the end whenever the second call fails, and the order it sends would overwrite anything
|
||||
# another tab changed in between.
|
||||
positions: list[Annotated[int, Field(ge=0)]] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_position_per_key(self):
|
||||
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
|
||||
raise ValueError("positions must pair one-to-one with item_rating_keys")
|
||||
return self
|
||||
|
||||
|
||||
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
||||
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||
already in the list does not move: the add stays idempotent."""
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
present = {r.item_id for r in rows}
|
||||
added = 0
|
||||
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||
if it.id in present:
|
||||
continue
|
||||
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
||||
db.add(row)
|
||||
rows.insert(min(at, len(rows)), row)
|
||||
present.add(it.id)
|
||||
added += 1
|
||||
for i, r in enumerate(rows):
|
||||
r.position = i
|
||||
return added
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/bulk")
|
||||
def playlist_add_bulk(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
body: PlexItemAddBulkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
||||
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
||||
instead of appended. Returns how many were added + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
if body.positions is not None:
|
||||
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
||||
placed = [
|
||||
(at, by_rk[str(rk)])
|
||||
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
||||
if str(rk) in by_rk
|
||||
]
|
||||
added = _insert_at(db, pid, placed)
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
|
||||
@@ -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>[] = [
|
||||
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
|
||||
// tabs' action clusters line up in the same place.
|
||||
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
|
||||
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
|
||||
@@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => {
|
||||
expect(opened).toEqual(["UCb"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChannelLayoutGrid · the card\'s "…"', () => {
|
||||
it("docks it in the heading row beside the name, not in the meta line", () => {
|
||||
const columns: Column<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;
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
|
||||
* belongs beside the name it acts on, not under the card's last line. */
|
||||
moreKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
|
||||
* a channel answers the right-click in the card layout exactly as it does in the table. */
|
||||
@@ -42,6 +45,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
leadKey,
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
moreKey,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
emptyText,
|
||||
@@ -49,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
|
||||
const { t } = useTranslation();
|
||||
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
|
||||
const primary = columns.filter((c) => c.cardPrimary);
|
||||
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
||||
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
||||
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
||||
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
|
||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||
|
||||
@@ -70,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
))}
|
||||
</div>
|
||||
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
|
||||
</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">
|
||||
|
||||
@@ -16,8 +16,10 @@ import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { pinChannel } from "./comfortPin";
|
||||
import { pinChannel, useComfortPin } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
@@ -37,7 +39,6 @@ import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
@@ -64,7 +65,6 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
const isAdmin = me.role === "admin";
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
|
||||
@@ -94,7 +94,6 @@ export default function Channels() {
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
|
||||
// callback) now that Channels reads the feed filters + navigation straight from their providers.
|
||||
@@ -130,21 +129,6 @@ export default function Channels() {
|
||||
|
||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: {
|
||||
id: string;
|
||||
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
|
||||
}) => api.updateChannel(v.id, v.body),
|
||||
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||
// Requesting full history may have just pulled in a channel's recent uploads, so
|
||||
// refresh the per-user status and feed too — not only the channel list.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
},
|
||||
});
|
||||
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
||||
// jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll
|
||||
// position). The new order takes effect next time the page loads. Press-and-hold is debounced +
|
||||
@@ -216,25 +200,6 @@ export default function Channels() {
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const resetBackfill = useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
onSuccess: (r: { updated?: number }) => {
|
||||
@@ -328,24 +293,19 @@ export default function Channels() {
|
||||
|
||||
const onView = (c: ManagedChannel) =>
|
||||
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
|
||||
const onUnsub = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.unsubscribeOnYoutube"),
|
||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
|
||||
};
|
||||
const onReset = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.backfillThis"),
|
||||
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.backfillThis"),
|
||||
});
|
||||
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
|
||||
};
|
||||
|
||||
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
|
||||
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
|
||||
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
|
||||
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
|
||||
const rowMenu = useContextMenu<ManagedChannel>();
|
||||
const rowPin = useComfortPin(rowMenu.open);
|
||||
const channelActions = useChannelActions({ onView, onOpenWizard });
|
||||
const menuRow = rowMenu.target;
|
||||
const menuItems = menuRow
|
||||
? channelActions.items(menuRow, {
|
||||
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
|
||||
})
|
||||
: [];
|
||||
// Distinct priority levels present in the catalog → the priority column's filter options.
|
||||
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
|
||||
.sort((a, b) => b - a)
|
||||
@@ -353,16 +313,21 @@ export default function Channels() {
|
||||
|
||||
const columns = buildChannelColumns({
|
||||
t,
|
||||
isAdmin,
|
||||
canWrite,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
|
||||
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
|
||||
onReset,
|
||||
onUnsub,
|
||||
more: (c) => {
|
||||
const mine = rowMenu.open && rowMenu.target === c;
|
||||
return (
|
||||
<MoreButton
|
||||
label={t("channels.row.menu")}
|
||||
expanded={mine}
|
||||
menuProps={rowMenu.buttonProps(c)}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
@@ -430,11 +395,6 @@ export default function Channels() {
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
|
||||
// the card layout — they are two paints of the same rows, so a right-click must mean the same
|
||||
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
|
||||
// `Channel` row yet, so pinning one could only 404.
|
||||
const pinMenu = useContextMenu<ManagedChannel>();
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
@@ -602,7 +562,7 @@ export default function Channels() {
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<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"))}
|
||||
/>
|
||||
</div>
|
||||
@@ -637,7 +597,9 @@ export default function Channels() {
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||
rowClassName={() => "group"}
|
||||
keyboardNav
|
||||
/>
|
||||
) : (
|
||||
@@ -647,16 +609,13 @@ export default function Channels() {
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
)}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinChannel(c.id)}
|
||||
title={(c) => c.title ?? t("channels.row.thisChannel")}
|
||||
/>
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -61,6 +61,20 @@ const client = () => new QueryClient({ defaultOptions: { queries: { retry: false
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
/** Pick `label` from the menu `open` opens. The surface is visibility:hidden in jsdom (floating-ui
|
||||
* never positions it), so the row is matched on its text. */
|
||||
async function pickFromMenu(open: () => void, label: string) {
|
||||
open();
|
||||
const row = await waitFor(() => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(row);
|
||||
}
|
||||
|
||||
describe("the Plex playlist page", () => {
|
||||
it("puts back a row whose removal failed, and says why", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({
|
||||
@@ -69,13 +83,20 @@ describe("the Plex playlist page", () => {
|
||||
items: [movie("1"), movie("2")],
|
||||
});
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist"));
|
||||
// The row's menu reads the comfort shelf (its pin row) once it opens: answered here, or its
|
||||
// failed fetch lands its notice in whichever test runs next.
|
||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
fireEvent.click(screen.getByLabelText("plex.playlist.removeItem:Movie 1"));
|
||||
// E28 C1: the row's X went into its menu (right-click here, the "…" beside it on screen).
|
||||
await pickFromMenu(
|
||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||
"plex.playlist.removeFromPlaylist",
|
||||
);
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
@@ -90,8 +111,11 @@ describe("the Plex playlist page", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Films");
|
||||
fireEvent.click(screen.getByTitle("plex.playlist.rename"));
|
||||
const field = screen.getByDisplayValue("Films");
|
||||
await pickFromMenu(
|
||||
() => fireEvent.click(screen.getByRole("button", { name: "plex.playlist.menu" })),
|
||||
"plex.playlist.rename",
|
||||
);
|
||||
const field = await screen.findByDisplayValue("Films");
|
||||
fireEvent.change(field, { target: { value: "Movies" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
@@ -107,7 +131,11 @@ describe("the Plex playlist page", () => {
|
||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={() => {}} />
|
||||
</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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -417,6 +417,7 @@ export default function PlexBrowse() {
|
||||
playlistId={pid}
|
||||
onBack={sub.back}
|
||||
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// (E9 review). The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list
|
||||
// nothing sums for says no runtime at all.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -60,6 +60,20 @@ async function meta(items: PlexCard[]) {
|
||||
).textContent;
|
||||
}
|
||||
|
||||
/** Pick `label` from the menu `open` opens. The surface is visibility:hidden in jsdom (floating-ui
|
||||
* never positions it), so the row is matched on its text. */
|
||||
async function pickFromMenu(open: () => void, label: string) {
|
||||
open();
|
||||
const row = await waitFor(() => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(row);
|
||||
}
|
||||
|
||||
describe("PlexPlaylistView · the header's meta line", () => {
|
||||
it("sums the known runtimes and counts an unknown one as nothing", async () => {
|
||||
expect(await meta([movie("1", 5400), movie("2"), movie("3", 1800)])).toBe(
|
||||
@@ -80,8 +94,11 @@ describe("PlexPlaylistView · rename", () => {
|
||||
const write = vi.spyOn(api, "plexRenamePlaylist").mockResolvedValue({} as never);
|
||||
await meta([movie("1", 5400)]);
|
||||
for (const value of [null, " "]) {
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.rename" }));
|
||||
const field = screen.getByRole("textbox", { name: "plex.playlist.rename" });
|
||||
await pickFromMenu(
|
||||
() => fireEvent.click(screen.getByRole("button", { name: "plex.playlist.menu" })),
|
||||
"plex.playlist.rename",
|
||||
);
|
||||
const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" });
|
||||
if (value !== null) fireEvent.change(field, { target: { value } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await screen.findByRole("heading", { name: "Films" });
|
||||
|
||||
@@ -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 { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
Play,
|
||||
Rows3,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navItemAttrs, useListNav } from "../lib/listNav";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
@@ -32,6 +33,12 @@ import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { DragHandle } from "./ui/DragHandle";
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
@@ -51,9 +58,22 @@ import { PageHeading } from "./ui/Headings";
|
||||
// list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their
|
||||
// show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a
|
||||
// PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue.
|
||||
//
|
||||
// E28 C1: every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on
|
||||
// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no
|
||||
// confirm: the toast's Undo puts the rows back where they stood. `j`/`k` walk the visible rows and
|
||||
// Delete takes out the one the keyboard is on; the playlist's own rename and delete sit in the
|
||||
// header's "…".
|
||||
|
||||
type Layout = "accordion" | "tree";
|
||||
type Season = { key: string; number: number | null; items: PlexCard[] };
|
||||
type ShowBlock = Extract<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 =
|
||||
| { kind: "movie"; id: string; item: PlexCard }
|
||||
| {
|
||||
@@ -120,7 +140,11 @@ type Ctx = {
|
||||
toggleShow: (k: string) => void;
|
||||
toggleSeason: (k: string) => void;
|
||||
onPlay: (rk: string) => void;
|
||||
onRemove: (keys: string[]) => void;
|
||||
/** The row's "…" and its right-click / long-press / Shift+F10 — the page's one menu, on `target`. */
|
||||
more: (target: MenuTarget) => ReactNode;
|
||||
trigger: (target: MenuTarget) => Record<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 }) {
|
||||
@@ -145,11 +169,14 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const target: MenuTarget = { kind: "item", key: ep.id, item: ep };
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
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
|
||||
label={t("plex.playlist.reorderItem", { title: ep.title })}
|
||||
@@ -161,7 +188,8 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
</span>
|
||||
<Pressable
|
||||
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
|
||||
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>
|
||||
)}
|
||||
{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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove([ep.id])}
|
||||
disabled={ctx.busy}
|
||||
label={t("plex.playlist.removeItem", { title: ep.title })}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
{ctx.more(target)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,8 +224,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const dense = ctx.layout === "tree";
|
||||
const target: MenuTarget = { kind: "item", key: item.id, item };
|
||||
return (
|
||||
<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
|
||||
label={t("plex.playlist.reorderItem", { title: item.title })}
|
||||
{...attributes}
|
||||
@@ -212,30 +240,33 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
/>
|
||||
<Pressable
|
||||
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"} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-muted">{item.year}</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" />
|
||||
</div>
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove([item.id])}
|
||||
disabled={ctx.busy}
|
||||
label={t("plex.playlist.removeItem", { title: item.title })}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
{ctx.more(target)}
|
||||
</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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
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 dense = ctx.layout === "tree";
|
||||
const seasonLabel = (s: Season) =>
|
||||
s.number != null
|
||||
? t("plex.playlist.season", { n: s.number })
|
||||
: t("plex.playlist.seasonUnknown");
|
||||
const seasonLabel = useSeasonLabel();
|
||||
const target: MenuTarget = { kind: "show", key: block.id, block };
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
|
||||
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
|
||||
<div className="flex items-center gap-2.5 p-2">
|
||||
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
|
||||
<div
|
||||
{...ctx.trigger(target)}
|
||||
{...navItemAttrs(ctx.navIndex(block.id), block.title)}
|
||||
className="group flex items-center gap-2.5 p-2"
|
||||
>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: block.title })}
|
||||
{...attributes}
|
||||
@@ -290,20 +323,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
))}
|
||||
</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="text-2xs text-muted">
|
||||
{t("plex.playlist.episodes", { count: block.items.length })}
|
||||
</div>
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
|
||||
disabled={ctx.busy}
|
||||
label={t("plex.playlist.removeShow")}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
{ctx.more(target)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
@@ -315,9 +345,13 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
{block.seasons.map((s) => {
|
||||
const sKey = `${block.showKey}:${s.key}`;
|
||||
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
|
||||
const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s };
|
||||
return (
|
||||
<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 && (
|
||||
<IconButton
|
||||
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">
|
||||
{seasonLabel(s)} · {s.items.length}
|
||||
</span>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
||||
disabled={ctx.busy}
|
||||
label={t("plex.playlist.removeSeason")}
|
||||
className="ml-1"
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
<span className="ml-1">{ctx.more(sTarget)}</span>
|
||||
</div>
|
||||
{sOpen && (
|
||||
<div
|
||||
@@ -365,10 +391,13 @@ export default function PlexPlaylistView({
|
||||
playlistId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onInfoCard,
|
||||
}: {
|
||||
playlistId: number;
|
||||
onBack: () => void;
|
||||
onPlay: (itemRk: string, queue: string[]) => void;
|
||||
/** A title's media-info page, from its menu's "Details". */
|
||||
onInfoCard?: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -390,7 +419,8 @@ export default function PlexPlaylistView({
|
||||
// The query, the optimistic copy and the two writes that edit it (reorder, remove) live in
|
||||
// `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches
|
||||
// `commit` cannot be, since dnd-kit's sensors do not activate under jsdom.
|
||||
const { q, items, busy, invalidate, commit, onRemove } = usePlexPlaylistEdits(playlistId);
|
||||
const { q, items, busy, invalidate, commit, onRemove, setStatus } =
|
||||
usePlexPlaylistEdits(playlistId);
|
||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
|
||||
@@ -504,6 +534,105 @@ export default function PlexPlaylistView({
|
||||
}
|
||||
}
|
||||
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
const rowMenu = useContextMenu<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 = {
|
||||
layout,
|
||||
busy,
|
||||
@@ -512,9 +641,43 @@ export default function PlexPlaylistView({
|
||||
toggleShow,
|
||||
toggleSeason,
|
||||
onPlay: (rk) => onPlay(rk, order),
|
||||
onRemove,
|
||||
more: (target) => {
|
||||
const mine = rowMenu.open && rowMenu.target?.key === target.key;
|
||||
return (
|
||||
<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>[] = [
|
||||
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
|
||||
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
|
||||
@@ -570,18 +733,6 @@ export default function PlexPlaylistView({
|
||||
onChange={setLayout}
|
||||
label={t("plex.playlist.layoutLabel")}
|
||||
/>
|
||||
<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 && (
|
||||
<Button
|
||||
onClick={() => onPlay(order[0]!, order)}
|
||||
@@ -590,9 +741,16 @@ export default function PlexPlaylistView({
|
||||
{t("plex.playlist.playAll")}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton variant="glass" size="md" onClick={del} label={t("plex.playlist.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
|
||||
rename opened from there hands the focus back to it. */}
|
||||
<MoreButton
|
||||
ref={renameFocus.target}
|
||||
variant="glass"
|
||||
size="md"
|
||||
label={t("plex.playlist.menu")}
|
||||
expanded={headerMenu.open}
|
||||
menuProps={headerMenu.buttonProps(playlistId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
@@ -611,7 +769,7 @@ export default function PlexPlaylistView({
|
||||
accessibility={dndA11y}
|
||||
>
|
||||
<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) =>
|
||||
b.kind === "movie" ? (
|
||||
<MovieRow key={b.id} item={b.item} ctx={ctx} />
|
||||
@@ -624,6 +782,9 @@ export default function PlexPlaylistView({
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("plex.playlist.rowMenu")} items={menuItems} />
|
||||
<ContextMenu menu={headerMenu} ariaLabel={t("plex.playlist.menu")} items={headerItems} />
|
||||
{titleActions.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ManagedChannel } from "../lib/api";
|
||||
import { formatNumber } from "../lib/format";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The Channels-manager cells that are components in their own right (they read i18n), split out of
|
||||
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
|
||||
@@ -29,77 +28,3 @@ export function StoredCell({ c }: { c: ManagedChannel }) {
|
||||
</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
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
|
||||
import { applySyncFilter, buildChannelColumns, tagsColumnTest } from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
@@ -71,3 +71,28 @@ describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChannelColumns — the row's end", () => {
|
||||
it('ends every row in the channel\'s "…", with no action buttons before it', () => {
|
||||
const more = (c: ManagedChannel) => `more:${c.id}`;
|
||||
const noop = () => {};
|
||||
const cols = buildChannelColumns({
|
||||
t: ((k: string) => k) as never,
|
||||
prioOptions: [],
|
||||
userTags: [],
|
||||
bumpPriority: noop,
|
||||
setPriority: noop,
|
||||
more,
|
||||
onView: noop,
|
||||
toggleTag: noop,
|
||||
createAndAttachTag: noop,
|
||||
onFilterByTag: noop,
|
||||
suggestionsFor: () => [],
|
||||
onAcceptSuggestion: noop,
|
||||
onDismissSuggestion: noop,
|
||||
});
|
||||
expect(cols[cols.length - 1]!.key).toBe("actions");
|
||||
expect(cols.filter((c) => c.key === "actions")).toHaveLength(1);
|
||||
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
||||
@@ -7,7 +7,7 @@ import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import { ActionsCell, StoredCell } from "./channelCells";
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
@@ -49,16 +49,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
* column block lives outside the page component (its cell components are in channelCells). */
|
||||
export interface ChannelColumnDeps {
|
||||
t: TFunction;
|
||||
isAdmin: boolean;
|
||||
canWrite: boolean;
|
||||
prioOptions: { value: string; label: string }[];
|
||||
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
||||
bumpPriority: (id: string, delta: number) => void;
|
||||
setPriority: (id: string, value: number) => void;
|
||||
onHide: (c: ManagedChannel) => void;
|
||||
onDeep: (c: ManagedChannel) => void;
|
||||
onReset: (c: ManagedChannel) => void;
|
||||
onUnsub: (c: ManagedChannel) => void;
|
||||
/** The row's "…" — the door to the channel's one action list (`useChannelActions`). */
|
||||
more: (c: ManagedChannel) => ReactNode;
|
||||
onView: (c: ManagedChannel) => void;
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
@@ -72,16 +68,11 @@ export interface ChannelColumnDeps {
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
const {
|
||||
t,
|
||||
isAdmin,
|
||||
canWrite,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
onHide,
|
||||
onDeep,
|
||||
onReset,
|
||||
onUnsub,
|
||||
more,
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
@@ -109,25 +100,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<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",
|
||||
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" } }),
|
||||
}));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
notify: vi.fn(
|
||||
(_n: { level: string; message: string; action?: { label: string; onClick: () => void } }) => 0,
|
||||
),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
@@ -117,9 +119,39 @@ describe("usePlexPlaylistEdits · the removal", () => {
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
|
||||
|
||||
await act(() => hook.result.current.onRemove(["2"]));
|
||||
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
|
||||
expect(errors()).toEqual(["plex.playlist.removeFailed"]);
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
|
||||
expect(hook.result.current.busy).toBe(false);
|
||||
// A failed removal offers no Undo: there is nothing to put back.
|
||||
expect(notify.mock.calls.some(([n]) => n.action)).toBe(false);
|
||||
});
|
||||
|
||||
// E28 C1: no confirm — the toast's Undo is the way back, and it puts each row where it stood.
|
||||
it("says what went, and its Undo puts the rows back at their own indices in one request", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(4));
|
||||
vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
|
||||
// A season whose episodes do not sit together: rows 2 and 4.
|
||||
await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
expect(toast.message).toBe("plex.playlist.removedToast");
|
||||
expect(toast.action!.label).toBe("common.undo");
|
||||
act(() => toast.action!.onClick());
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
expect(add).toHaveBeenCalledWith(5, ["4", "2"], [3, 1]);
|
||||
});
|
||||
|
||||
it("says so when the Undo fails", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
|
||||
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.undoRemoveFailed"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
/** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal.
|
||||
@@ -79,9 +80,15 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(keys: string[]) {
|
||||
/** Take `keys` out — one row, a season or a whole show — with no confirm: the toast's Undo puts
|
||||
* each row back at the index it held (E28 C1, the same bargain as a YouTube playlist's rows).
|
||||
* `title` names what went in that toast. */
|
||||
async function onRemove(keys: string[], title: string) {
|
||||
if (busy || !keys.length) return;
|
||||
setBusy(true);
|
||||
// Indices over the list as it stands now, which is the server's order: a season's episodes need
|
||||
// not sit together (a reorder inside the show interleaves them), so each key keeps its own.
|
||||
const positions = keys.map((k) => items.findIndex((i) => i.id === k));
|
||||
const set = new Set(keys);
|
||||
setItems((prev) => prev.filter((i) => !set.has(i.id)));
|
||||
try {
|
||||
@@ -91,10 +98,32 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
const back = keys.filter((_, i) => positions[i]! >= 0);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("plex.playlist.removedToast", { title }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void api
|
||||
.plexPlaylistAddBulk(
|
||||
playlistId,
|
||||
back,
|
||||
positions.filter((p) => p >= 0),
|
||||
)
|
||||
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
|
||||
.finally(invalidate),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { q, items, busy, invalidate, commit, onRemove };
|
||||
/** A watched toggle's optimistic step on the local copy (the query refetch then confirms it). */
|
||||
const setStatus = (id: string, status: string) =>
|
||||
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status } : i)));
|
||||
|
||||
return { q, items, busy, invalidate, commit, onRemove, setStatus };
|
||||
}
|
||||
|
||||
@@ -107,18 +107,18 @@
|
||||
"fullySynced": "fully synced",
|
||||
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
||||
"backfillThis": "Backfill this channel",
|
||||
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
||||
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search).",
|
||||
"resetBackfill": "Reset & re-fetch this channel",
|
||||
"cancelBackfill": "Cancel the full-history request",
|
||||
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
|
||||
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
||||
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows.",
|
||||
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
||||
"hiddenHint": "Hidden — this channel's videos are kept out of your Feed. Click to show them again.",
|
||||
"hideHint": "Hide this channel's videos from your Feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||
"unhide": "Unhide",
|
||||
"hideFromFeed": "Hide from Feed",
|
||||
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
|
||||
"unsubscribeOnYoutube": "Unsubscribe on YouTube",
|
||||
"thisChannel": "This channel"
|
||||
"thisChannel": "This channel",
|
||||
"menu": "Channel actions"
|
||||
},
|
||||
"notify": {
|
||||
"synced": "Synced {{count}} subscriptions",
|
||||
@@ -131,7 +131,8 @@
|
||||
"connect": "Connect",
|
||||
"filteredByTag": "Feed filtered by tag: {{name}}",
|
||||
"resetDone": "{{name}} reset — re-fetching now",
|
||||
"resetFailed": "Couldn't reset this channel"
|
||||
"resetFailed": "Couldn't reset this channel",
|
||||
"hidden": "“{{name}}” hidden from your Feed"
|
||||
},
|
||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your Feed, hide it instead.",
|
||||
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
||||
|
||||
@@ -326,8 +326,14 @@
|
||||
"renameFailed": "Couldn't rename the playlist.",
|
||||
"deleteFailed": "Couldn't delete the playlist.",
|
||||
"reorderFailed": "Couldn't save the new order.",
|
||||
"removeItem": "Remove “{{title}}” from the playlist",
|
||||
"reorderItem": "Move “{{title}}”"
|
||||
"reorderItem": "Move “{{title}}”",
|
||||
"rowMenu": "Item actions",
|
||||
"menu": "Playlist actions",
|
||||
"playShow": "Play the show",
|
||||
"playSeason": "Play the season",
|
||||
"removeFromPlaylist": "Remove from the playlist",
|
||||
"removedToast": "“{{title}}” removed from the playlist",
|
||||
"undoRemoveFailed": "Couldn't put it back in the playlist."
|
||||
},
|
||||
"people": {
|
||||
"count_one": "{{count}} title",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"sound": "Sound on alerts",
|
||||
"soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).",
|
||||
"autoDismiss": "Auto-dismiss toasts",
|
||||
"autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time.",
|
||||
"autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time; one with a button (Undo) stays at least 8 seconds.",
|
||||
"dismissAfter": "Dismiss after",
|
||||
"sendTest": "Send a test notification",
|
||||
"testTitle": "Test notification",
|
||||
|
||||
@@ -107,18 +107,18 @@
|
||||
"fullySynced": "teljesen szinkronban",
|
||||
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
||||
"backfillThis": "Csatorna teljes letöltése",
|
||||
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
|
||||
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés).",
|
||||
"resetBackfill": "Csatorna resetelése és újraletöltése",
|
||||
"cancelBackfill": "A teljes letöltés kérésének visszavonása",
|
||||
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi.",
|
||||
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
||||
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a Feededben. Kattints, hogy újra láthatóak legyenek.",
|
||||
"hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||
"unhide": "Megjelenítés",
|
||||
"hideFromFeed": "Elrejtés a Feedből",
|
||||
"unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.",
|
||||
"unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
|
||||
"thisChannel": "Ez a csatorna"
|
||||
"thisChannel": "Ez a csatorna",
|
||||
"menu": "Csatorna műveletei"
|
||||
},
|
||||
"notify": {
|
||||
"synced": "{{count}} feliratkozás szinkronizálva",
|
||||
@@ -131,7 +131,8 @@
|
||||
"connect": "Csatlakoztatás",
|
||||
"filteredByTag": "Feed szűrve címkére: {{name}}",
|
||||
"resetDone": "{{name}} resetelve — újraletöltés folyamatban",
|
||||
"resetFailed": "Nem sikerült resetelni a csatornát"
|
||||
"resetFailed": "Nem sikerült resetelni a csatornát",
|
||||
"hidden": "„{{name}}” elrejtve a Feedből"
|
||||
},
|
||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.",
|
||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||
|
||||
@@ -326,8 +326,14 @@
|
||||
"renameFailed": "Nem sikerült átnevezni a lejátszási listát.",
|
||||
"deleteFailed": "Nem sikerült törölni a lejátszási listát.",
|
||||
"reorderFailed": "Nem sikerült menteni az új sorrendet.",
|
||||
"removeItem": "„{{title}}” eltávolítása a listáról",
|
||||
"reorderItem": "„{{title}}” áthelyezése"
|
||||
"reorderItem": "„{{title}}” áthelyezése",
|
||||
"rowMenu": "Elem műveletei",
|
||||
"menu": "Lista műveletei",
|
||||
"playShow": "Sorozat lejátszása",
|
||||
"playSeason": "Évad lejátszása",
|
||||
"removeFromPlaylist": "Eltávolítás a listáról",
|
||||
"removedToast": "„{{title}}” eltávolítva a listáról",
|
||||
"undoRemoveFailed": "Nem sikerült visszatenni a listára."
|
||||
},
|
||||
"people": {
|
||||
"count_one": "{{count}} cím",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"sound": "Hang riasztásoknál",
|
||||
"soundHint": "Rövid hangjelzés, ha egy értesítés a figyelmedet igényli (pl. hibák, kérdések).",
|
||||
"autoDismiss": "Buborékok automatikus eltüntetése",
|
||||
"autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak.",
|
||||
"autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak; a gombos (Visszavonás) legalább 8 másodpercig marad.",
|
||||
"dismissAfter": "Eltüntetés ennyi után",
|
||||
"sendTest": "Tesztértesítés küldése",
|
||||
"testTitle": "Tesztértesítés",
|
||||
|
||||
@@ -558,13 +558,16 @@ export const plexApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, item_rating_key }),
|
||||
}),
|
||||
/** `positions` (one per key, an index over the ordered rows) puts removed rows back where they
|
||||
* stood — the list page's Undo, in one request; without it the keys are appended. */
|
||||
plexPlaylistAddBulk: (
|
||||
id: number,
|
||||
itemRks: string[],
|
||||
positions?: number[],
|
||||
): Promise<{ added: number; item_count: number }> =>
|
||||
req(`/api/plex/playlists/${id}/items/bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||
body: JSON.stringify({ item_rating_keys: itemRks, ...(positions ? { positions } : {}) }),
|
||||
}),
|
||||
plexPlaylistRemoveBulk: (
|
||||
id: number,
|
||||
|
||||
@@ -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 MAX_HISTORY = 100;
|
||||
const ERROR_TTL = 15000;
|
||||
// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast
|
||||
// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner, E28).
|
||||
// The setting still decides for longer, and "never" still means never.
|
||||
export const ACTION_TTL_MIN = 8000;
|
||||
|
||||
// 6b: per-account notification settings (persisted by the Settings panel into the
|
||||
// server `preferences`, mirrored to localStorage so they apply before login).
|
||||
@@ -269,7 +273,9 @@ export function notify(input: NotifyInput): number {
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: config.durationMs > 0
|
||||
? config.durationMs
|
||||
? input.action
|
||||
? Math.max(config.durationMs, ACTION_TTL_MIN)
|
||||
: config.durationMs
|
||||
: undefined;
|
||||
|
||||
// Coalesce a rapid burst of identical notifications (same level+title+message within the
|
||||
|
||||
Reference in New Issue
Block a user