From 2b5b6ac965a68edb74c2f659371abc866b030e4f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 22:30:35 +0200 Subject: [PATCH 1/3] feat(channels): search box + clickable tag chips; fix(feed): contextual Your-tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channels manager: prominent channel-name search box and the 'Your tags' chips are now clickable to filter the table by tag (replacing the hidden per-column DataTable popovers). Both filter client-side over the status-filtered list; a focus-channel intent seeds the search, the reset intent clears both. - Feed 'Your tags' sidebar: count user tags in the facet endpoint (the 'other' category) and make the widget contextual like language/topic — counts reflect the current filter and zero-count chips hide (e.g. a source=search view with no tagged channels shows 'no matching tags' instead of the full static list). EN/HU/DE searchPlaceholder. --- backend/app/routes/feed.py | 2 +- frontend/src/components/Channels.tsx | 97 +++++++++++++++++----- frontend/src/components/Sidebar.tsx | 14 +++- frontend/src/i18n/locales/de/channels.json | 3 +- frontend/src/i18n/locales/en/channels.json | 3 +- frontend/src/i18n/locales/hu/channels.json | 3 +- 6 files changed, 93 insertions(+), 29 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 5969edb..cb8d542 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -535,7 +535,7 @@ def get_facets( (tag ids).""" visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) counts: dict[int, int] = {} - for category in ("language", "topic"): + for category in ("language", "topic", "other"): # Disjunctive (OR) facets drop the category's own selections so its chips keep # independent counts (you can OR more of them in). Conjunctive (AND, topics only) # keeps them applied, so each remaining chip narrows to channels that ALSO have all diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 21cce00..32b3a36 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -11,7 +11,9 @@ import { Pencil, Plus, RefreshCw, + Search, UserMinus, + X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { useDismiss } from "../lib/useDismiss"; @@ -181,9 +183,26 @@ export default function Channels({ onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); + // Channel-name search + tag-chip filtering, applied client-side over the status-filtered list + // (prominent controls instead of the DataTable's hidden per-column popovers). + const [search, setSearch] = useState(""); + const [tagFilter, setTagFilter] = useState([]); + // A focus-channel intent (header "without full history" / tag manager) seeds the search box. + useEffect(() => { + if (focusChannelName) setSearch(focusChannelName); + }, [focusChannelName]); + // The header's reset intent clears the search + tag chips (skip the initial mount). + const resetRef = useRef(filtersResetToken); + useEffect(() => { + if (filtersResetToken === resetRef.current) return; + resetRef.current = filtersResetToken; + setSearch(""); + setTagFilter([]); + }, [filtersResetToken]); + // The Sync-status filter stays a top-level chip set (not a column filter) because the - // header's "go to full history" deep-link drives it; the DataTable then handles name - // search, tag filtering, sort and pagination over whatever the status chip leaves. + // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination + // run over whatever the status chip leaves. const channels = (channelsQuery.data ?? []).filter((c) => statusFilter === "needs_full" ? !c.backfill_done @@ -193,6 +212,12 @@ export default function Channels({ ? c.hidden : true ); + const q = search.trim().toLowerCase(); + const visibleChannels = channels.filter((c) => { + if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false; + if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false; + return true; + }); const s = statusQuery.data; const onView = (c: ManagedChannel) => @@ -231,7 +256,6 @@ export default function Channels({ header: t("channels.cols.channel"), sortable: true, sortValue: (c) => (c.title ?? c.id).toLowerCase(), - filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, render: (c) => ( ({ value: String(tg.id), label: tg.name })), - // OR semantics: show channels carrying any of the selected tags. - test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), - }, render: (c) => (

- {/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */} + {/* Channel-name search */} +
+ + setSearch(e.target.value)} + placeholder={t("channels.searchPlaceholder")} + className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent" + /> + {search && ( + + )} +
+ + {/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
{t("channels.tags.yourTags")} - {userTags.map((tg) => ( - { + const active = tagFilter.includes(tg.id); + return ( + + ); + })} + {tagFilter.length > 0 && ( + + )}
); + } } } diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index 93cafc2..1fd8b16 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -122,5 +122,6 @@ "resetFailed": "Kanal konnte nicht zurückgesetzt werden" }, "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.", - "confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent." + "confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.", + "searchPlaceholder": "Kanäle suchen…" } diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index eef0389..8d8703a 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -122,5 +122,6 @@ "resetFailed": "Couldn't reset this channel" }, "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.", - "confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota." + "confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.", + "searchPlaceholder": "Search channels…" } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6b9fa1e..f0617ab 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -122,5 +122,6 @@ "resetFailed": "Nem sikerült resetelni a csatornát" }, "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.", - "confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt." + "confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.", + "searchPlaceholder": "Csatornák keresése…" } From cd9987c00f954643d6477eb7b9be0691522faa7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 23:53:44 +0200 Subject: [PATCH 2/3] feat(card): tooltip the full video title only when it's clamped The card title is line-clamped to 2 lines; measure overflow (ResizeObserver, re-checks on responsive resize) and set the native title attribute with the full text only when it's actually truncated, so hovering reveals the rest without a redundant tooltip on short titles. --- frontend/src/components/VideoCard.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 541c86e..4e55b35 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Bookmark, @@ -264,12 +264,26 @@ function VideoCard({ )} ); + // Show the full title in a native tooltip only when it's clamped (overflows its 2 lines). + const titleRef = useRef(null); + const [titleClamped, setTitleClamped] = useState(false); + useEffect(() => { + const el = titleRef.current; + if (!el) return; + const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1); + check(); + const ro = new ResizeObserver(check); + ro.observe(el); + return () => ro.disconnect(); + }, [video.title]); const title = ( openInApp(e, video, onOpen)} + title={titleClamped ? video.title ?? undefined : undefined} className="font-medium leading-snug line-clamp-2 hover:text-accent" > {video.title} From 77707a3cbe8ead2cae445e6a51ad2439863e0d89 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 00:01:26 +0200 Subject: [PATCH 3/3] fix(channels): flip the row tag picker upward when it would clip below The per-row tag '+' menu always opened downward (top-full) and clipped against the scroll container for rows near the viewport bottom. Measure the button on open and flip it upward (bottom-full) when there isn't room below but there is above; cap its height with an internal scroll so a long tag list can't overflow either way. --- frontend/src/components/Channels.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 32b3a36..a110d7b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -664,8 +664,20 @@ function TagsCell({ }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); + // Open the picker upward when there isn't room below (rows near the viewport bottom would + // otherwise clip it against the scroll container), as long as there's room above. + const [openUp, setOpenUp] = useState(false); const ref = useRef(null); + const btnRef = useRef(null); useDismiss(open, () => setOpen(false), ref); + const toggle = () => { + if (!open) { + const r = btnRef.current?.getBoundingClientRect(); + const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below + setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8); + } + setOpen((o) => !o); + }; if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag @@ -684,7 +696,8 @@ function TagsCell({ ))} {open && ( -
+
{userTags.map((tg) => { const on = c.tag_ids.includes(tg.id); return (