diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index e09f037b..3c4386de 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -1,11 +1,8 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { X } from "lucide-react"; import { api, type FeedFilters } from "../lib/api"; -import { useScrollFade } from "../lib/useScrollFade"; -import { inputCls } from "./ui/form"; +import Combobox, { type ComboboxOption } from "./Combobox"; // Pick channels to narrow the feed to — several OR together, so every other filter then applies // across all of them. Until this existed the filter was settable only via a shared "?channel=" link: @@ -22,86 +19,29 @@ export default function ChannelPicker({ setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); - const [query, setQuery] = useState(""); - // Same edge-fade affordance the nav rail and the panel body use to hint at more content. - const fade = useScrollFade(); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels, staleTime: 5 * 60_000, }); - const q = query.trim().toLowerCase(); const all = channelsQuery.data ?? []; - const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all; - const picked = filters.channelIds; - const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id; - const toggle = (id: string) => - setFilters({ - ...filters, - channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id], - }); + // The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the + // options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later + // narrowing ever drops it from the visible rows. + const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id })); + const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id])); return ( - <> - {/* The picks sit above the list so they stay visible once the list scrolls away. */} - {picked.length > 0 && ( -
- {picked.map((id) => ( - - ))} -
- )} - setQuery(e.target.value)} - placeholder={t("sidebar.channelSearch")} - aria-label={t("sidebar.channelSearch")} - className={inputCls} - /> - {/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks - random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing - but does drop the rows that explain it. */} - {!q && all.length > 0 && ( -
{t("sidebar.channelOrder")}
- )} -
- {matches.length === 0 ? ( -
- {channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")} -
- ) : ( - matches.map((c) => { - const on = picked.includes(c.id); - return ( - - ); - }) - )} -
- + setFilters({ ...filters, channelIds })} + loading={channelsQuery.isLoading} + emptyText={t("sidebar.noChannelMatch")} + selectedLabels={selectedLabels} + /> ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d934834a..63a97252 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,7 +15,8 @@ import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import SegmentedToggle from "./SegmentedToggle"; -import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; +import FilterChipGroup from "./FilterChipGroup"; +import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useMe } from "../lib/useMe"; @@ -29,39 +30,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [ { days: 365, key: "1year" }, ]; -function TagChip({ - tag, - count, - active, - onClick, -}: { - tag: Tag; - count: number; - active: boolean; - onClick: () => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} - export default function Sidebar({ layout, setLayout, @@ -113,11 +81,20 @@ export default function Sidebar({ // the panel layout key. `FilterRail` owns the customize `editing` state now. const source: MediaSourceKind = "feed"; - function toggleTag(id: number) { - const has = filters.tags.includes(id); + // The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns + // only its own slice: its selection replaces the ids that belong to its list, leaving the others. + const tagOption = (tg: Tag): ComboboxOption => ({ + value: String(tg.id), + label: tg.name, + count: chipCount(tg), + }); + const selectedIn = (list: Tag[]): string[] => + filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String); + function setTagSelection(list: Tag[], next: string[]) { + const inList = new Set(list.map((tg) => tg.id)); setFilters({ ...filters, - tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id], + tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)], }); } @@ -216,28 +193,19 @@ export default function Sidebar({ ); } case "language": { - const chips = visibleChips(languages); return ( -
- {chips.map((tg) => ( - toggleTag(tg.id)} - /> - ))} - {facetsReady && chips.length === 0 && ( - {t("sidebar.noMatchingTags")} - )} -
+ setTagSelection(languages, next)} + emptyText={t("sidebar.noMatchingTags")} + /> ); } case "topic": { - const chips = visibleChips(topics); return ( - <> +
setFilters({ ...filters, tagMode })} /> -
- {chips.map((tg) => ( - toggleTag(tg.id)} - /> - ))} - {facetsReady && chips.length === 0 && ( - {t("sidebar.noMatchingTags")} - )} -
- + setTagSelection(topics, next)} + emptyText={t("sidebar.noMatchingTags")} + /> +
); } case "tags": { - const tagChips = visibleChips(userTags); return ( -
- {tagChips.map((tg) => ( - toggleTag(tg.id)} - /> - ))} - {facetsReady && tagChips.length === 0 && ( - {t("sidebar.noMatchingTags")} - )} +
+ setTagSelection(userTags, next)} + emptyText={t("sidebar.noMatchingTags")} + />