feat(channel): enrich the About tab + fix the tab-switch header shift

- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
  page's scroll container, so switching between the tall Videos tab and the
  short About tab no longer shifts the banner/avatar/buttons a few px sideways
  (the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
  (topicCategories → readable chips), and Keywords (brandingSettings keywords
  parsed into chips, quoted multi-word tags kept whole). country/language/topics
  were already stored; keywords is new (migration 0055_channel_keywords, mapped
  in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
  links each channel name to its in-app channel page (ChannelLink onView), so a
  discovered channel's About/videos can be inspected BEFORE subscribing (was
  subscribe-only, plain text before).

Note: the channel info-card epic itself was already delivered in v0.19.0; this is
the About-tab enrichment follow-up + the header-shift fix. external_links stays
empty by design (YouTube removed the field from the Data API ~2023).
This commit is contained in:
npeter83 2026-07-12 16:12:39 +02:00
parent fe387f06af
commit 45d16452f2
11 changed files with 141 additions and 6 deletions

View file

@ -0,0 +1,23 @@
"""channels.keywords: creator keyword tags from brandingSettings (About tab)
Revision ID: 0055_channel_keywords
Revises: 0054_audit_log
Create Date: 2026-07-12
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0055_channel_keywords"
down_revision: Union[str, None] = "0054_audit_log"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("channels", sa.Column("keywords", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("channels", "keywords")

View file

@ -180,6 +180,9 @@ class Channel(Base, TimestampMixin):
topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16))
country: Mapped[str | None] = mapped_column(String(8))
# Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string,
# multi-word tags quoted; the client parses it into chips for the About tab.
keywords: Mapped[str | None] = mapped_column(Text)
# Sync bookkeeping.
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View file

@ -233,6 +233,9 @@ def _channel_detail_dict(
"published_at": channel.published_at.isoformat() if channel.published_at else None,
"external_links": channel.external_links or [],
"country": channel.country,
"default_language": channel.default_language,
"topic_categories": channel.topic_categories or [],
"keywords": channel.keywords,
"subscribed": subscribed,
"explored": explored,
"blocked": blocked,

View file

@ -69,6 +69,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
channel.external_links = _external_links(branding)
channel.topic_categories = topics.get("topicCategories")
channel.keywords = branding.get("channel", {}).get("keywords")
channel.details_synced_at = now

View file

@ -18,9 +18,11 @@ import { useConfirm } from "./ConfirmProvider";
export default function ChannelDiscovery({
canWrite,
onOpenWizard,
onViewChannel,
}: {
canWrite: boolean;
onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -76,7 +78,13 @@ export default function ChannelDiscovery({
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => (
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onViewChannel(c.id, c.title ?? c.id)}
/>
),
},
subsColumn<DiscoveredChannel>(t),

View file

@ -10,6 +10,39 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// --- About-tab metadata helpers ---
// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji.
function countryFlag(code: string): string {
if (!/^[A-Za-z]{2}$/.test(code)) return "";
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));
}
function displayName(code: string, lang: string, type: "region" | "language"): string {
try {
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
} catch {
return code;
}
}
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
}
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated.
function topicLabels(urls: string[]): string[] {
const seen = new Set<string>();
for (const url of urls) {
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
let label = seg;
try {
label = decodeURIComponent(seg);
} catch {
/* keep raw */
}
seen.add(label.replace(/_/g, " "));
}
return [...seen];
}
// 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,
@ -147,13 +180,20 @@ export default function ChannelPage({
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
const topics = ch?.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
const keywords = ch?.keywords ? parseKeywords(ch.keywords) : [];
const hasAboutDetails =
!!ch?.country || !!ch?.default_language || topics.length > 0 || keywords.length > 0;
const tabClass = (active: boolean) =>
active
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
return (
<main className="flex-1 min-w-0 overflow-y-auto">
// scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
// tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the
// header/logo/buttons horizontally as the scrollbar appears/disappears.
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url ? (
@ -341,6 +381,48 @@ export default function ChannelPage({
))}
</div>
)}
{hasAboutDetails && (
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
{ch?.country && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
<span>
{countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")}
</span>
</div>
)}
{ch?.default_language && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
</div>
)}
{topics.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
<div className="flex flex-wrap gap-1.5">
{topics.map((tp) => (
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
{tp}
</span>
))}
</div>
</div>
)}
{keywords.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
{kw}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</main>

View file

@ -399,7 +399,7 @@ export default function Channels({
return (
<>
{tabs}
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} />
</>
);
}

View file

@ -19,5 +19,9 @@
"noDescription": "Dieser Kanal hat keine Beschreibung.",
"block": "Kanal blockieren",
"unblock": "Blockierung aufheben",
"blockedBadge": "Blockiert"
"blockedBadge": "Blockiert",
"country": "Land",
"language": "Sprache",
"topics": "Themen",
"keywords": "Schlagwörter"
}

View file

@ -19,5 +19,9 @@
"noDescription": "This channel has no description.",
"block": "Block channel",
"unblock": "Unblock channel",
"blockedBadge": "Blocked"
"blockedBadge": "Blocked",
"country": "Country",
"language": "Language",
"topics": "Topics",
"keywords": "Keywords"
}

View file

@ -19,5 +19,9 @@
"noDescription": "Ennek a csatornának nincs leírása.",
"block": "Csatorna tiltása",
"unblock": "Tiltás feloldása",
"blockedBadge": "Tiltva"
"blockedBadge": "Tiltva",
"country": "Ország",
"language": "Nyelv",
"topics": "Témák",
"keywords": "Kulcsszavak"
}

View file

@ -96,6 +96,9 @@ export interface ChannelDetail {
published_at: string | null;
external_links: ChannelLink[];
country: string | null;
default_language: string | null;
topic_categories: string[];
keywords: string | null;
subscribed: boolean;
explored: boolean;
blocked: boolean;