diff --git a/frontend/src/components/ActionMenu.tsx b/frontend/src/components/ActionMenu.tsx
new file mode 100644
index 00000000..f959b974
--- /dev/null
+++ b/frontend/src/components/ActionMenu.tsx
@@ -0,0 +1,74 @@
+import { useState, type ReactNode } from "react";
+import { MoreHorizontal } from "lucide-react";
+import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
+
+// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
+// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
+// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
+// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
+
+export interface ActionMenuItem {
+ id: string;
+ label: string;
+ icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
+ onSelect: () => void;
+ disabled?: boolean;
+ title?: string; // optional native tooltip (carries a longer hint the label alone can't)
+}
+
+export default function ActionMenu({
+ items,
+ ariaLabel,
+ className,
+ trigger,
+}: {
+ items: readonly ActionMenuItem[];
+ /** Accessible name for the trigger (e.g. "Catalog actions"). */
+ ariaLabel: string;
+ /** Trigger className override; omit for the default glass ⋯ icon button. */
+ className?: string;
+ /** Custom trigger content; omit for the default ⋯ icon. */
+ trigger?: ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
+ return (
+ <>
+
+
+ {items.map((it) => (
+ {
+ it.onSelect();
+ setOpen(false);
+ }}
+ className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
+ >
+ {it.icon && {it.icon}}
+ {it.label}
+
+ ))}
+
+ >
+ );
+}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index b6ba7706..a3f8190a 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -20,6 +20,7 @@ import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
+import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import Pager from "./Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
@@ -347,30 +348,28 @@ export default function Channels() {
);
- // Sync + backfill — now share the tags row (right side).
+ // Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the
+ // tags row. Pending mutations keep the item visible but disabled, with a spinning icon.
+ const catalogActions: ActionMenuItem[] = [
+ {
+ id: "sync",
+ label: t("channels.syncSubscriptions"),
+ title: t("channels.syncSubscriptionsHint"),
+ icon: ,
+ onSelect: () => syncSubs.mutate(),
+ disabled: syncSubs.isPending,
+ },
+ {
+ id: "backfill",
+ label: t("channels.backfillEverything"),
+ title: t("channels.backfillEverythingHint"),
+ icon: ,
+ onSelect: () => deepAll.mutate(),
+ disabled: deepAll.isPending,
+ },
+ ];
const actionButtons = (
-
-
-
-
-
-
-
-
+
);
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
@@ -465,10 +464,16 @@ export default function Channels() {
search lives in the shared top SearchBar, tag create/delete in the manager. */}
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
+ "Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
-
- {t("channels.tags.yourTags")}
-
+
{userTags.map((tg) => (
{actionButtons}
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 0073af9c..899ca173 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -1,4 +1,5 @@
{
+ "actionsLabel": "Catalog actions",
"syncSubscriptions": "Read subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything",
@@ -45,6 +46,7 @@
"fullySynced": "Fully synced"
},
"tags": {
+ "label": "Tags",
"yourTags": "Your tags",
"manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index 1f685502..517ed5b4 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -45,6 +45,7 @@
"fullySynced": "Teljesen szinkronizálva"
},
"tags": {
+ "label": "Címkék",
"yourTags": "Címkéid",
"manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",