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.
663 lines
31 KiB
TypeScript
663 lines
31 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|