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:
2026-07-16 00:11:30 +02:00
parent 0ab1d0b057
commit 3caef7f6f9
9 changed files with 136 additions and 44 deletions
+5 -36
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState";
@@ -12,6 +12,7 @@ import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
@@ -101,22 +102,6 @@ export default function Sidebar({
);
};
const needChannelName = !!filters.channelId && !filters.channelName;
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
enabled: needChannelName,
staleTime: 5 * 60_000,
});
const resolvedChannelName =
filters.channelName ??
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
undefined;
const channelChipLabel =
resolvedChannelName ??
(needChannelName && channelsQuery.isLoading
? t("sidebar.loading")
: t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
@@ -154,6 +139,7 @@ export default function Sidebar({
const available: Record<string, boolean> = {
savedviews: !isDemo,
date: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
tags: userTags.length > 0,
@@ -163,6 +149,8 @@ export default function Sidebar({
switch (id) {
case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
return (
<>
@@ -413,25 +401,6 @@ export default function Sidebar({
))}
</div>
{filters.channelId && !editing && (
<div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
{t("sidebar.channel")}
</div>
<div className="px-3 pb-3 pt-2">
<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">{channelChipLabel}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</div>
</div>
)}
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
{tagManagerOpen && (