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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,5 +76,5 @@
|
||||
},
|
||||
"channelSearch": "Search channels…",
|
||||
"noChannelMatch": "No channel matches.",
|
||||
"channelMore": "Showing {{shown}} of {{total}} — search to narrow."
|
||||
"channelOrder": "Priority first, then A–Z"
|
||||
}
|
||||
|
||||
@@ -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 A–Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user