The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
48 lines
1.9 KiB
TypeScript
48 lines
1.9 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, type FeedFilters } from "../lib/api";
|
|
import Combobox, { type ComboboxOption } from "./ui/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:
|
|
// the panel had a remove button for something nothing could apply.
|
|
//
|
|
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
|
|
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
|
|
// fetch here means a subscription list of a few hundred loads when the picker is actually looked at.
|
|
export default function ChannelPicker({
|
|
filters,
|
|
setFilters,
|
|
}: {
|
|
filters: FeedFilters;
|
|
setFilters: (f: FeedFilters) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const channelsQuery = useQuery({
|
|
queryKey: qk.channels(),
|
|
queryFn: api.channels,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
const all = channelsQuery.data ?? [];
|
|
// 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 (
|
|
<Combobox
|
|
label={t("sidebar.channelSearch")}
|
|
placeholder={t("sidebar.channelSearch")}
|
|
options={options}
|
|
selected={filters.channelIds}
|
|
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
|
loading={channelsQuery.isLoading}
|
|
emptyText={t("sidebar.noChannelMatch")}
|
|
selectedLabels={selectedLabels}
|
|
/>
|
|
);
|
|
}
|