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>
|
</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
|
<a
|
||||||
href={ytUrl}
|
href={ytUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -344,23 +363,6 @@ export default function ChannelPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { api, type FeedFilters } from "../lib/api";
|
import { api, type FeedFilters } from "../lib/api";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { inputCls } from "./ui/form";
|
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
|
// 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.
|
// 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 { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
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({
|
const channelsQuery = useQuery({
|
||||||
queryKey: ["channels"],
|
queryKey: ["channels"],
|
||||||
queryFn: api.channels,
|
queryFn: api.channels,
|
||||||
@@ -47,7 +48,6 @@ export default function ChannelPicker({
|
|||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const all = channelsQuery.data ?? [];
|
const all = channelsQuery.data ?? [];
|
||||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
||||||
const shown = matches.slice(0, MAX_ROWS);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -58,18 +58,28 @@ export default function ChannelPicker({
|
|||||||
aria-label={t("sidebar.channelSearch")}
|
aria-label={t("sidebar.channelSearch")}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5">
|
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
|
||||||
{shown.length === 0 ? (
|
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">
|
<div className="px-2 py-1.5 text-xs text-muted">
|
||||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
shown.map((c) => (
|
matches.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => setFilters({ ...filters, channelId: c.id, channelName: c.title ?? undefined })}
|
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
|
// shrink-0 is load-bearing: these are flex children in a max-height box, so without them
|
||||||
// fifty of them squash to ~12px each and clip their own text instead of scrolling.
|
// 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"
|
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}
|
{c.title ?? c.id}
|
||||||
@@ -77,13 +87,6 @@ export default function ChannelPicker({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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…",
|
"channelSearch": "Search channels…",
|
||||||
"noChannelMatch": "No channel matches.",
|
"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…",
|
"channelSearch": "Csatornák keresése…",
|
||||||
"noChannelMatch": "Nincs találat.",
|
"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