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

- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
This commit is contained in:
2026-09-26 05:56:07 +02:00
parent b0e51c6f89
commit 1b946113c6
27 changed files with 1354 additions and 293 deletions
@@ -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 };
}