feat(feed,channels): "No tag" filter for untagged channels (E8·S1)
Filter to channels you haven't personally tagged in the channel manager (no user-owned ChannelTag; auto language/topic tags don't count): - Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive with real tag picks) + a removable "Untagged" chip; new `untagged` bool feed param / `notag` URL key, persisted + coerced on every filter path. - Channel manager: a virtual "No tag" chip in the tag-chip row filters the table to untagged channels (client-side, shared NO_TAG_SENTINEL). Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps it applied in the base pass — the count/facet lockstep holds with no mirror. Note: item E (per-tag counts) and item D (Any/All toggle) were already shipped; S1 delivers only item B.
This commit is contained in:
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
@@ -104,6 +104,41 @@ export default function Sidebar({
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
|
||||
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
|
||||
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
|
||||
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
|
||||
function userTagsCombo(): ReactNode {
|
||||
const noneOn = !!filters.untagged;
|
||||
const inList = (id: number) => userTags.some((tg) => tg.id === id);
|
||||
const onChange = (next: string[]) => {
|
||||
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
|
||||
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
|
||||
} else {
|
||||
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
|
||||
untagged: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={[
|
||||
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
|
||||
...visibleChips(userTags).map(tagOption),
|
||||
]}
|
||||
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
|
||||
onChange={onChange}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
@@ -124,7 +159,7 @@ export default function Sidebar({
|
||||
return (
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
case "language": {
|
||||
case "language":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
@@ -136,8 +171,7 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "topic": {
|
||||
case "topic":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
@@ -149,22 +183,10 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
case "tags":
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker.
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={visibleChips(userTags).map(tagOption)}
|
||||
selected={selectedIn(userTags)}
|
||||
onChange={(next) => setTagSelection(userTags, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// body is just the picker — user tags plus the "No tag" sentinel.
|
||||
return userTagsCombo();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user