improvement(channel): bring the actions to the tabs, list every channel

Channel page: the actions sat in the header's top-right corner, a page-width
mouse trip from anything else you'd click. There are only ever two tabs, so
they ride the tab row now, right next to About.

Channel picker: drop the 50-row cap — 320 plain buttons cost nothing to render
(the feed virtualises thousands), so the whole list scrolls in the same box. The
cap was caution, not a measurement. The box gets the app's usual edge-fade to
hint at more, and a line saying "Priority first, then A-Z" — the backend orders
by `priority DESC, lower(title)`, which reads as random to anyone who hasn't set
a priority.
This commit is contained in:
2026-07-16 00:32:53 +02:00
parent 452fbd43de
commit 82e351bc62
4 changed files with 40 additions and 35 deletions
+20 -18
View File
@@ -284,7 +284,26 @@ export default function ChannelPage({
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pb-1">
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
<div className="ml-2 flex items-center gap-2 pb-1.5">
<a
href={ytUrl}
target="_blank"
@@ -344,23 +363,6 @@ export default function ChannelPage({
))}
</div>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
</div>
</div>
{/* Tab content */}
+18 -15
View File
@@ -3,10 +3,9 @@ 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";
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.
//
@@ -22,6 +21,8 @@ export default function ChannelPicker({
}) {
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,
@@ -47,7 +48,6 @@ export default function ChannelPicker({
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 (
<>
@@ -58,18 +58,28 @@ export default function ChannelPicker({
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 ? (
{/* 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>
) : (
shown.map((c) => (
matches.map((c) => (
<button
key={c.id}
onClick={() => setFilters({ ...filters, channelId: c.id, channelName: c.title ?? undefined })}
// shrink-0 is load-bearing: these are flex children in a max-height box, so without it
// fifty of them squash to ~12px each and clip their own text instead of scrolling.
// 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 text-muted hover:text-fg hover:bg-card truncate transition"
>
{c.title ?? c.id}
@@ -77,13 +87,6 @@ export default function ChannelPicker({
))
)}
</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 -1
View File
@@ -76,5 +76,5 @@
},
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
"channelMore": "Showing {{shown}} of {{total}} — search to narrow."
"channelOrder": "Priority first, then AZ"
}
+1 -1
View File
@@ -76,5 +76,5 @@
},
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
"channelMore": "{{total}} közül {{shown}} látszik — keress a szűkítéshez."
"channelOrder": "Prioritás szerint, majd AZ"
}