feat(filters): make the feed's channel filter reachable
`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a shared view. The panel still rendered a CHANNEL card whose only control was "remove" — a remove button for a filter the UI could not apply. Two ways in now. The panel gets a real Channel group (searchable picker, first-class alongside date/tags/language, so the chip row surfaces it for free); normalizeLayout appends it to saved layouts, so nobody loses their arrangement. And a subscribed channel's page gets "Show in my feed", which applies the filter and keeps the rest of your filters — the point is "this channel, the way I normally read", which the channel page itself can't answer (it runs show=all, scope=all over the whole catalog). The picker owns its channel query so a few-hundred-entry list loads when the panel is actually opened, not on every feed visit, and says "showing 50 of N" rather than quietly stopping.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
const MAX_ROWS = 50;
|
||||
|
||||
// Pick one channel to narrow the feed to. Until this existed, `filters.channelId` was settable only
|
||||
// via a shared "?channel=" link — the panel had a remove button for a filter 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 [query, setQuery] = useState("");
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: api.channels,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (filters.channelId) {
|
||||
const name =
|
||||
filters.channelName ??
|
||||
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
|
||||
(channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.thisChannel"));
|
||||
return (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{name}</span>
|
||||
<X className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = channelsQuery.data ?? [];
|
||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
||||
const shown = matches.slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
aria-label={t("sidebar.channelSearch")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted">
|
||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
||||
</div>
|
||||
) : (
|
||||
shown.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setFilters({ ...filters, channelId: c.id, channelName: c.title ?? undefined })}
|
||||
className="w-full text-left text-sm px-2 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card truncate transition"
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* The list is a picker, not a browser — but say so when it's cut off, rather than just
|
||||
stopping and letting a channel look absent. */}
|
||||
{matches.length > shown.length && (
|
||||
<div className="px-2 pt-1.5 text-[11px] text-muted">
|
||||
{t("sidebar.channelMore", { shown: shown.length, total: matches.length })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user