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
+11
View File
@@ -843,6 +843,17 @@ export default function App() {
view={view} view={view}
onBack={closeChannel} onBack={closeChannel}
onOpenChannel={openChannel} onOpenChannel={openChannel}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
setFilters({
...filters,
channelId: channelView.id,
channelName: channelView.name,
});
closeChannel();
setPage("feed");
}}
/> />
</Suspense> </Suspense>
) : ( ) : (
+16 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import Feed from "./Feed"; import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@@ -56,12 +56,17 @@ export default function ChannelPage({
view, view,
onBack, onBack,
onOpenChannel, onOpenChannel,
onShowInFeed,
}: { }: {
channelId: string; channelId: string;
initialName?: string; initialName?: string;
me: Me; me: Me;
view: "grid" | "list"; view: "grid" | "list";
onBack: () => void; onBack: () => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
// follow would just land you on an empty page.
onShowInFeed: () => void;
onOpenChannel: (id: string, name?: string) => void; onOpenChannel: (id: string, name?: string) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -290,6 +295,16 @@ export default function ChannelPage({
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
{ch?.subscribed && (
<button
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
)}
{!me.is_demo && ( {!me.is_demo && (
<button <button
onClick={() => block.mutate()} onClick={() => block.mutate()}
+87
View File
@@ -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>
)}
</>
);
}
+5 -36
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; 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 { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
@@ -12,6 +12,7 @@ import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget"; import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel"; import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups"; import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [ 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 languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system); const userTags = tags.filter((t) => !t.system);
@@ -154,6 +139,7 @@ export default function Sidebar({
const available: Record<string, boolean> = { const available: Record<string, boolean> = {
savedviews: !isDemo, savedviews: !isDemo,
date: true, date: true,
channel: true,
language: languages.length > 0, language: languages.length > 0,
topic: topics.length > 0, topic: topics.length > 0,
tags: userTags.length > 0, tags: userTags.length > 0,
@@ -163,6 +149,8 @@ export default function Sidebar({
switch (id) { switch (id) {
case "savedviews": case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />; return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date": case "date":
return ( return (
<> <>
@@ -413,25 +401,6 @@ export default function Sidebar({
))} ))}
</div> </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} /> <PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
{tagManagerOpen && ( {tagManagerOpen && (
+2 -1
View File
@@ -23,5 +23,6 @@
"country": "Country", "country": "Country",
"language": "Language", "language": "Language",
"topics": "Topics", "topics": "Topics",
"keywords": "Keywords" "keywords": "Keywords",
"showInFeed": "Show in my feed"
} }
+6 -2
View File
@@ -40,7 +40,8 @@
"content": "Content type", "content": "Content type",
"language": "Language", "language": "Language",
"topic": "Topic", "topic": "Topic",
"tags": "Your tags" "tags": "Your tags",
"channel": "Channel"
}, },
"show": { "show": {
"unwatched": "Unwatched", "unwatched": "Unwatched",
@@ -72,5 +73,8 @@
"1month": "1 month", "1month": "1 month",
"6months": "6 months", "6months": "6 months",
"1year": "1 year" "1year": "1 year"
} },
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
"channelMore": "Showing {{shown}} of {{total}} — search to narrow."
} }
+2 -1
View File
@@ -23,5 +23,6 @@
"country": "Ország", "country": "Ország",
"language": "Nyelv", "language": "Nyelv",
"topics": "Témák", "topics": "Témák",
"keywords": "Kulcsszavak" "keywords": "Kulcsszavak",
"showInFeed": "Mutasd a hírfolyamomban"
} }
+6 -2
View File
@@ -40,7 +40,8 @@
"content": "Tartalomtípus", "content": "Tartalomtípus",
"language": "Nyelv", "language": "Nyelv",
"topic": "Téma", "topic": "Téma",
"tags": "Saját címkék" "tags": "Saját címkék",
"channel": "Csatorna"
}, },
"show": { "show": {
"unwatched": "Nem nézett", "unwatched": "Nem nézett",
@@ -72,5 +73,8 @@
"1month": "1 hónap", "1month": "1 hónap",
"6months": "6 hónap", "6months": "6 hónap",
"1year": "1 év" "1year": "1 év"
} },
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
"channelMore": "{{total}} közül {{shown}} látszik — keress a szűkítéshez."
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are // Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears. // appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = { const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["savedviews", "date", "tags", "language", "topic"], feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
plex: [ plex: [
"scope", "scope",
"playlists", "playlists",