Files
siftlode/frontend/src/components/ChannelPage.tsx
T
peter 6d8b7be6d8 feat!: remove the shared demo account
The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
2026-10-05 00:19:38 +02:00

663 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { useNavigationActions } from "./navigationContext";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
import {
ArrowLeft,
Ban,
Check,
ExternalLink,
Eye,
EyeOff,
History,
Loader2,
Plus,
RefreshCw,
SlidersHorizontal,
} from "lucide-react";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useScrollFold } from "./ui/scrollFold";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../lib/usePref";
import { LS } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatDate, formatCompact } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import { useTags } from "../lib/useTags";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
// and offers "load more" to page deeper — without permanently polluting everyone's library
// (the backend keeps explored videos private to this user until they subscribe).
export default function ChannelPage({
channelId,
initialName,
me,
view,
setView,
onShowInFeed,
}: {
channelId: string;
initialName?: string;
me: Me;
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
// follow would just land you on an empty page.
onShowInFeed: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { closeChannel } = useNavigationActions();
const qc = useQueryClient();
const confirm = useConfirm();
// Seed the channel view's content-type prefs from the global feed (read once at mount). The
// channel page is its own filter scope, but WHICH content types the user wants to see (normal /
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
// in. Same queryKey as before, so mutation invalidations still hit.
const detail = useLiveQuery(qk.channel(channelId), () => api.channelDetail(channelId), {
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
// new banner was committed with the previous one's exhausted attempt count still in place.
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
if (bannerFor !== ch?.banner_url) {
setBannerFor(ch?.banner_url);
setBannerAttempt(0);
}
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
useBackfillFeedRefresh(ch, qc);
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
const [exploring, setExploring] = useState(false);
const autoTried = useRef<string | null>(null);
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return (
api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos (E11 S2b).
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
// already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
useEffect(() => {
if (!ch) return;
if (autoTried.current === channelId) return;
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore();
}, [ch, channelId, runExplore]);
const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
meta: { failTitle: "errors.writeFailed.blockChannel" },
});
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
});
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
});
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
// back-catalog pager. Same trigger the channel manager uses.
const deepBackfill = useMutation({
mutationFn: () => api.updateChannel(channelId, { deep_requested: true }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "info", message: t("channel.fullHistoryQueued") });
},
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
// hook so the two can't drift). Optimistic on this channel's detail cache; a success re-sorts the
// manager list + feed, a failure reverts by refetching the detail.
const { setPriority, bumpPriority } = useChannelPriority({
read: (id) => qc.getQueryData<ChannelDetail>(qk.channel(id))?.priority,
optimistic: (id, next) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old ? { ...old, priority: next } : old,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.feed() });
},
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
// channel page never shows the control, so the request would be thrown away.
const tagsQuery = useTags({ enabled: !!ch?.subscribed });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old
? {
...old,
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
}
: old,
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
confirmLabel: t("channel.unsubscribe"),
danger: true,
});
if (ok) unsubscribe.mutate();
};
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
// what is still unwatched, and a change sticks — across channels, reloads and devices.
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
localKey: LS.channelShow,
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
});
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
tags: [],
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
librarySource: "all",
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "unwatched",
channelIds: [channelId],
}));
const filters: FeedFilters = { ...ownFilters, show };
const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show);
setOwnFilters(next);
};
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null
? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) })
: null,
// Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the
// gap between the browsable videos and YouTube's raw count is honest rather than looking like
// missing data (the "get full history" button pulls the rest).
ch?.video_count != null
? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count
? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count })
: t("channel.videoCount", { count: ch.video_count })
: null,
ch?.total_view_count != null
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
: null,
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
// once we actually hold some of the channel's videos.
ch && ch.stored_videos > 0
? t("channel.videoTypes", {
normal: ch.count_normal,
short: ch.count_short,
live: ch.count_live,
})
: null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
// The page's name and its state, shown in the identity block or, folded, in the tab row.
const heading = (
<PageHeading
data-testid="channel-title"
className={`${folded ? "text-base" : "text-xl"} font-semibold truncate`}
>
{name}
</PageHeading>
);
const stateBadge = ch?.blocked ? (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
{t("channel.exploringBadge")}
</span>
)
);
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
return (
<>
<PageToolbar>
<div className="relative">
{!folded && (
<div ref={foldable}>
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
<div className="px-4 sm:px-6 pt-3">
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
</div>
<div className="px-4 sm:px-6">
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints
ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
{heading}
{stateBadge}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)}
<div className="px-4 sm:px-6">
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
<div
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
>
{/* One Back button in both states, so a focused Back keeps its focus when the header folds:
over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */}
<Button
variant={folded ? "outline" : "media"}
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className={folded ? "" : "absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"}
>
{t("channel.back")}
</Button>
{folded && (
<div
className="flex min-w-0 max-w-[40%] items-center gap-2"
data-testid="channel-folded"
>
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-8 h-8 rounded-full shrink-0"
/>
{heading}
{stateBadge}
</div>
)}
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
]}
/>
{/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back
and the name take that room, so at a tablet width the actions wrap onto a line of their
own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
onClick={onShowInFeed}
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
size="md"
hint={t("channels.row.priorityHint")}
/>
</div>
)}
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
While loading, the label carries the live stored/total count (the detail poll refreshes
it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */}
{ch?.subscribed && !ch.backfill_done && (
<Button
variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
loading={ch.deep_requested}
icon={<History className="w-4 h-4" />}
title={
ch.deep_requested
? t("channel.fullHistoryLoadingHint")
: t("channel.fullHistory")
}
>
<span className="hidden sm:inline whitespace-nowrap">
{!ch.deep_requested
? t("channel.fullHistory")
: ch.video_count != null && ch.stored_videos < ch.video_count
? t("channel.fullHistoryProgress", {
stored: ch.stored_videos,
total: ch.video_count,
})
: t("channel.fullHistoryLoading")}
</span>
</Button>
)}
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && (
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!ch?.blocked &&
(ch?.subscribed ? (
<Button
variant="outline"
onClick={onUnsubscribe}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
>
{t("channel.subscribedState")}
</Button>
) : (
<Button
variant="outline"
onClick={() => subscribe.mutate()}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
</Button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && (
<div className="min-w-0 flex-1">
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row's leftover width: tags inline, "+N" only when they do not fit.
bounded={false}
/>
</div>
)}
</div>
</div>
</div>
</div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
<div {...panel.panelProps(tab)}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</div>
</PageToolbarSlot.Provider>
</>
);
}