Pick any number of channels; they OR together and every other filter applies across the lot — "the last week's unwatched from these three". `channelId`/`channelName` are replaced outright by `channelIds: string[]`, no legacy reader (standing rule: no back-compat unless asked — a saved view written against the old field simply loses its channel filter). The wire and the share URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via `Video.channel_id.in_()`; the count endpoint shares the same params object. Two things the migration exposed: - SavedViewsWidget fed DB blobs straight into serializers that now index an array — the FeedFilters type says nothing about what an older build wrote, so the blob is coerced where it enters. It white-screened the app before this. - Chips carried channelName to label themselves. Without it they resolve names from the channel cache, so they now fall back to the id, not a shared "This channel" that would render N identical chips while the cache loads.
107 lines
4.2 KiB
TypeScript
107 lines
4.2 KiB
TypeScript
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 { useScrollFade } from "../lib/useScrollFade";
|
|
import { inputCls } from "./ui/form";
|
|
|
|
// 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 [query, setQuery] = useState("");
|
|
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
|
|
const fade = useScrollFade();
|
|
const channelsQuery = useQuery({
|
|
queryKey: ["channels"],
|
|
queryFn: api.channels,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
const q = query.trim().toLowerCase();
|
|
const all = channelsQuery.data ?? [];
|
|
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
|
const picked = filters.channelIds;
|
|
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
|
const toggle = (id: string) =>
|
|
setFilters({
|
|
...filters,
|
|
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
|
{picked.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pb-2">
|
|
{picked.map((id) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => toggle(id)}
|
|
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
|
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-sm hover:opacity-90 transition"
|
|
>
|
|
<span className="truncate">{nameOf(id)}</span>
|
|
<X className="w-3 h-3 shrink-0" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("sidebar.channelSearch")}
|
|
aria-label={t("sidebar.channelSearch")}
|
|
className={inputCls}
|
|
/>
|
|
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
|
|
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
|
|
but does drop the rows that explain it. */}
|
|
{!q && all.length > 0 && (
|
|
<div className="px-2 pt-1.5 text-[11px] text-muted">{t("sidebar.channelOrder")}</div>
|
|
)}
|
|
<div
|
|
ref={fade.ref}
|
|
style={fade.style}
|
|
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
|
>
|
|
{matches.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-xs text-muted">
|
|
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
|
</div>
|
|
) : (
|
|
matches.map((c) => {
|
|
const on = picked.includes(c.id);
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => toggle(c.id)}
|
|
aria-pressed={on}
|
|
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
|
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
|
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
|
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
{c.title ?? c.id}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|