refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,251 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache";
import { api, type ManagedChannel, type Tag } from "../../lib/api";
import { priorityChoices } from "../../lib/channelOrder";
import { compareText } from "../../lib/collate";
import { channelYouTubeUrl } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
// A subscribed channel's ONE action list: 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. Tags and
// priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one
// up the list, from the menu the hand is already in.
export function useChannelActions({
onView,
onOpenWizard,
tags,
tagsState = "ready",
onToggleTag,
onPickPriority,
}: {
onView: (c: ManagedChannel) => void;
onOpenWizard: () => void;
/** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */
tags: readonly Tag[];
/** Whether `tags` is the answer yet: loading or failed is said as such, never as "no tags yet". */
tagsState?: "loading" | "error" | "ready";
onToggleTag: (c: ManagedChannel, tagId: number) => void;
/** A priority step picked: the caller re-sorts its list and follows the row. */
onPickPriority: (c: ManagedChannel, priority: number) => void;
}) {
const qc = useQueryClient();
const { t, i18n } = useTranslation();
const lang = i18n.language;
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) => {
invalidateSubscription(qc, c.id);
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 } }),
};
}
/** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag
* yet the row stays, off, and says where tags are made. */
function tagsItem(c: ManagedChannel): ActionMenuItem {
const base = {
id: "tags",
label: t("channels.tags.label"),
icon: <Tags className="w-4 h-4" />,
};
if (tags.length === 0) {
const note =
tagsState === "loading"
? t("common.loading")
: tagsState === "error"
? t("common.loadError")
: t("channels.row.noTagsYet");
return { ...base, disabled: true, note, onSelect: () => {} };
}
const on = new Set(c.tag_ids);
return {
...base,
items: [...tags]
.sort((a, b) => compareText(a.name, b.name, lang))
.map((tg) => ({
id: `tag-${tg.id}`,
label: tg.name,
check: { kind: "many" as const, checked: on.has(tg.id) },
onSelect: () => onToggleTag(c, tg.id),
})),
};
}
/** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */
function priorityItem(c: ManagedChannel): ActionMenuItem {
return {
id: "priority",
label: t("channels.row.priority"),
icon: <ArrowUpDown className="w-4 h-4" />,
items: priorityChoices(c.priority).map((p) => ({
id: `priority-${p}`,
label: String(p),
check: { kind: "one" as const, checked: p === c.priority },
onSelect: () => {
if (p !== c.priority) onPickPriority(c, p);
},
})),
};
}
/** 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,
{ ...tagsItem(c), separatorBefore: true },
priorityItem(c),
...[
{
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 };
}