Review fixes (user /code-review high):
- DataTable: hoist a stable EMPTY_FILTERS fallback so a controlled caller that omits
`filters` doesn't churn a fresh {} each render.
- DataTable: the persistKey blob now stores only the slices this table OWNS — a
controlled sort/filter is written as null/{} instead of a stale never-read duplicate.
- useFilterState: optional `migrate` seed (used only when storage is empty);
ChannelsProvider carries the retired LS.channelFilter sync chip forward via migrateSyncChip.
- Channels: guard the sync-chip read with typeof instead of an `as string` cast.
Also unify the tag filter, same as the sync one: the top "YOUR TAGS" chip row is now a
controlled view over the Tags COLUMN filter (`filters.tags`), replacing its own tagFilter
state — so the chips and the Tags funnel share one state bidirectionally, the card layout
applies it via filterRows, and it persists with the rest of the map.
612 lines
26 KiB
TypeScript
612 lines
26 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
|
import ExcludedChannelList from "./ExcludedChannelList";
|
|
import { api, type ManagedChannel } from "../lib/api";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import { accountKey, LS } from "../lib/storage";
|
|
import { formatEta } from "../lib/format";
|
|
import { buildChannelColumns } from "./channelColumns";
|
|
import { notify } from "../lib/notifications";
|
|
import Tooltip from "./Tooltip";
|
|
import DataTable from "./DataTable";
|
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
|
import { LoadingState, StateMessage } from "./QueryState";
|
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
|
import { useChannelPriority } from "../lib/useChannelPriority";
|
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
|
import ColumnSortControl from "./ColumnSortControl";
|
|
import Pager from "./Pager";
|
|
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
|
import { sortRows } from "../lib/columnSort";
|
|
import { filterRows } from "./tableHeader";
|
|
import { useCardPager } from "../lib/useCardPager";
|
|
import { PageToolbar } from "./PageShell";
|
|
import ChannelDiscovery from "./ChannelDiscovery";
|
|
import TagManager from "./TagManager";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
|
|
import { useNavigationActions } from "./NavigationProvider";
|
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
|
import { useWizard } from "./WizardProvider";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
|
|
|
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
|
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
|
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
|
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
|
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
|
{ value: "", labelKey: "channels.filters.all" },
|
|
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
|
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
|
];
|
|
|
|
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
|
table: Table,
|
|
cards: LayoutGrid,
|
|
};
|
|
|
|
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
|
|
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
|
|
// callers); this page is their primary editor.
|
|
const {
|
|
search,
|
|
filters,
|
|
view,
|
|
sort,
|
|
filtersResetToken,
|
|
focusName: focusChannelName,
|
|
focusToken: focusChannelToken,
|
|
} = useChannels();
|
|
const layout = useChannelLayout();
|
|
const {
|
|
setSearch,
|
|
setFilters,
|
|
setView,
|
|
setLayout,
|
|
setSort,
|
|
focusChannel: onFocusChannel,
|
|
} = useChannelsActions();
|
|
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
|
const feedFilters = useFeedFilters();
|
|
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.
|
|
const onFilterByTag = (tagId: number, name: string) => {
|
|
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
|
setPage("feed");
|
|
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
|
};
|
|
|
|
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
|
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
|
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
|
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
|
const unblock = useMutation({
|
|
mutationFn: (id: string) => api.unblockChannel(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
|
},
|
|
});
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
|
qc.invalidateQueries({ queryKey: qk.tags() });
|
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
|
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
|
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
|
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
|
};
|
|
|
|
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),
|
|
// 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.feedCount() });
|
|
},
|
|
});
|
|
// 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 +
|
|
// flushed on unmount by the shared hook (same flow the channel detail page uses).
|
|
const { setPriority, bumpPriority } = useChannelPriority({
|
|
read: (id) =>
|
|
qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority,
|
|
optimistic: (id, next) =>
|
|
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
|
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
|
|
),
|
|
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
|
});
|
|
|
|
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
|
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
|
|
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
|
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
|
optimisticToggle: (id, tagId, attach) =>
|
|
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
|
(old ?? []).map((ch) =>
|
|
ch.id === id
|
|
? {
|
|
...ch,
|
|
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
|
}
|
|
: ch,
|
|
),
|
|
),
|
|
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
|
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
|
|
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
|
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
|
});
|
|
const syncSubs = useMutation({
|
|
mutationFn: () => api.syncSubscriptions(),
|
|
onSuccess: (r: { subscriptions?: number }) => {
|
|
invalidate();
|
|
notify({
|
|
level: "success",
|
|
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
|
|
});
|
|
},
|
|
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 }) => {
|
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
|
notify({
|
|
level: "success",
|
|
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
|
});
|
|
},
|
|
onError: (e) =>
|
|
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
|
});
|
|
|
|
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
|
|
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
|
|
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
|
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
|
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
|
const toggleTagFilter = (tagId: number) => {
|
|
const id = String(tagId);
|
|
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
|
|
setFilters({ ...filters, tags: next });
|
|
};
|
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
|
useEffect(() => {
|
|
if (focusChannelName) setSearch(focusChannelName);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [focusChannelToken]);
|
|
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
|
|
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
|
const resetRef = useRef(filtersResetToken);
|
|
useEffect(() => {
|
|
if (filtersResetToken === resetRef.current) return;
|
|
resetRef.current = filtersResetToken;
|
|
setSearch("");
|
|
}, [filtersResetToken]);
|
|
|
|
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
|
|
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
|
|
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
|
|
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
|
|
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
|
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
|
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
|
const q = search.trim().toLowerCase();
|
|
const visibleChannels = channels.filter(
|
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
|
);
|
|
const s = statusQuery.data;
|
|
|
|
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 });
|
|
};
|
|
|
|
// 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)
|
|
.map((p) => ({ value: String(p), label: String(p) }));
|
|
|
|
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,
|
|
onView,
|
|
toggleTag,
|
|
createAndAttachTag,
|
|
onFilterByTag,
|
|
});
|
|
|
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
|
// where they act.
|
|
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
|
|
const statusChips = (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{SYNC_CHIPS.map((c) => (
|
|
<button
|
|
key={c.value || "all"}
|
|
onClick={() => setFilters({ ...filters, sync: c.value })}
|
|
aria-pressed={syncFilter === c.value}
|
|
className="pill text-xs px-2.5 py-1 rounded-full border"
|
|
>
|
|
{t(c.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
|
|
// future tabs on the left.
|
|
const statsBlock = (
|
|
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
|
|
{s && (
|
|
<>
|
|
<Stat
|
|
label={t("channels.stats.channels")}
|
|
value={s.channels_total}
|
|
hint={t("channels.stats.channelsHint")}
|
|
/>
|
|
<Stat
|
|
label={t("channels.stats.recentSynced")}
|
|
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
|
hint={t("channels.stats.recentSyncedHint")}
|
|
/>
|
|
<Stat
|
|
label={t("channels.stats.fullHistory")}
|
|
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
|
hint={t("channels.stats.fullHistoryHint")}
|
|
/>
|
|
{s.deep_pending_count > 0 && (
|
|
<Stat
|
|
label={t("channels.stats.left")}
|
|
value={formatEta(s.deep_eta_seconds)}
|
|
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
|
/>
|
|
)}
|
|
<Stat
|
|
label={t("channels.stats.myVideos")}
|
|
value={s.my_videos.toLocaleString()}
|
|
hint={t("channels.stats.myVideosHint")}
|
|
/>
|
|
<Stat
|
|
label={t("channels.stats.quotaLeft")}
|
|
value={s.quota_remaining_today.toLocaleString()}
|
|
hint={t("channels.stats.quotaLeftHint")}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Sync + backfill — now share the tags row (right side).
|
|
const actionButtons = (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Tooltip side="bottom" hint={t("channels.syncSubscriptionsHint")}>
|
|
<button
|
|
onClick={() => syncSubs.mutate()}
|
|
disabled={syncSubs.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
|
{t("channels.syncSubscriptions")}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip side="bottom" hint={t("channels.backfillEverythingHint")}>
|
|
<button
|
|
onClick={() => deepAll.mutate()}
|
|
disabled={deepAll.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
|
{t("channels.backfillEverything")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
|
|
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
|
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
|
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
|
id,
|
|
label: t("channels.layout." + id),
|
|
icon: LAYOUT_ICON[id],
|
|
}));
|
|
const layoutSwitcher = (
|
|
<ViewSwitcher
|
|
value={layout}
|
|
options={layoutOptions}
|
|
onChange={setLayout}
|
|
label={t("channels.layoutLabel")}
|
|
labelClassName="hidden lg:inline"
|
|
/>
|
|
);
|
|
const hasLayout = view === "subscribed" || view === "discovery";
|
|
// 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 = (
|
|
<ColumnSortControl
|
|
columns={columns}
|
|
sort={sort}
|
|
onChange={setSort}
|
|
label={t("channels.sort.label")}
|
|
defaultLabel={t("channels.sort.default")}
|
|
ascLabel={t("channels.sort.asc")}
|
|
descLabel={t("channels.sort.desc")}
|
|
/>
|
|
);
|
|
// The card layout paginates like the table (a page of cards + a pager), rather than one long
|
|
// infinite scroll. Slice the sorted rows; the Pager reports page/size changes.
|
|
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
|
|
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
|
const isCards = layout === "cards";
|
|
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
|
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
|
const sortedChannels = isCards
|
|
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
|
|
: visibleChannels;
|
|
const {
|
|
paged: pagedCards,
|
|
pagerProps,
|
|
resetPage,
|
|
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
|
|
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
|
|
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
|
|
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
|
|
// otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
|
|
// handled by the hook's clamp instead.
|
|
useEffect(() => {
|
|
resetPage();
|
|
}, [filters, search, sort, resetPage]);
|
|
const cardPager = <Pager {...pagerProps} />;
|
|
|
|
const tabs = (
|
|
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => (
|
|
<button
|
|
key={v}
|
|
onClick={() => setView(v)}
|
|
aria-pressed={view === v}
|
|
className="pill text-sm px-3 py-1.5 rounded-full border"
|
|
>
|
|
{t(`channels.tabs.${v}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{view === "subscribed" && statsBlock}
|
|
{view === "subscribed" && layout === "cards" && sortControl}
|
|
{hasLayout && layoutSwitcher}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
|
|
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
|
|
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
|
|
<PageToolbar>
|
|
{tabs}
|
|
{view === "subscribed" && (
|
|
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
|
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
|
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
|
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
|
{t("channels.tags.yourTags")}
|
|
</span>
|
|
</Tooltip>
|
|
{userTags.map((tg) => (
|
|
<button
|
|
key={tg.id}
|
|
onClick={() => toggleTagFilter(tg.id)}
|
|
aria-pressed={tagFilter.includes(String(tg.id))}
|
|
className="pill text-xs px-2 py-1 rounded-full border"
|
|
>
|
|
{tg.name}
|
|
</button>
|
|
))}
|
|
{tagFilter.length > 0 && (
|
|
<button
|
|
onClick={() => setFilters({ ...filters, tags: [] })}
|
|
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
|
>
|
|
{t("datatable.clear")}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setTagManagerOpen(true)}
|
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
{t("channels.tags.manage")}
|
|
</button>
|
|
</div>
|
|
{actionButtons}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
|
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
|
{view === "subscribed" && layout === "cards" && (
|
|
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
|
|
{statusChips}
|
|
{cardPager}
|
|
</div>
|
|
)}
|
|
</PageToolbar>
|
|
{tagManagerOpen && (
|
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
|
)}
|
|
|
|
{view === "discovery" ? (
|
|
<ChannelDiscovery
|
|
canWrite={canWrite}
|
|
search={search}
|
|
onOpenWizard={onOpenWizard}
|
|
onViewChannel={onViewChannel}
|
|
/>
|
|
) : view === "blocked" ? (
|
|
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
|
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
|
<ExcludedChannelList
|
|
channels={blockedQuery.data ?? []}
|
|
icon={<Ban className="w-4 h-4 text-muted" />}
|
|
title={t("channels.blocked.title")}
|
|
hint={t("channels.blocked.hint")}
|
|
emptyText={t("channels.blocked.empty")}
|
|
actionLabel={t("channel.unblock")}
|
|
actionIcon={<X className="w-3.5 h-3.5" />}
|
|
onAction={(id) => unblock.mutate(id)}
|
|
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
|
/>
|
|
</div>
|
|
) : view === "hidden" ? (
|
|
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
|
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
|
|
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
|
<ExcludedChannelList
|
|
channels={hiddenChannels}
|
|
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
|
title={t("channels.hidden.title")}
|
|
hint={t("channels.hidden.hint")}
|
|
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 } })}
|
|
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
|
/>
|
|
</div>
|
|
) : (
|
|
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
|
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
|
visual gap). */
|
|
<div className="px-4 pb-4 max-w-384 mx-auto">
|
|
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
|
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
|
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
|
status chips ride in the fixed band above instead). */}
|
|
{channelsQuery.isError && !channelsQuery.data ? (
|
|
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : channelsQuery.isLoading ? (
|
|
<LoadingState label={t("channels.loading")} />
|
|
) : layout === "table" ? (
|
|
<DataTable
|
|
rows={visibleChannels}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
|
controlsPosition="both"
|
|
controlsLeading={statusChips}
|
|
controlsInBand
|
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
|
sort={sort}
|
|
onSortChange={setSort}
|
|
filters={filters}
|
|
onFiltersChange={setFilters}
|
|
emptyText={t("channels.empty")}
|
|
/>
|
|
) : (
|
|
<ChannelLayoutGrid
|
|
rows={pagedCards}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
leadKey="priority"
|
|
aboutKey="about"
|
|
actionsKey="actions"
|
|
emptyText={t("channels.empty")}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
|
|
return (
|
|
<Tooltip hint={hint ?? ""}>
|
|
<span className={hint ? "cursor-help" : ""}>
|
|
<span className="text-fg font-semibold">{value}</span> {label}
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
}
|