feat(channels): search box + clickable tag chips; fix(feed): contextual Your-tags

- 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.
This commit is contained in:
npeter83 2026-06-30 22:30:35 +02:00
parent 72ae936958
commit 2b5b6ac965
6 changed files with 93 additions and 29 deletions

View file

@ -535,7 +535,7 @@ def get_facets(
(tag ids).""" (tag ids)."""
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
counts: dict[int, int] = {} 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 # 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) # 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 # keeps them applied, so each remaining chip narrows to channels that ALSO have all

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -11,7 +11,9 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
UserMinus, UserMinus,
X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@ -181,9 +183,26 @@ export default function Channels({
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), 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<number[]>([]);
// 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 // 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 // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
// search, tag filtering, sort and pagination over whatever the status chip leaves. // run over whatever the status chip leaves.
const channels = (channelsQuery.data ?? []).filter((c) => const channels = (channelsQuery.data ?? []).filter((c) =>
statusFilter === "needs_full" statusFilter === "needs_full"
? !c.backfill_done ? !c.backfill_done
@ -193,6 +212,12 @@ export default function Channels({
? c.hidden ? c.hidden
: true : 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 s = statusQuery.data;
const onView = (c: ManagedChannel) => const onView = (c: ManagedChannel) =>
@ -231,7 +256,6 @@ export default function Channels({
header: t("channels.cols.channel"), header: t("channels.cols.channel"),
sortable: true, sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(), sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true, cardPrimary: true,
render: (c) => ( render: (c) => (
<ChannelLink <ChannelLink
@ -305,12 +329,6 @@ export default function Channels({
key: "tags", key: "tags",
header: t("channels.cols.tags"), header: t("channels.cols.tags"),
cardLabel: false, cardLabel: false,
filter: {
kind: "multi",
options: userTags.map((tg) => ({ 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) => ( render: (c) => (
<TagsCell <TagsCell
c={c} c={c}
@ -468,21 +486,60 @@ export default function Channels({
/> />
</p> </p>
{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */} {/* Channel-name search */}
<div className="relative mb-3 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
<input
value={search}
onChange={(e) => 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 && (
<button
onClick={() => setSearch("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
<div className="flex flex-wrap items-center gap-1.5 mb-4"> <div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint={t("channels.tags.yourTagsHint")}> <Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4"> <span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")} {t("channels.tags.yourTags")}
</span> </span>
</Tooltip> </Tooltip>
{userTags.map((tg) => ( {userTags.map((tg) => {
<span const active = tagFilter.includes(tg.id);
key={tg.id} return (
className="text-xs px-2 py-1 rounded-full bg-card border border-border" <button
key={tg.id}
onClick={() =>
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
}
aria-pressed={active}
className={`text-xs px-2 py-1 rounded-full border transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{tg.name}
</button>
);
})}
{tagFilter.length > 0 && (
<button
onClick={() => setTagFilter([])}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
> >
{tg.name} {t("datatable.clear")}
</span> </button>
))} )}
<button <button
onClick={() => setTagManagerOpen(true)} onClick={() => setTagManagerOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition" className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
@ -500,14 +557,12 @@ export default function Channels({
<div className="text-muted py-8">{t("channels.loading")}</div> <div className="text-muted py-8">{t("channels.loading")}</div>
) : ( ) : (
<DataTable <DataTable
rows={channels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelsTable" persistKey="siftlode.channelsTable"
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
resetFiltersToken={filtersResetToken}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
/> />

View file

@ -182,7 +182,8 @@ export default function Sidebar({
const languages = tags.filter((t) => t.category === "language"); const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); const topics = tags.filter((t) => t.category === "topic");
// The user's own (non-system) tags — those assigned to channels in the Channel manager. // 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 userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false); const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
@ -407,18 +408,22 @@ export default function Sidebar({
</> </>
); );
} }
case "tags": case "tags": {
const tagChips = visibleChips(userTags);
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{userTags.map((tg) => ( {tagChips.map((tg) => (
<TagChip <TagChip
key={tg.id} key={tg.id}
tag={tg} tag={tg}
count={tg.channel_count} count={chipCount(tg)}
active={filters.tags.includes(tg.id)} active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)} onClick={() => toggleTag(tg.id)}
/> />
))} ))}
{facetsReady && tagChips.length === 0 && (
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
)}
<button <button
onClick={() => setTagManagerOpen(true)} onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")} title={t("sidebar.manageTags")}
@ -429,6 +434,7 @@ export default function Sidebar({
</button> </button>
</div> </div>
); );
}
} }
} }

View file

@ -122,5 +122,6 @@
"resetFailed": "Kanal konnte nicht zurückgesetzt werden" "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.", "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…"
} }

View file

@ -122,5 +122,6 @@
"resetFailed": "Couldn't reset this channel" "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.", "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…"
} }

View file

@ -122,5 +122,6 @@
"resetFailed": "Nem sikerült resetelni a csatornát" "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.", "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…"
} }