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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user