feat(feed): use the Combobox picker for Channel/Your tags/Topic/Language filters
Replaces the hand-rolled channel search list and the flat tag chip walls with the searchable, ARIA-correct Combobox built for the Plex filter bar (E5), so the YT feed filters get the same input+listbox+chips UX with facet counts. - ChannelPicker now wraps Combobox (selectedLabels keeps picks named); drops its bespoke input + scroll-fade list. - Sidebar language/topic/tags: each Combobox owns only its slice of the shared filters.tags list (setTagSelection replaces that list's ids, leaves the rest). Topic keeps its any/all match toggle, Your tags keeps Manage. TagChip removed.
This commit is contained in:
@@ -1,11 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { api, type FeedFilters } from "../lib/api";
|
import { api, type FeedFilters } from "../lib/api";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||||
import { inputCls } from "./ui/form";
|
|
||||||
|
|
||||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
// 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:
|
// 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;
|
setFilters: (f: FeedFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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({
|
const channelsQuery = useQuery({
|
||||||
queryKey: qk.channels(),
|
queryKey: qk.channels(),
|
||||||
queryFn: api.channels,
|
queryFn: api.channels,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
const all = channelsQuery.data ?? [];
|
const all = channelsQuery.data ?? [];
|
||||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
|
||||||
const picked = filters.channelIds;
|
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
|
||||||
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
// narrowing ever drops it from the visible rows.
|
||||||
const toggle = (id: string) =>
|
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
|
||||||
setFilters({
|
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
|
||||||
...filters,
|
|
||||||
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Combobox
|
||||||
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
label={t("sidebar.channelSearch")}
|
||||||
{picked.length > 0 && (
|
placeholder={t("sidebar.channelSearch")}
|
||||||
<div className="flex flex-wrap gap-1.5 pb-2">
|
options={options}
|
||||||
{picked.map((id) => (
|
selected={filters.channelIds}
|
||||||
<button
|
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||||
key={id}
|
loading={channelsQuery.isLoading}
|
||||||
onClick={() => toggle(id)}
|
emptyText={t("sidebar.noChannelMatch")}
|
||||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
selectedLabels={selectedLabels}
|
||||||
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
|
/>
|
||||||
>
|
|
||||||
<span className="truncate">{nameOf(id)}</span>
|
|
||||||
<X className="w-3 h-3 shrink-0" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => 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 && (
|
|
||||||
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={fade.ref}
|
|
||||||
style={fade.style}
|
|
||||||
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
{matches.length === 0 ? (
|
|
||||||
<div className="px-2 py-1.5 text-xs text-muted">
|
|
||||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
matches.map((c) => {
|
|
||||||
const on = picked.includes(c.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => toggle(c.id)}
|
|
||||||
aria-pressed={on}
|
|
||||||
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
|
||||||
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
|
||||||
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
|
||||||
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.title ?? c.id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { type PanelItem } from "./PanelGroups";
|
|||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
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 { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useChannelsActions } from "./ChannelsProvider";
|
import { useChannelsActions } from "./ChannelsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
@@ -29,39 +30,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
|||||||
{ days: 365, key: "1year" },
|
{ days: 365, key: "1year" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TagChip({
|
|
||||||
tag,
|
|
||||||
count,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
tag: Tag;
|
|
||||||
count: number;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
data-testid="filter-tag-chip"
|
|
||||||
data-tag={tag.name}
|
|
||||||
onClick={onClick}
|
|
||||||
title={t("sidebar.channelCount", { count })}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={filterChipClass}
|
|
||||||
>
|
|
||||||
<span>{tag.name}</span>
|
|
||||||
<span
|
|
||||||
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
|
|
||||||
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
@@ -113,11 +81,20 @@ export default function Sidebar({
|
|||||||
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
const source: MediaSourceKind = "feed";
|
const source: MediaSourceKind = "feed";
|
||||||
|
|
||||||
function toggleTag(id: number) {
|
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
|
||||||
const has = filters.tags.includes(id);
|
// 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({
|
setFilters({
|
||||||
...filters,
|
...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": {
|
case "language": {
|
||||||
const chips = visibleChips(languages);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<Combobox
|
||||||
{chips.map((tg) => (
|
label={t("sidebar.widget.language")}
|
||||||
<TagChip
|
options={visibleChips(languages).map(tagOption)}
|
||||||
key={tg.id}
|
selected={selectedIn(languages)}
|
||||||
tag={tg}
|
onChange={(next) => setTagSelection(languages, next)}
|
||||||
count={chipCount(tg)}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
active={filters.tags.includes(tg.id)}
|
/>
|
||||||
onClick={() => toggleTag(tg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{facetsReady && chips.length === 0 && (
|
|
||||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "topic": {
|
case "topic": {
|
||||||
const chips = visibleChips(topics);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2">
|
||||||
<SegmentedToggle
|
<SegmentedToggle
|
||||||
label={t("sidebar.match")}
|
label={t("sidebar.match")}
|
||||||
title={t("sidebar.tagModeTooltip")}
|
title={t("sidebar.tagModeTooltip")}
|
||||||
@@ -249,43 +217,30 @@ export default function Sidebar({
|
|||||||
value={filters.tagMode}
|
value={filters.tagMode}
|
||||||
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
|
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<Combobox
|
||||||
{chips.map((tg) => (
|
label={t("sidebar.widget.topic")}
|
||||||
<TagChip
|
options={visibleChips(topics).map(tagOption)}
|
||||||
key={tg.id}
|
selected={selectedIn(topics)}
|
||||||
tag={tg}
|
onChange={(next) => setTagSelection(topics, next)}
|
||||||
count={chipCount(tg)}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
active={filters.tags.includes(tg.id)}
|
/>
|
||||||
onClick={() => toggleTag(tg.id)}
|
</div>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{facetsReady && chips.length === 0 && (
|
|
||||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tags": {
|
case "tags": {
|
||||||
const tagChips = visibleChips(userTags);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-col gap-2">
|
||||||
{tagChips.map((tg) => (
|
<Combobox
|
||||||
<TagChip
|
label={t("sidebar.widget.tags")}
|
||||||
key={tg.id}
|
options={visibleChips(userTags).map(tagOption)}
|
||||||
tag={tg}
|
selected={selectedIn(userTags)}
|
||||||
count={chipCount(tg)}
|
onChange={(next) => setTagSelection(userTags, next)}
|
||||||
active={filters.tags.includes(tg.id)}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
onClick={() => toggleTag(tg.id)}
|
/>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{facetsReady && tagChips.length === 0 && (
|
|
||||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
title={t("sidebar.manageTags")}
|
title={t("sidebar.manageTags")}
|
||||||
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 self-start text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
{t("sidebar.manageTags")}
|
{t("sidebar.manageTags")}
|
||||||
|
|||||||
Reference in New Issue
Block a user