Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
1109 lines
42 KiB
TypeScript
1109 lines
42 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Ban,
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
History,
|
|
LayoutGrid,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Table,
|
|
UserMinus,
|
|
X,
|
|
} from "lucide-react";
|
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import { accountKey, LS } from "../lib/storage";
|
|
import { useDismiss } from "../lib/useDismiss";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
|
import { subsColumn } from "./channelColumns";
|
|
import { notify } from "../lib/notifications";
|
|
import Tooltip from "./Tooltip";
|
|
import DataTable, { type Column } from "./DataTable";
|
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
|
import Overlay from "./Overlay";
|
|
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 { useCardPager } from "../lib/useCardPager";
|
|
import { PageToolbar } from "./PageShell";
|
|
import ChannelLink from "./ChannelLink";
|
|
import AboutCell from "./AboutCell";
|
|
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" | "blocked";
|
|
|
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
|
|
|
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|
{ id: "all", labelKey: "channels.filters.all" },
|
|
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
|
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
|
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
|
];
|
|
|
|
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,
|
|
statusFilter,
|
|
view,
|
|
sort,
|
|
filtersResetToken,
|
|
focusName: focusChannelName,
|
|
focusToken: focusChannelToken,
|
|
} = useChannels();
|
|
const layout = useChannelLayout();
|
|
const {
|
|
setSearch,
|
|
setStatusFilter,
|
|
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: ["channels"], queryFn: api.channels });
|
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
|
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
|
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
|
const unblock = useMutation({
|
|
mutationFn: (id: string) => api.unblockChannel(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
|
},
|
|
});
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
// 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: ["feed"] });
|
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
|
};
|
|
|
|
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: ["channels"] });
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
|
},
|
|
});
|
|
// 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.
|
|
const bumpPriority = (id: string, current: number, delta: number) => {
|
|
const next = current + delta;
|
|
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
|
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
|
|
);
|
|
api
|
|
.updateChannel(id, { priority: next })
|
|
.catch(() => qc.invalidateQueries({ queryKey: ["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.
|
|
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
|
|
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
|
(old ?? []).map((ch) =>
|
|
ch.id === id
|
|
? {
|
|
...ch,
|
|
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId],
|
|
}
|
|
: ch,
|
|
),
|
|
);
|
|
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
|
|
call.catch(() => qc.invalidateQueries({ queryKey: ["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: ["channels"] });
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
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: ["channels"] });
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
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: ["channels"] });
|
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
notify({
|
|
level: "success",
|
|
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
|
});
|
|
},
|
|
onError: (e) =>
|
|
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
|
});
|
|
|
|
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
|
|
// client-side over the status-filtered list.
|
|
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
|
// 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 + tag chips (skip the initial mount).
|
|
const resetRef = useRef(filtersResetToken);
|
|
useEffect(() => {
|
|
if (filtersResetToken === resetRef.current) return;
|
|
resetRef.current = filtersResetToken;
|
|
setSearch("");
|
|
setTagFilter([]);
|
|
}, [filtersResetToken]);
|
|
|
|
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
|
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
|
// run over whatever the status chip leaves.
|
|
const channels = (channelsQuery.data ?? []).filter((c) =>
|
|
statusFilter === "needs_full"
|
|
? !c.backfill_done
|
|
: statusFilter === "fully_synced"
|
|
? c.backfill_done
|
|
: statusFilter === "hidden"
|
|
? c.hidden
|
|
: true,
|
|
);
|
|
const q = search.trim().toLowerCase();
|
|
const visibleChannels = channels.filter((c) => {
|
|
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
|
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
|
|
return true;
|
|
});
|
|
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: Column<ManagedChannel>[] = [
|
|
{
|
|
key: "priority",
|
|
header: t("channels.cols.priority"),
|
|
align: "center",
|
|
width: "56px",
|
|
sortable: true,
|
|
hideInCard: true,
|
|
sortValue: (c) => c.priority,
|
|
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
|
|
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />,
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: t("channels.cols.actions"),
|
|
align: "left",
|
|
nowrap: true,
|
|
width: "104px",
|
|
cardLabel: false,
|
|
render: (c) => (
|
|
<ActionsCell
|
|
c={c}
|
|
isAdmin={isAdmin}
|
|
canWrite={canWrite}
|
|
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
|
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
|
|
onReset={() => onReset(c)}
|
|
onUnsubscribe={() => onUnsub(c)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "channel",
|
|
header: t("channels.cols.channel"),
|
|
sortable: true,
|
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
|
cardPrimary: true,
|
|
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
|
|
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
|
|
// Without this a single long name blows the column ~289→429px and overflows the centered table.
|
|
render: (c) => (
|
|
<div className="max-w-[18rem]">
|
|
<ChannelLink
|
|
id={c.id}
|
|
title={c.title}
|
|
handle={c.handle}
|
|
thumbnailUrl={c.thumbnail_url}
|
|
onView={() => onView(c)}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "about",
|
|
header: t("channels.cols.about"),
|
|
hideInCard: true,
|
|
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
|
},
|
|
{
|
|
key: "stored",
|
|
header: t("channels.cols.stored"),
|
|
align: "right",
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (c) => c.stored_videos,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
|
|
),
|
|
},
|
|
subsColumn<ManagedChannel>(t),
|
|
{
|
|
key: "lastUpload",
|
|
header: t("channels.cols.lastUpload"),
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
|
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
|
|
},
|
|
{
|
|
key: "length",
|
|
header: t("channels.cols.length"),
|
|
align: "right",
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (c) => c.total_duration_seconds,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">
|
|
{formatTotalHours(c.total_duration_seconds)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "types",
|
|
header: t("channels.cols.types"),
|
|
nowrap: true,
|
|
render: (c) => (
|
|
<Tooltip hint={t("channels.cols.typesHint")}>
|
|
<span className="text-muted tabular-nums cursor-help">
|
|
{c.count_normal} / {c.count_short} / {c.count_live}
|
|
</span>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
key: "sync",
|
|
header: t("channels.cols.sync"),
|
|
nowrap: true,
|
|
cardLabel: false,
|
|
filter: {
|
|
kind: "select",
|
|
options: [
|
|
{ value: "fully", label: t("channels.filters.fullySynced") },
|
|
{ value: "needsFull", label: t("channels.filters.needsFull") },
|
|
],
|
|
test: (c, v) =>
|
|
v === "fully"
|
|
? c.recent_synced && c.backfill_done
|
|
: v === "needsFull"
|
|
? !c.backfill_done
|
|
: true,
|
|
},
|
|
render: (c) => <SyncCell c={c} />,
|
|
},
|
|
{
|
|
key: "tags",
|
|
header: t("channels.cols.tags"),
|
|
cardLabel: false,
|
|
filter: {
|
|
kind: "multi",
|
|
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
|
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
|
},
|
|
render: (c) => (
|
|
<TagsCell
|
|
c={c}
|
|
userTags={userTags}
|
|
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
|
onTagClick={onFilterByTag}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
|
// where they act.
|
|
const statusChips = (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{STATUS_FILTERS.map((f) => (
|
|
<button
|
|
key={f.id}
|
|
onClick={() => setStatusFilter(f.id)}
|
|
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
|
statusFilter === f.id
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border text-muted hover:border-accent"
|
|
}`}
|
|
>
|
|
{t(f.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";
|
|
const sortedChannels = isCards ? sortRows(visibleChannels, 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 ["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();
|
|
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
|
const cardPager = <Pager {...pagerProps} />;
|
|
|
|
const tabs = (
|
|
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
|
<button
|
|
key={v}
|
|
onClick={() => setView(v)}
|
|
className={`text-sm px-3 py-1.5 rounded-full border transition ${
|
|
view === v
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border text-muted hover:border-accent"
|
|
}`}
|
|
>
|
|
{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-[96rem] 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) => {
|
|
const active = tagFilter.includes(tg.id);
|
|
return (
|
|
<button
|
|
key={tg.id}
|
|
onClick={() =>
|
|
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
|
}
|
|
aria-pressed={active}
|
|
className={`text-xs px-2 py-1 rounded-full border transition ${
|
|
active
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
|
|
}`}
|
|
>
|
|
{tg.name}
|
|
</button>
|
|
);
|
|
})}
|
|
{tagFilter.length > 0 && (
|
|
<button
|
|
onClick={() => setTagFilter([])}
|
|
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-[96rem] 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-[96rem] mx-auto">
|
|
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
|
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Ban className="w-4 h-4 text-muted" />
|
|
<span className="text-xs uppercase tracking-wide text-muted">
|
|
{t("channels.blocked.title")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{blockedQuery.data!.map((c) => (
|
|
<span
|
|
key={c.id}
|
|
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
|
>
|
|
{c.title ?? c.id}
|
|
<button
|
|
onClick={() => unblock.mutate(c.id)}
|
|
title={t("channel.unblock")}
|
|
aria-label={t("channel.unblock")}
|
|
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
|
|
</>
|
|
)}
|
|
</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-[96rem] 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.isLoading ? (
|
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
|
) : 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-[96rem] mx-auto"
|
|
sort={sort}
|
|
onSortChange={setSort}
|
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
|
emptyText={t("channels.empty")}
|
|
/>
|
|
) : (
|
|
<ChannelLayoutGrid
|
|
rows={pagedCards}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
leadKey="priority"
|
|
aboutKey="about"
|
|
actionsKey="actions"
|
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
|
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>
|
|
);
|
|
}
|
|
|
|
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
|
|
return (
|
|
<Tooltip hint={hint ?? ""}>
|
|
<span
|
|
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border ${
|
|
ok ? "border-accent/40 text-accent" : "border-border text-muted"
|
|
}`}
|
|
>
|
|
{ok && <Check className="w-3 h-3" />}
|
|
{label}
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function PriorityCell({
|
|
c,
|
|
onPriority,
|
|
}: {
|
|
c: ManagedChannel;
|
|
onPriority: (delta: number) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Tooltip hint={t("channels.row.priorityHint")}>
|
|
<div className="inline-flex flex-col items-center cursor-help">
|
|
<button
|
|
onClick={() => onPriority(1)}
|
|
className="text-muted hover:text-accent"
|
|
aria-label={t("channels.row.raisePriority")}
|
|
>
|
|
<ArrowUp className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
|
<button
|
|
onClick={() => onPriority(-1)}
|
|
className="text-muted hover:text-accent"
|
|
aria-label={t("channels.row.lowerPriority")}
|
|
>
|
|
<ArrowDown className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// Status only — the backfill *action* lives in the Actions column. When both recent and
|
|
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
|
|
// present plus the missing/queued state.
|
|
function SyncCell({ c }: { c: ManagedChannel }) {
|
|
const { t } = useTranslation();
|
|
if (c.recent_synced && c.backfill_done) {
|
|
return (
|
|
<SyncBadge
|
|
ok
|
|
label={t("channels.row.fullySynced")}
|
|
hint={t("channels.row.fullySyncedHint")}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<SyncBadge
|
|
ok={c.recent_synced}
|
|
label={t("channels.row.recent")}
|
|
hint={
|
|
c.recent_synced
|
|
? t("channels.row.recentSyncedHint")
|
|
: t("channels.row.recentNotSyncedHint")
|
|
}
|
|
/>
|
|
{c.backfill_done ? (
|
|
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
|
) : c.deep_requested ? (
|
|
<SyncBadge
|
|
ok={false}
|
|
label={t("channels.row.fullHistoryQueued")}
|
|
hint={t("channels.row.queuedRequestedHint")}
|
|
/>
|
|
) : c.deep_in_queue ? (
|
|
<SyncBadge
|
|
ok={false}
|
|
label={t("channels.row.fullHistoryComing")}
|
|
hint={t("channels.row.queuedByOtherHint")}
|
|
/>
|
|
) : (
|
|
<SyncBadge
|
|
ok={false}
|
|
label={t("channels.row.full")}
|
|
hint={t("channels.row.fullNotFetchedHint")}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TagsCell({
|
|
c,
|
|
userTags,
|
|
onToggleTag,
|
|
onTagClick,
|
|
}: {
|
|
c: ManagedChannel;
|
|
userTags: Tag[];
|
|
onToggleTag: (tagId: number) => void;
|
|
onTagClick: (tagId: number, name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
|
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
|
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
|
|
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
|
|
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
|
|
null,
|
|
);
|
|
const fade = useScrollFade();
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
const popRef = useRef<HTMLDivElement | null>(null);
|
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
|
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
|
// Keep the scroll-fade hook's callback ref working alongside our own.
|
|
const setPopRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
popRef.current = node;
|
|
fade.ref(node);
|
|
},
|
|
[fade.ref],
|
|
);
|
|
|
|
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
|
|
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
|
|
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
|
|
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
|
|
// re-runs this once it is.
|
|
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
|
|
const w = popRef.current?.offsetWidth ?? 0;
|
|
const left =
|
|
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
|
|
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
|
|
}, []);
|
|
|
|
const place = useCallback(() => {
|
|
const r = btnRef.current?.getBoundingClientRect();
|
|
if (!r) return false;
|
|
setCoords(coordsFrom(r, false));
|
|
return true;
|
|
}, [coordsFrom]);
|
|
const toggle = () => {
|
|
if (open) setOpen(false);
|
|
else if (place()) setOpen(true);
|
|
};
|
|
|
|
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
|
|
// there is room above), and apply the width clamp now that the menu can actually be measured.
|
|
useLayoutEffect(() => {
|
|
const pop = popRef.current;
|
|
const r = btnRef.current?.getBoundingClientRect();
|
|
if (!open || !pop || !coords || !r) return;
|
|
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
|
|
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
|
|
const fitsAbove = r.top - 4 - h >= 8;
|
|
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
|
|
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
|
|
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
|
|
const above = coords.bottom !== undefined;
|
|
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
|
const next = coordsFrom(r, flip);
|
|
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
|
|
setCoords(next);
|
|
}
|
|
}, [open, coords, coordsFrom]);
|
|
|
|
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
|
|
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
|
|
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
|
|
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
|
|
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
|
|
// Scrolling INSIDE the menu must not move it.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let raf = 0;
|
|
const reposition = () => {
|
|
raf = 0;
|
|
const r = btnRef.current?.getBoundingClientRect();
|
|
const scroller = document.querySelector("main")?.getBoundingClientRect();
|
|
const topEdge = scroller ? scroller.top : 0;
|
|
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
|
|
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
|
|
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
|
|
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
|
|
};
|
|
const onScroll = (e: Event) => {
|
|
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
|
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
|
|
};
|
|
const onResize = () => setOpen(false);
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", onResize);
|
|
return () => {
|
|
if (raf) cancelAnimationFrame(raf);
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", onResize);
|
|
};
|
|
}, [open, coordsFrom]);
|
|
if (userTags.length === 0) return null;
|
|
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
|
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
|
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
|
|
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
|
|
return (
|
|
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
|
{attached.map((tg) => (
|
|
<button
|
|
key={tg.id}
|
|
onClick={() => onTagClick(tg.id, tg.name)}
|
|
title={t("channels.row.filterFeedByTag", { name: tg.name })}
|
|
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
|
|
>
|
|
{tg.name}
|
|
</button>
|
|
))}
|
|
<button
|
|
ref={btnRef}
|
|
onClick={toggle}
|
|
title={t("channels.row.editTags")}
|
|
aria-label={t("channels.row.editTags")}
|
|
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
</button>
|
|
{open && coords && (
|
|
<Overlay>
|
|
<div
|
|
ref={setPopRef}
|
|
style={{
|
|
...fade.style,
|
|
position: "fixed",
|
|
left: coords.left,
|
|
top: coords.top,
|
|
bottom: coords.bottom,
|
|
}}
|
|
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
|
>
|
|
{userTags.map((tg) => {
|
|
const on = c.tag_ids.includes(tg.id);
|
|
return (
|
|
<button
|
|
key={tg.id}
|
|
onClick={() => onToggleTag(tg.id)}
|
|
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
on ? "text-fg" : "text-muted hover:bg-card"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
|
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
|
}`}
|
|
>
|
|
{on && <Check className="w-2.5 h-2.5" />}
|
|
</span>
|
|
{tg.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Overlay>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionsCell({
|
|
c,
|
|
isAdmin,
|
|
canWrite,
|
|
onHide,
|
|
onDeep,
|
|
onReset,
|
|
onUnsubscribe,
|
|
}: {
|
|
c: ManagedChannel;
|
|
isAdmin: boolean;
|
|
canWrite: boolean;
|
|
onHide: () => void;
|
|
onDeep: () => void;
|
|
onReset: () => void;
|
|
onUnsubscribe: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
|
|
// state — always actionable. Regular users get the per-user "request full history" opt-in,
|
|
// which only applies until the back-catalog is in (or already coming for everyone).
|
|
const done = c.backfill_done;
|
|
const requested = !done && c.deep_requested;
|
|
const optInActionable = !done && !c.deep_in_queue;
|
|
const backfillHint = isAdmin
|
|
? t("channels.row.resetHint")
|
|
: c.deep_in_queue
|
|
? t("channels.row.queuedByOtherHint")
|
|
: requested
|
|
? t("channels.row.queuedRequestedHint")
|
|
: done
|
|
? t("channels.row.fullHint")
|
|
: t("channels.row.backfillThisHint");
|
|
const enabled = isAdmin || optInActionable;
|
|
return (
|
|
<div className="inline-flex items-center gap-2">
|
|
<Tooltip hint={backfillHint}>
|
|
<button
|
|
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
|
|
disabled={!enabled}
|
|
className={`shrink-0 transition ${
|
|
requested
|
|
? "text-accent"
|
|
: enabled
|
|
? "text-muted hover:text-fg"
|
|
: "text-muted/40 cursor-default"
|
|
}`}
|
|
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
|
>
|
|
<History className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
|
|
<button
|
|
onClick={onHide}
|
|
className="text-muted hover:text-fg shrink-0"
|
|
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
|
|
>
|
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</Tooltip>
|
|
{canWrite && (
|
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
|
<button
|
|
onClick={onUnsubscribe}
|
|
className="text-muted hover:text-red-400 shrink-0"
|
|
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
|
>
|
|
<UserMinus className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|