Merge feature/channel-card-enrich: channel About-tab enrichment + fixes
About tab now shows country/language/topics/keywords (migration 0055_channel_keywords); Discovery tab links each channel to its in-app page; fixes: tab-switch header shift (scrollbar-gutter:stable), hide the Source filter on the channel-scoped feed, recover a throttled channel banner (retry + cache-buster), and match the no-banner fallback bar to the banner height so the avatar no longer overlaps Back. /code-review findings fixed; E2E + user UAT passed.
This commit is contained in:
commit
ab61b1583e
12 changed files with 194 additions and 28 deletions
23
backend/alembic/versions/0055_channel_keywords.py
Normal file
23
backend/alembic/versions/0055_channel_keywords.py
Normal 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")
|
||||||
|
|
@ -180,6 +180,9 @@ class Channel(Base, TimestampMixin):
|
||||||
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
default_language: Mapped[str | None] = mapped_column(String(16))
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
country: Mapped[str | None] = mapped_column(String(8))
|
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.
|
# Sync bookkeeping.
|
||||||
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,9 @@ def _channel_detail_dict(
|
||||||
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
||||||
"external_links": channel.external_links or [],
|
"external_links": channel.external_links or [],
|
||||||
"country": channel.country,
|
"country": channel.country,
|
||||||
|
"default_language": channel.default_language,
|
||||||
|
"topic_categories": channel.topic_categories or [],
|
||||||
|
"keywords": channel.keywords,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"explored": explored,
|
"explored": explored,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||||
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
||||||
channel.external_links = _external_links(branding)
|
channel.external_links = _external_links(branding)
|
||||||
channel.topic_categories = topics.get("topicCategories")
|
channel.topic_categories = topics.get("topicCategories")
|
||||||
|
channel.keywords = (branding.get("channel") or {}).get("keywords")
|
||||||
channel.details_synced_at = now
|
channel.details_synced_at = now
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ import { useConfirm } from "./ConfirmProvider";
|
||||||
export default function ChannelDiscovery({
|
export default function ChannelDiscovery({
|
||||||
canWrite,
|
canWrite,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
|
onViewChannel,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
|
onViewChannel: (id: string, name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -76,7 +78,13 @@ export default function ChannelDiscovery({
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => (
|
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),
|
subsColumn<DiscoveredChannel>(t),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,40 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
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 */
|
||||||
|
}
|
||||||
|
label = label.replace(/_/g, " ").trim();
|
||||||
|
if (label) seen.add(label);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// 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
|
// 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,
|
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
|
||||||
|
|
@ -34,12 +68,20 @@ export default function ChannelPage({
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||||
|
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel
|
||||||
|
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
|
||||||
|
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
|
||||||
|
// succeed. 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);
|
||||||
|
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
queryKey: ["channel", channelId],
|
queryKey: ["channel", channelId],
|
||||||
queryFn: () => api.channelDetail(channelId),
|
queryFn: () => api.channelDetail(channelId),
|
||||||
});
|
});
|
||||||
const ch = detail.data;
|
const ch = detail.data;
|
||||||
|
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
||||||
|
|
||||||
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||||
|
|
@ -147,16 +189,23 @@ export default function ChannelPage({
|
||||||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].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) =>
|
const tabClass = (active: boolean) =>
|
||||||
active
|
active
|
||||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
? "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";
|
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||||
|
|
||||||
return (
|
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) */}
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
<div className="relative px-4 sm:px-6 pt-3">
|
||||||
{ch?.banner_url ? (
|
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
// 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
|
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||||
|
|
@ -165,13 +214,27 @@ export default function ChannelPage({
|
||||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${ch.banner_url}=w1707`}
|
// 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={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover object-center"
|
className="w-full h-full object-cover object-center"
|
||||||
|
onError={() => {
|
||||||
|
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||||
|
window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-14 w-full rounded-2xl bg-surface" />
|
// 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" }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
|
|
@ -341,6 +404,48 @@ export default function ChannelPage({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ export default function Channels({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tabs}
|
{tabs}
|
||||||
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
|
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -552,6 +552,12 @@ export default function Feed({
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||||
|
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||||
|
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||||
|
only offer a near-empty, confusing slice — hide it here. */}
|
||||||
|
{!channelScoped && (
|
||||||
|
<>
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||||
<span>{t("feed.source.label")}</span>
|
<span>{t("feed.source.label")}</span>
|
||||||
|
|
@ -571,6 +577,8 @@ export default function Feed({
|
||||||
<option value="search">{t("feed.source.only")}</option>
|
<option value="search">{t("feed.source.only")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<span className="text-xs text-muted whitespace-nowrap">
|
<span className="text-xs text-muted whitespace-nowrap">
|
||||||
{countQuery.data
|
{countQuery.data
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
||||||
"block": "Kanal blockieren",
|
"block": "Kanal blockieren",
|
||||||
"unblock": "Blockierung aufheben",
|
"unblock": "Blockierung aufheben",
|
||||||
"blockedBadge": "Blockiert"
|
"blockedBadge": "Blockiert",
|
||||||
|
"country": "Land",
|
||||||
|
"language": "Sprache",
|
||||||
|
"topics": "Themen",
|
||||||
|
"keywords": "Schlagwörter"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "This channel has no description.",
|
"noDescription": "This channel has no description.",
|
||||||
"block": "Block channel",
|
"block": "Block channel",
|
||||||
"unblock": "Unblock channel",
|
"unblock": "Unblock channel",
|
||||||
"blockedBadge": "Blocked"
|
"blockedBadge": "Blocked",
|
||||||
|
"country": "Country",
|
||||||
|
"language": "Language",
|
||||||
|
"topics": "Topics",
|
||||||
|
"keywords": "Keywords"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "Ennek a csatornának nincs leírása.",
|
"noDescription": "Ennek a csatornának nincs leírása.",
|
||||||
"block": "Csatorna tiltása",
|
"block": "Csatorna tiltása",
|
||||||
"unblock": "Tiltás feloldása",
|
"unblock": "Tiltás feloldása",
|
||||||
"blockedBadge": "Tiltva"
|
"blockedBadge": "Tiltva",
|
||||||
|
"country": "Ország",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"topics": "Témák",
|
||||||
|
"keywords": "Kulcsszavak"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ export interface ChannelDetail {
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
external_links: ChannelLink[];
|
external_links: ChannelLink[];
|
||||||
country: string | null;
|
country: string | null;
|
||||||
|
default_language: string | null;
|
||||||
|
topic_categories: string[];
|
||||||
|
keywords: string | null;
|
||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
explored: boolean;
|
explored: boolean;
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue