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 && (
+
- ))}
+ {t("datatable.clear")}
+
+ )}
))}
setOpen((o) => !o)}
+ ref={btnRef}
+ onClick={toggle}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
@@ -692,7 +705,11 @@ function TagsCell({
{open && (
-
+
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (