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:
parent
72ae936958
commit
2b5b6ac965
6 changed files with 93 additions and 29 deletions
|
|
@ -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<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
|
||||
// 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) => (
|
||||
<ChannelLink
|
||||
|
|
@ -305,12 +329,6 @@ export default function Channels({
|
|||
key: "tags",
|
||||
header: t("channels.cols.tags"),
|
||||
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) => (
|
||||
<TagsCell
|
||||
c={c}
|
||||
|
|
@ -468,21 +486,60 @@ export default function Channels({
|
|||
/>
|
||||
</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">
|
||||
<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">
|
||||
{t("channels.tags.yourTags")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{userTags.map((tg) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
className="text-xs px-2 py-1 rounded-full bg-card border border-border"
|
||||
{userTags.map((tg) => {
|
||||
const active = tagFilter.includes(tg.id);
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
|
|
@ -500,14 +557,12 @@ export default function Channels({
|
|||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={channels}
|
||||
rows={visibleChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey="siftlode.channelsTable"
|
||||
controlsPosition="top"
|
||||
controlsLeading={statusChips}
|
||||
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
|
||||
resetFiltersToken={filtersResetToken}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{userTags.map((tg) => (
|
||||
{tagChips.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
count={tg.channel_count}
|
||||
count={chipCount(tg)}
|
||||
active={filters.tags.includes(tg.id)}
|
||||
onClick={() => toggleTag(tg.id)}
|
||||
/>
|
||||
))}
|
||||
{facetsReady && tagChips.length === 0 && (
|
||||
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
title={t("sidebar.manageTags")}
|
||||
|
|
@ -429,6 +434,7 @@ export default function Sidebar({
|
|||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue