From 45d16452f2e2fe7a211d7dc8bd4c8d20fe3ef790 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 16:12:39 +0200 Subject: [PATCH] feat(channel): enrich the About tab + fix the tab-switch header shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../alembic/versions/0055_channel_keywords.py | 23 +++++ backend/app/models.py | 3 + backend/app/routes/channels.py | 3 + backend/app/sync/subscriptions.py | 1 + frontend/src/components/ChannelDiscovery.tsx | 10 ++- frontend/src/components/ChannelPage.tsx | 84 ++++++++++++++++++- frontend/src/components/Channels.tsx | 2 +- frontend/src/i18n/locales/de/channel.json | 6 +- frontend/src/i18n/locales/en/channel.json | 6 +- frontend/src/i18n/locales/hu/channel.json | 6 +- frontend/src/lib/api.ts | 3 + 11 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/0055_channel_keywords.py diff --git a/backend/alembic/versions/0055_channel_keywords.py b/backend/alembic/versions/0055_channel_keywords.py new file mode 100644 index 0000000..0a2877c --- /dev/null +++ b/backend/alembic/versions/0055_channel_keywords.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 6e40d5b..d31a9f8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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)) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 38b5dff..e74589f 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -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, diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index a007296..3638eea 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -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 diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 17e98d1..0353e5a 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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) => ( - + onViewChannel(c.id, c.title ?? c.id)} + /> ), }, subsColumn(t), diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 08e259b..5ad3ea1 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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(); + 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 ( -
+ // 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. +
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
{ch?.banner_url ? ( @@ -341,6 +381,48 @@ export default function ChannelPage({ ))}
)} + {hasAboutDetails && ( +
+ {ch?.country && ( +
+ {t("channel.country")} + + {countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")} + +
+ )} + {ch?.default_language && ( +
+ {t("channel.language")} + {displayName(ch.default_language, i18n.language, "language")} +
+ )} + {topics.length > 0 && ( +
+ {t("channel.topics")} +
+ {topics.map((tp) => ( + + {tp} + + ))} +
+
+ )} + {keywords.length > 0 && ( +
+ {t("channel.keywords")} +
+ {keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} +
+ )} )}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b4e2043..d41af77 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -399,7 +399,7 @@ export default function Channels({ return ( <> {tabs} - + ); } diff --git a/frontend/src/i18n/locales/de/channel.json b/frontend/src/i18n/locales/de/channel.json index ffeac3c..a5cfe78 100644 --- a/frontend/src/i18n/locales/de/channel.json +++ b/frontend/src/i18n/locales/de/channel.json @@ -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" } diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json index c295fb3..612ebd3 100644 --- a/frontend/src/i18n/locales/en/channel.json +++ b/frontend/src/i18n/locales/en/channel.json @@ -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" } diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json index 6357dfe..ee0715b 100644 --- a/frontend/src/i18n/locales/hu/channel.json +++ b/frontend/src/i18n/locales/hu/channel.json @@ -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" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6443733..b329329 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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;