Files
siftlode/frontend/src/components/Channels.tsx
T
peter ca9dd54923 fix(channels,datatable): address R13 S2a review + unify tag chips with the Tags column filter
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.
2026-08-14 02:19:44 +02:00

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>
);
}