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:
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user