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..a110d7b 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 && (
+
- ))}
+ {t("datatable.clear")}
+
+ )}
))}
{open && (
-
+
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index e58643c..53a69a9 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -182,7 +182,8 @@ export default function Sidebar({
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
- // Facets only cover language/topic, so these show their static channel_count.
+ // Facets cover them too now (the "other" category), so they're contextual like the rest:
+ // zero-count chips hide once facets load, counts reflect the current filter.
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
@@ -407,18 +408,22 @@ export default function Sidebar({
>
);
}
- case "tags":
+ case "tags": {
+ const tagChips = visibleChips(userTags);
return (
- {userTags.map((tg) => (
+ {tagChips.map((tg) => (
toggleTag(tg.id)}
/>
))}
+ {facetsReady && tagChips.length === 0 && (
+ {t("sidebar.noMatchingTags")}
+ )}
);
+ }
}
}
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}
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…"
}