From 479574b726ecd4eaf838be590543267418ba5873 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 11:18:20 +0200 Subject: [PATCH 1/2] refactor(banner): extract reusable Banner base for VersionBanner + DemoBanner Both top-of-app bars shared the same shell (icon + content + border-b bar); factor it into a tone-driven Banner with optional action/dismiss. The version/demo logic stays in the wrappers. Leaves a clean extension point for a future news-ticker variant. --- frontend/src/components/Banner.tsx | 53 +++++++++++++++++++++++ frontend/src/components/DemoBanner.tsx | 12 +++-- frontend/src/components/VersionBanner.tsx | 34 +++++++-------- 3 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/Banner.tsx diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx new file mode 100644 index 0000000..d789078 --- /dev/null +++ b/frontend/src/components/Banner.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { X } from "lucide-react"; + +type BannerTone = "accent" | "warning"; + +const TONES: Record = { + accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", action: "bg-accent text-accent-fg" }, + warning: { bar: "bg-amber-500/15 border-amber-500/30", icon: "text-amber-500", action: "bg-amber-500 text-black" }, +}; + +// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this; +// a future news-ticker variant can build on the same shell (rotating content as children). +export default function Banner({ + tone, + icon: Icon, + children, + action, + onDismiss, + dismissTitle, +}: { + tone: BannerTone; + icon: LucideIcon; + children: ReactNode; + action?: { label: string; onClick: () => void }; + onDismiss?: () => void; + dismissTitle?: string; +}) { + const c = TONES[tone]; + return ( +
+ + {children} + {action && ( + + )} + {onDismiss && ( + + )} +
+ ); +} diff --git a/frontend/src/components/DemoBanner.tsx b/frontend/src/components/DemoBanner.tsx index 2fe59bd..d9b6cb9 100644 --- a/frontend/src/components/DemoBanner.tsx +++ b/frontend/src/components/DemoBanner.tsx @@ -1,17 +1,15 @@ import { useTranslation } from "react-i18next"; import { AlertTriangle } from "lucide-react"; +import Banner from "./Banner"; // Permanent, non-dismissible bar shown to the shared demo account so it's always clear the // state is communal. (Replaces the old login-time toast, which re-popped on every reload.) export default function DemoBanner() { const { t } = useTranslation(); return ( -
- - - {t("common.demoTitle")} - — {t("common.demoSharedNotice")} - -
+ + {t("common.demoTitle")} + — {t("common.demoSharedNotice")} + ); } diff --git a/frontend/src/components/VersionBanner.tsx b/frontend/src/components/VersionBanner.tsx index 720854e..d486bb6 100644 --- a/frontend/src/components/VersionBanner.tsx +++ b/frontend/src/components/VersionBanner.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Sparkles, X } from "lucide-react"; +import { Sparkles } from "lucide-react"; import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version"; +import Banner from "./Banner"; // Eye-catching, dismissible bar shown once after the running build's version changes // (compares the baked frontend version to the last one the user acknowledged). @@ -19,25 +20,20 @@ export default function VersionBanner({ onOpen }: { onOpen: () => void }) { } return ( -
- - {t("header.banner.updated", { version: FRONTEND_VERSION })} - - -
+ }, + }} + onDismiss={markSeen} + dismissTitle={t("header.banner.dismiss")} + > + {t("header.banner.updated", { version: FRONTEND_VERSION })} + ); } From 94fc5c9806eaeecef750ee3b88720eb0f838fb79 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 11:18:28 +0200 Subject: [PATCH 2/2] feat(discovery): total-videos column + subscribe confirmation Add a "Videos" column (channel total uploads, already in the discovery response) to the Discover-from-playlists table, and guard the Subscribe button with a confirm dialog warning it changes the real YouTube account and spends quota (mirrors the unsubscribe guard). Strings added in EN/HU/DE. --- frontend/src/components/ChannelDiscovery.tsx | 28 +++++++++++++++++++- frontend/src/i18n/locales/de/channels.json | 2 ++ frontend/src/i18n/locales/en/channels.json | 2 ++ frontend/src/i18n/locales/hu/channels.json | 2 ++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index ce0d9cf..62deae1 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -7,6 +7,7 @@ import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import ChannelLink from "./ChannelLink"; import DataTable, { type Column } from "./DataTable"; +import { useConfirm } from "./ConfirmProvider"; // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but // that they don't subscribe to. Subscribing here only creates the subscription + enriches @@ -20,6 +21,7 @@ export default function ChannelDiscovery({ }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const query = useQuery({ queryKey: ["discovered-channels"], @@ -60,6 +62,17 @@ export default function ChannelDiscovery({ }, }); + // Subscribing changes the user's real YouTube account and spends a little quota — confirm + // first (mirrors the unsubscribe guard in the Subscriptions tab). + const onSubscribe = async (c: DiscoveredChannel) => { + const ok = await confirm({ + title: t("channels.discovery.subscribe"), + message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.discovery.subscribe"), + }); + if (ok) subscribe.mutate(c); + }; + const rows = query.data ?? []; const columns: Column[] = [ @@ -87,6 +100,19 @@ export default function ChannelDiscovery({ ), }, + { + key: "videos", + header: t("channels.discovery.cols.totalVideos"), + align: "right", + nowrap: true, + sortable: true, + sortValue: (c) => c.video_count ?? -1, + render: (c) => ( + + {c.video_count != null ? formatViews(c.video_count) : "—"} + + ), + }, { key: "inPlaylists", header: t("channels.discovery.cols.inPlaylists"), @@ -123,7 +149,7 @@ export default function ChannelDiscovery({ } >