Merge E3: feed view modes + scroll-affordance sweep

Five view modes behind a reusable ViewSwitcher (cards · cardsSmall · rows ·
rowsCompact · tiles), and a subdued edge-fade on the page scroller and every
capped list. UAT-accepted; not shipped to prod (user's call — the Platform
refactor is next). Version bump + releaseNotes deferred to ship time.
This commit is contained in:
2026-07-17 21:58:43 +02:00
30 changed files with 954 additions and 203 deletions
+22 -13
View File
@@ -30,6 +30,7 @@ import {
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { parseFeedView, type FeedView } from "./lib/feedView";
import {
accountKey,
getAccountRaw,
@@ -53,6 +54,7 @@ import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
import PageScroller from "./components/PageScroller";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
@@ -108,7 +110,6 @@ const PERF_KEY = LS.perfMode;
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
theme: ThemePrefs;
view: "grid" | "list";
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
@@ -170,14 +171,16 @@ export default function App() {
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search))
);
const [view, setView] = useState<"grid" | "list">("grid");
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft:
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
@@ -387,6 +390,11 @@ export default function App() {
writeAccount(LS.navCollapsed, next);
api.savePrefs({ navCollapsed: next }).catch(() => {});
}
function setView(next: FeedView) {
setViewState(next);
writeAccount(LS.feedView, next);
api.savePrefs({ view: next }).catch(() => {});
}
function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next);
writeAccount(LS.filterCollapsed, next);
@@ -603,7 +611,6 @@ export default function App() {
// effects above apply the draft (theme/perf/hints/notifications) for display.
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
@@ -615,13 +622,16 @@ export default function App() {
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setView(adopted.view);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page).
const prefsRec = prefs as Record<string, unknown>;
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
const adoptedView = parseFeedView(prefs.view);
setViewState(adoptedView);
writeAccount(LS.feedView, adoptedView);
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
if (raw) {
@@ -689,11 +699,11 @@ export default function App() {
}
const prefsDirty =
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
const payload: EditablePrefs = { theme, performanceMode: perf, hints, notifications: notif };
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("saving");
api
@@ -709,12 +719,11 @@ export default function App() {
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, [theme, view, perf, hints, notif]);
}, [theme, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setView(savedPrefs.view);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
@@ -725,8 +734,6 @@ export default function App() {
const prefsCtl: PrefsController = {
theme,
setTheme,
view,
setView,
perf,
setPerf,
hints,
@@ -842,6 +849,7 @@ export default function App() {
initialName={channelView.name}
me={meQuery.data!}
view={view}
setView={setView}
onBack={closeChannel}
onOpenChannel={openChannel}
onShowInFeed={() => {
@@ -891,7 +899,7 @@ export default function App() {
>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto">
<PageScroller>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
@@ -962,6 +970,7 @@ export default function App() {
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
@@ -972,7 +981,7 @@ export default function App() {
/>
)}
</Suspense>
</main>
</PageScroller>
</div>
</>
)}
+3 -1
View File
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
@@ -17,6 +18,7 @@ export default function AddToPlaylist({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
@@ -134,7 +136,7 @@ export default function AddToPlaylist({
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylist")}
</div>
<div className="max-h-56 overflow-y-auto">
<div ref={fade.ref} style={fade.style} className="max-h-56 overflow-y-auto no-scrollbar">
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">
{t("playlists.noneYet")}
+29 -15
View File
@@ -4,29 +4,43 @@ import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
// <main class="overflow-y-auto"> the normal pages share App's, the channel page renders its own
// so this follows whichever one is mounted. Portaled to <body> so its fixed position is
// viewport-relative regardless of transformed ancestors.
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const scrollerRef = useRef<HTMLElement | null>(null);
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than
// looking <main> up when this binds. Scroll events don't bubble, but they do capture — and the
// lookup was a race the lazy pages lost: on the FIRST visit to the channel page its chunk is
// still loading, so the Suspense fallback is on screen, there is no <main> to find, and the
// effect gave up for good (its dep never changes again). The button then stayed dead for that
// whole visit and came back only once the chunk was cached. Nothing to find, nothing to race.
useEffect(() => {
const onScroll = (e: Event) => {
const el = e.target as HTMLElement | null;
if (!el || el.tagName !== "MAIN") return;
scrollerRef.current = el;
setShow(el.scrollTop > threshold);
};
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
return () => document.removeEventListener("scroll", onScroll, { capture: true });
}, [threshold]);
// On navigation, re-read where the new page sits: usually the top (so hide), but some pages
// restore their scroll position. Missing <main> here is now harmless — a page that hasn't
// rendered hasn't scrolled either, and the listener above picks it up the moment it does.
useEffect(() => {
const el = document.querySelector("main");
scrollerRef.current = el;
if (!el) {
setShow(false);
return;
}
const onScroll = () => setShow(el.scrollTop > threshold);
onScroll(); // a freshly-bound page starts at the top → hidden
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
setShow(!!el && el.scrollTop > threshold);
}, [dep, threshold]);
const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
const toTop = () =>
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({ top: 0, behavior: "smooth" });
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
@@ -34,11 +48,11 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal(
<button
onClick={toTop}
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="w-5 h-5" aria-hidden="true" />
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
document.body
+10 -6
View File
@@ -4,10 +4,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import PageScroller from "./PageScroller";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// --- About-tab metadata helpers ---
@@ -54,6 +56,7 @@ export default function ChannelPage({
initialName,
me,
view,
setView,
onBack,
onOpenChannel,
onShowInFeed,
@@ -61,7 +64,10 @@ export default function ChannelPage({
channelId: string;
initialName?: string;
me: Me;
view: "grid" | "list";
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => 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
@@ -203,10 +209,7 @@ export default function ChannelPage({
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
return (
// scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
// tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the
// header/logo/buttons horizontally as the scrollbar appears/disappears.
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
<PageScroller>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
@@ -371,6 +374,7 @@ export default function ChannelPage({
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
@@ -464,6 +468,6 @@ export default function ChannelPage({
)}
</div>
)}
</main>
</PageScroller>
);
}
+5 -1
View File
@@ -19,6 +19,7 @@ import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -684,6 +685,7 @@ function TagsCell({
// Open the picker upward when there isn't room below (rows near the viewport bottom would
// otherwise clip it against the scroll container), as long as there's room above.
const [openUp, setOpenUp] = useState(false);
const fade = useScrollFade();
const ref = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useDismiss(open, () => setOpen(false), ref);
@@ -723,7 +725,9 @@ function TagsCell({
</button>
{open && (
<div
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
ref={fade.ref}
style={fade.style}
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
+7 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
@@ -28,6 +29,7 @@ export default function ChatThread({
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const fade = useScrollFade();
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
@@ -132,7 +134,11 @@ export default function ChatThread({
return (
<>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
<div
ref={fade.ref}
style={fade.style}
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
>
{items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
+19 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -9,6 +10,22 @@ import { useDismiss } from "../lib/useDismiss";
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
// list built from the same column definitions.
// The multi-select filter's option list, capped and scrollable. Its own component (module level,
// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
// render, so React remounts it — state parked in there would churn.
function FilterOptionList({ children }: { children: ReactNode }) {
const fade = useScrollFade();
return (
<div
ref={fade.ref}
style={fade.style}
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
>
{children}
</div>
);
}
type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
@@ -218,7 +235,7 @@ export default function DataTable<T>({
</div>
)}
{f.kind === "multi" && (
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
@@ -249,7 +266,7 @@ export default function DataTable<T>({
</button>
);
})}
</div>
</FilterOptionList>
)}
{isActive(val) && (
<button
+41 -2
View File
@@ -1,7 +1,21 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ArrowLeft,
Columns2,
Grid3x3,
Info,
LayoutGrid,
List,
RefreshCw,
Rows3,
Trash2,
Youtube,
type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
@@ -14,8 +28,10 @@ import {
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -32,6 +48,15 @@ const CONTENT = [
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
cards: LayoutGrid,
cardsSmall: Grid3x3,
rows: Rows3,
rowsCompact: List,
tiles: Columns2,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
@@ -56,6 +81,7 @@ export default function Feed({
filters,
setFilters,
view,
setView,
canRead,
isDemo = false,
onOpenWizard,
@@ -67,7 +93,10 @@ export default function Feed({
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
// The view mode is an account pref, not a filter: App owns it and it persists on pick, so it
// isn't part of FeedFilters (no share-link param, no saved view, no active-filter chip).
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
isDemo?: boolean;
onOpenWizard: () => void;
@@ -621,6 +650,16 @@ export default function Feed({
<RefreshCw className="w-4 h-4" />
</button>
)}
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({ id, label: t("feed.view." + id), icon: VIEW_ICON[id] }))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</div>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
import { type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
// The page's scroll container. Two of these exist and are never both mounted: App renders one for
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
// than owning a scroller — which is why the edge-fade lives here once instead of three times.
//
// Two invariants this element carries, both learned the hard way:
//
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
// switch from the tall Videos tab to the short About tab.
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
// fade next to its scrollbar, not instead of it.
//
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
// `children` arrives as an already-built element, so React skips the page subtree underneath.
export default function PageScroller({ children }: { children: ReactNode }) {
const fade = useScrollFade();
return (
<main
ref={fade.ref}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
>
{children}
</main>
);
}
+15 -3
View File
@@ -10,6 +10,7 @@ import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
import { useScrollFade } from "../lib/useScrollFade";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -73,6 +74,7 @@ export default function PlayerModal({
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const descFade = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
@@ -581,7 +583,12 @@ export default function PlayerModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
return (
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
// edge-fade mask makes it one) this z-50 is scoped inside it, and the nav rail (z-40) and the
// header (z-30) paint over the modal — worst at browser zoom, where the centered card is smaller
// relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.)
return createPortal(
<div
ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
@@ -775,7 +782,11 @@ export default function PlayerModal({
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
@@ -938,6 +949,7 @@ export default function PlayerModal({
</button>
)}
</div>
</div>
</div>,
document.body
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import {
@@ -226,8 +227,10 @@ export default function PlexBrowse({
}
if (sub.view.kind === "player") {
// The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller.
return (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<Suspense fallback={createPortal(<div className="fixed inset-0 z-50 bg-black" />, document.body)}>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
@@ -26,6 +27,8 @@ export default function PlexCollectionEditor({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const editableFade = useScrollFade();
const othersFade = useScrollFade();
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
const [q, setQ] = useState("");
const [newName, setNewName] = useState("");
@@ -146,7 +149,11 @@ export default function PlexCollectionEditor({
)}
{/* Editable collections with in/out toggle */}
<div className="max-h-80 space-y-1 overflow-y-auto">
<div
ref={editableFade.ref}
style={editableFade.style}
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
>
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : shown.length === 0 ? (
@@ -197,7 +204,11 @@ export default function PlexCollectionEditor({
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
{t("plex.collEditor.others", { count: eligible.length })}
</summary>
<div className="mt-2 max-h-40 space-y-1 overflow-y-auto">
<div
ref={othersFade.ref}
style={othersFade.style}
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
>
{eligible.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
<span className="min-w-0 flex-1 truncate">{c.title}</span>
+25 -5
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
type WheelEvent as ReactWheelEvent,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
@@ -36,6 +37,7 @@ import { api, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
@@ -245,13 +247,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const menuOpenRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const tracksRef = useRef<HTMLDivElement>(null);
const tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
audioRef.current = audioOrd;
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
const tracksFade = useScrollFade();
const setTracksNode = useCallback(
(node: HTMLElement | null) => {
tracksRef.current = node as HTMLDivElement | null;
tracksFade.ref(node);
},
[tracksFade.ref]
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
const helpOpenRef = useRef(false);
@@ -932,7 +945,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
return (
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
// this z-50 is then scoped inside it — the rail, the side panel and the header paint straight
// over the player. That is exactly what the scroller's edge-fade did before this moved out.
return createPortal(
<div
ref={wrapRef}
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
@@ -1222,10 +1240,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</button>
{tracksOpen && (
<div
ref={tracksRef}
ref={setTracksNode}
style={tracksFade.style}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
>
{detail.audio_streams.length > 1 && (
<>
@@ -1606,7 +1625,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
)}
</div>
</div>,
document.body
);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
@@ -16,6 +17,7 @@ export type PlexAddTarget =
// an in/out (or partial) state and a field to create a new one seeded with the target.
export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) {
const { t } = useTranslation();
const fade = useScrollFade();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
@@ -101,7 +103,7 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
</button>
</div>
<div className="max-h-80 space-y-1 overflow-y-auto">
<div ref={fade.ref} style={fade.style} className="max-h-80 space-y-1 overflow-y-auto no-scrollbar">
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : playlists.length === 0 ? (
+7 -1
View File
@@ -5,6 +5,7 @@ import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
@@ -56,6 +57,7 @@ export default function PlexSidebar({
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
@@ -258,7 +260,11 @@ export default function PlexSidebar({
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
<div
ref={collFade.ref}
style={collFade.style}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
>
{collections.map((c) => (
<button
key={c.id}
+1 -10
View File
@@ -20,8 +20,6 @@ export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
@@ -128,7 +126,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
return (
<>
@@ -186,13 +184,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
/>
</div>
)}
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch
label={t("settings.appearance.listView")}
checked={view === "list"}
onChange={(v) => setView(v ? "list" : "grid")}
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
+11 -3
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
@@ -28,6 +29,7 @@ function TagRow({
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
@@ -77,10 +79,11 @@ function TagRow({
{open &&
createPortal(
<div
ref={popFade.ref}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60 }}
className="glass-menu w-56 max-h-72 overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60, ...popFade.style }}
className="glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
@@ -111,6 +114,7 @@ export default function TagManager({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const listFade = useScrollFade();
const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
@@ -157,7 +161,11 @@ export default function TagManager({
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
<div className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto pr-1">
<div
ref={listFade.ref}
style={listFade.style}
className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto no-scrollbar pr-1"
>
{userTags.map((tag) => (
<TagRow
key={tag.id}
+347 -78
View File
@@ -4,16 +4,21 @@ import {
Bookmark,
Check,
CheckCheck,
Clock,
Eye,
EyeOff,
Play,
Radio,
RotateCcw,
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
Video as VideoIcon,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
@@ -21,11 +26,14 @@ function Actions({
onState,
onResetState,
onToggleSave,
dense = false,
}: {
video: Video;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
@@ -41,15 +49,24 @@ function Actions({
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
// stay the same size instead of two of them keeping their own default.
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
// narrowest the last button used to hang outside the card. 154 at dense.
const btn = clsx(
"rounded-md hover:bg-surface text-muted hover:text-fg",
dense ? "p-1" : "p-1.5"
);
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap opacity-0 group-hover:opacity-100 transition", dense ? "gap-0.5" : "gap-1")}>
<button
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
)}
className={clsx(btn, video.status === "watched" && "text-accent")}
>
{video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
@@ -60,22 +77,16 @@ function Actions({
<button
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.saved && "fill-current text-accent"
)}
className={clsx(btn, video.saved && "fill-current text-accent")}
>
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<AddToPlaylist videoId={video.id} className={btn} />
<DownloadButton videoId={video.id} title={video.title} className={btn} />
<button
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "hidden" && "text-accent"
)}
className={clsx(btn, video.status === "hidden" && "text-accent")}
>
{video.status === "hidden" ? (
<Eye className="w-4 h-4" />
@@ -91,7 +102,7 @@ function Actions({
onResetState(video.id);
}}
title={t("card.resetState")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
className={btn}
>
<RotateCcw className="w-4 h-4" />
</button>
@@ -112,23 +123,95 @@ function openInApp(
onOpen(video);
}
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
// no percentage to draw, so the two are deliberately separate predicates.
function isInProgress(video: Video): boolean {
return video.position_seconds > 0 && video.status !== "watched";
}
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
function resumePct(video: Video): number {
return isInProgress(video) && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
}
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
// floating chips over artwork and a table cell are different presentations, and forcing one
// component to do both would be a variant-flag knot for no reuse.
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
// videoBadges(video) -> Badge[] — and keep only the markup separate.
//
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video }: { video: Video }) {
const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
// because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
? { Icon: Radio, cls: "text-red-500", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
: null;
const StatusIcon = status?.Icon;
// 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
// the text-size setting the same way its contents do.
return (
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
<span className="text-right tabular-nums">
{video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
</span>
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
bare span has no role, and screen readers don't dependably surface title on one), while a
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
in Actions — the label must not tell anyone to click something that doesn't respond. */}
<span>
{StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}>
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
</span>
)}
</span>
<span>
{video.saved && (
<span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
</span>
)}
</span>
</div>
);
}
function Thumb({
video,
className,
onOpen,
small = false,
}: {
video: Video;
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
}) {
const { t } = useTranslation();
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
const pct =
inProgress && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
const inProgress = isInProgress(video);
const pct = resumePct(video);
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
@@ -192,27 +275,39 @@ function Thumb({
<button
onClick={open(null)}
title={t("card.continueTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
)}
>
<Play className="w-4 h-4 fill-current" />
{t("card.continue")}
{!small && t("card.continue")}
</button>
<button
onClick={open(0)}
title={t("card.restartTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
aria-label={t("card.restart")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
)}
>
<RotateCcw className="w-4 h-4" />
{t("card.restart")}
{!small && t("card.restart")}
</button>
</>
) : (
<button
onClick={open(null)}
title={t("card.play")}
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
aria-label={t("card.play")}
className={clsx(
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
small ? "w-9 h-9" : "w-12 h-12"
)}
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
<Play className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} />
</button>
)}
</div>
@@ -227,9 +322,49 @@ function Thumb({
);
}
// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.91.3), so the boxes must grow with
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
published: 192, // w-48
} as const;
const ROW_GAP = 12; // gap-3
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing
// What the row must measure before each column earns its place — cumulative, so each appears only
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
published:
ROW_BASE +
ROW_GAP +
ROW_COL.channel +
ROW_GAP +
ROW_COL.meta +
ROW_GAP +
ROW_COL.views +
ROW_GAP +
ROW_COL.published,
};
function VideoCard({
video,
view,
width,
onState,
onResetState,
onToggleSave,
@@ -237,7 +372,9 @@ function VideoCard({
onOpen,
}: {
video: Video;
view: "grid" | "list";
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
@@ -245,33 +382,53 @@ function VideoCard({
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t, i18n } = useTranslation();
const spec = FEED_VIEW_SPEC[view];
const watched = video.status === "watched";
const meta = (
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
// line here rather than let the mode silently lose them.
const noThumb = !spec.thumb;
// Each datum on its own, so the one-line row can give each a column of its own while the stacked
// layouts still run them together as one sentence. The count comes in two shapes from one span —
// one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
// ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
</span>
);
const published = (
<>
{video.view_count != null && (
<>
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
{formatViews(video.view_count)} {t("card.views")}
</span>{" "}
·{" "}
</>
)}
{relativeTime(video.published_at)}
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
</>
);
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
// title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
// repeats what's already legible is noise.
const titleRef = useRef<HTMLAnchorElement>(null);
const channelRef = useRef<HTMLButtonElement>(null);
const [titleClamped, setTitleClamped] = useState(false);
const [channelClamped, setChannelClamped] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (!el) return;
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
const cut = (el: HTMLElement) =>
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
const els: [HTMLElement | null, (v: boolean) => void][] = [
[titleRef.current, setTitleClamped],
[channelRef.current, setChannelClamped],
];
const check = () => els.forEach(([el, set]) => el && set(cut(el)));
check();
const ro = new ResizeObserver(check);
ro.observe(el);
els.forEach(([el]) => el && ro.observe(el));
return () => ro.disconnect();
}, [video.title]);
// `view` matters as much as the text: switching views returns a different branch, so React can
// remount these nodes — without it the observer would be left watching detached ones.
}, [video.title, video.channel_title, view]);
const title = (
<a
ref={titleRef}
@@ -280,61 +437,173 @@ function VideoCard({
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
className={clsx(
"font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2"
)}
>
{video.title}
</a>
);
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
const channelButton = (
<button
ref={channelRef}
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={channelClamped ? video.channel_title ?? undefined : undefined}
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
);
const actions = (
<Actions
video={video}
onState={onState}
onResetState={onResetState}
onToggleSave={onToggleSave}
dense={spec.dense}
/>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
// jumping as the pointer crosses it is a lot more restless than a card doing it.
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
if (spec.family === "row") {
// A table without being one. Every column is a FIXED width, which is what makes the same datum
// land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
// renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
// still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
// Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
// languages, since both have caught me out here:
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 48 (the bare
// "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
// 139) · published 183 ("11 months ago · Feb 28, 2026"; HU is shorter) · channel 128,
// which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
// of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
// still reads while a clipped view count loses the number.
// Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
// whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
// instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
// 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
// cell was a flex row, so its contents slid about — see RowMetaCells).
// The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) {
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. */}
<div className="shrink-0 w-48">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
filter panel are doing. Until the first measure lands (width 0) show everything: the
row is off-screen for that frame anyway, and guessing narrow would flash. */}
<div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && (
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
)}
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}
}
// Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
// data together as one sentence; the one-line row above gives each datum its own column and has
// already returned, so building this for it would be waste dressed up as shared code.
const views = viewsCell(true);
const textBlock = (
<>
{title}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<div className="mt-0.5">{channelButton}</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{published}
</div>
</>
);
if (view === "list") {
if (spec.family === "row") {
return (
<div
className={clsx(
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">{textBlock}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
onOpen={onOpen}
small
/>
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
reserves — which frees the ~168px the docked-right block used, so the block still
works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
already stretches this column to full height), so they land in the same place
whether the title above them ran to one line or three. */}
{spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
</div>
{!spec.actionsBelow && actions}
</div>
);
}
// The flex column is what puts the actions on the card's bottom edge: the grid already stretches
// every card in a row to the tallest one (align-items: stretch), so the column just needs to push
// its last child down. Otherwise the action row floats under a 1-line title on one card and a
// line lower on its 2-line neighbour — the same control landing somewhere different on each.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering.
return (
<div
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
<div className="min-w-0 flex-1">
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
and would spill straight out of it. */}
<Thumb video={video} className="aspect-video" onOpen={onOpen} small={spec.dense} />
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
gap would leave the title barely half the width. The channel name is still below it. */}
{!spec.dense && (
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
<div className="mt-auto pt-1">{actions}</div>
</div>
</div>
</div>
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { useDismiss } from "../lib/useDismiss";
export interface ViewOption<T extends string> {
id: T;
label: string;
icon: LucideIcon;
}
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
// density modes; the managers' layouts) without re-deriving the menu behaviour.
//
// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
export default function ViewSwitcher<T extends string>({
value,
options,
onChange,
label,
labelClassName = "hidden",
}: {
value: T;
options: readonly ViewOption<T>[];
onChange: (v: T) => void;
label: string;
/** Where the current mode's NAME is visible beside the icon — a Tailwind display class, e.g.
* `"hidden xl:inline"`. The caller sets it because only it knows how crowded its own toolbar is:
* the feed's is packed (show chips, content chips, source, count, sort) and can't spare the room
* until xl, while an emptier one could show it always. Defaults to icon-only. */
labelClassName?: string;
}) {
const [open, setOpen] = useState(false);
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
const [focusIdx, setFocusIdx] = useState(0);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
function close({ refocus = true }: { refocus?: boolean } = {}) {
setOpen(false);
if (refocus) btnRef.current?.focus();
}
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
const currentIdx = Math.max(
0,
options.findIndex((o) => o.id === value)
);
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
// would only schedule the update, so the focus effect below would still see the previous index
// and focus the wrong item for one render before correcting.
function openMenu() {
setFocusIdx(currentIdx);
setOpen(true);
}
// Clamped at render, so a caller whose option list shrinks while the menu is open can't leave
// the roving tabindex pointing past the end (which would make every item un-tabbable).
const rovingIdx = Math.min(focusIdx, options.length - 1);
useEffect(() => {
if (open) itemRefs.current[rovingIdx]?.focus();
}, [open, rovingIdx]);
const current = options[currentIdx];
if (!current) return null;
const CurrentIcon = current.icon;
function onMenuKeyDown(e: React.KeyboardEvent) {
const last = options.length - 1;
if (e.key === "Escape") {
// Stop it reaching useDismiss's document listener, which would close without refocusing.
e.stopPropagation();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIdx((i) => (i >= last ? 0 : i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIdx((i) => (i <= 0 ? last : i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setFocusIdx(0);
} else if (e.key === "End") {
e.preventDefault();
setFocusIdx(last);
} else if (e.key === "Tab") {
// Tabbing out of a menu closes it, but the focus is moving on by itself.
close({ refocus: false });
}
}
return (
<div className="relative">
<button
ref={btnRef}
onClick={() => (open ? close() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-30 min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o, i) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={on}
tabIndex={i === rovingIdx ? 0 : -1}
onClick={() => {
onChange(o.id);
close();
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
+64 -39
View File
@@ -1,17 +1,35 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Video } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
// Grid column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
const GRID_MIN_COL = 260;
// Column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
const GRID_GAP = 16;
const LIST_GAP = 4;
// Estimated row heights before measurement; real heights are measured per-row so
// these only affect the very first paint and the scrollbar's initial guess.
const GRID_ROW_EST = 340;
const LIST_ROW_EST = 96;
// Estimated row heights before measurement; real heights are measured per-row so these only affect
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
// when the bare row became a single line and halved (82 -> 46). The card figures are the roughest
// of the set by nature — a card's height follows its column width (measured ~307 at 3 columns,
// ~301 at 4, taller still at 2), so no single constant is right for every window.
const ROW_EST: Record<FeedView, number> = {
cards: 340,
cardsSmall: 257,
rows: 115,
rowsCompact: 46,
tiles: 132,
};
/** Columns that fit `width`, or 1 for the single-column views. */
function columnsFor(view: FeedView, width: number): number {
const { minCol } = FEED_VIEW_SPEC[view];
if (minCol == null) return 1;
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
}
// Start fetching the next page this many rows before the end scrolls into view
// (keeps the old IntersectionObserver's generous prefetch feel).
const PREFETCH_ROWS = 4;
@@ -37,7 +55,7 @@ function getScrollParent(node: HTMLElement | null): HTMLElement {
export interface VirtualFeedProps {
items: Video[];
view: "grid" | "list";
view: FeedView;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel: (channelId: string, channelName: string) => void;
@@ -62,10 +80,18 @@ export default function VirtualFeed({
}: VirtualFeedProps) {
const listRef = useRef<HTMLDivElement>(null);
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
const { minCol, maxCol } = FEED_VIEW_SPEC[view];
const singleCol = minCol == null;
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
// Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0);
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
const [itemWidth, setItemWidth] = useState(0);
useLayoutEffect(() => {
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
@@ -83,10 +109,12 @@ export default function VirtualFeed({
scrollEl.scrollTop;
setScrollMargin(m);
}
setColCount(
view === "list"
? 1
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
const w = node.clientWidth;
const cols = columnsFor(view, w);
setColCount(cols);
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
setItemWidth(
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
);
};
measure();
@@ -101,9 +129,9 @@ export default function VirtualFeed({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
estimateSize: () => ROW_EST[view],
overscan: 4,
gap: view === "grid" ? GRID_GAP : LIST_GAP,
gap: singleCol ? LIST_GAP : GRID_GAP,
scrollMargin,
});
@@ -137,37 +165,34 @@ export default function VirtualFeed({
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
}}
>
{view === "grid" ? (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
>
{row.map((v) => (
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto pb-1">
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
get their reading-width cap instead of grid columns. */}
<div
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
style={
singleCol
? { maxWidth: maxCol ?? undefined }
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
}
>
{row.map((v) => (
<VideoCard
video={row[0]}
view="list"
key={v.id}
video={v}
view={view}
// Only the one-line row reads its own width (it stands columns down as it
// narrows). Everything else takes a stable 0, so a resize — which changes
// itemWidth every frame — doesn't blow past VideoCard's memo for views that
// wouldn't do anything with it.
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
</div>
)}
))}
</div>
</div>
);
})}
+11 -5
View File
@@ -1,3 +1,4 @@
import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
@@ -34,11 +35,15 @@ export function DraftSaveBar({
}) {
if (!dirty && state === "idle") return null;
const saving = state === "saving";
const wrap =
variant === "floating"
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
return (
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
// mask) scopes this z-40 and fades the bar's bottom edge. The inline variant is in normal flow
// and stays put.
const bar = (
<div className={wrap}>
<span className="text-sm text-muted">
{state === "saved" ? (
@@ -72,4 +77,5 @@ export function DraftSaveBar({
</div>
</div>
);
return floating ? createPortal(bar, document.body) : bar;
}
+1
View File
@@ -4,6 +4,7 @@
"watchedUnmark": "Watched — click to unmark",
"markWatched": "Mark watched",
"savedRemove": "Saved — click to remove",
"savedMark": "Saved",
"saveForLater": "Save for later",
"unhide": "Unhide",
"hide": "Hide",
+8
View File
@@ -19,6 +19,14 @@
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"view": {
"cards": "Large cards",
"cardsSmall": "Small cards",
"rows": "Rows",
"rowsCompact": "Compact rows",
"tiles": "Tiles"
},
"dirAsc": "Ascending",
"dirDesc": "Descending",
"sortKey": {
@@ -36,8 +36,6 @@
"backgroundImage": "Background image",
"backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
"backgroundFade": "Image fade",
"listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode",
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
+1
View File
@@ -4,6 +4,7 @@
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
"markWatched": "Megnézettnek jelöl",
"savedRemove": "Mentve — kattints az eltávolításhoz",
"savedMark": "Mentve",
"saveForLater": "Mentés későbbre",
"unhide": "Megjelenítés",
"hide": "Elrejtés",
+8
View File
@@ -19,6 +19,14 @@
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"view": {
"cards": "Nagy kártya",
"cardsSmall": "Kis kártya",
"rows": "Sor",
"rowsCompact": "Tömör sor",
"tiles": "Csempe"
},
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"sortKey": {
@@ -36,8 +36,6 @@
"backgroundImage": "Háttérkép",
"backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
"backgroundFade": "Kép elhalványítása",
"listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód",
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
+70
View File
@@ -0,0 +1,70 @@
// The feed's view vocabulary. Lifted out of Feed (like feedSort.ts) because it has three
// consumers that can't share module-private state: App owns the pref, the toolbar's
// ViewSwitcher offers it, and VirtualFeed/VideoCard render it.
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
export const FEED_VIEWS: readonly FeedView[] = [
"cards",
"cardsSmall",
"rows",
"rowsCompact",
"tiles",
] as const;
const FEED_VIEW_DEFAULT: FeedView = "cards";
export interface FeedViewSpec {
/** Which shape VideoCard renders: a portrait card (thumbnail on top) or a horizontal block. */
family: "card" | "row";
/** Narrowest grid column in px, or null for ONE full-width column. VirtualFeed derives the
* column count from this and the container width, so every multi-column view reflows for free. */
minCol: number | null;
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
maxCol: number | null;
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
thumb: boolean;
/** card family: tighter padding and no channel avatar, for the small card's ~180px column. */
dense: boolean;
/** row family: actions sit under the meta rather than docked at the right edge. Frees ~168px of
* width (measured), which is what lets a row block survive in a narrow tile column. */
actionsBelow: boolean;
}
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
cardsSmall: { family: "card", minCol: 180, maxCol: null, thumb: true, dense: true, actionsBelow: false },
// Single column on purpose: the point of the list is that your eye runs down one edge with the
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
// dead space between them and the meta.
rows: { family: "row", minCol: null, maxCol: 896, thumb: true, dense: false, actionsBelow: false },
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
// the user's request once they saw how much room was left over with the rail and filters hidden.
rowsCompact: { family: "row", minCol: null, maxCol: 1472, thumb: false, dense: false, actionsBelow: false },
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
// floor is the action row's measured 156px plus enough for a title to be worth reading.
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
// channel page (1208px) and the unpinned feed into 3 columns while the filter-pinned feed
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
// halfway, which is the very waste this mode exists to kill.
tiles: { family: "row", minCol: 380, maxCol: null, thumb: true, dense: false, actionsBelow: true },
};
function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
}
/** Coerce a stored pref to a view. Up to 0.44 it was "grid" | "list"; those map forward here so
* nobody's saved choice is lost. Anything else (a corrupt value, a future view rolled back) falls
* back to the default rather than rendering nothing. */
export function parseFeedView(raw: unknown): FeedView {
if (isFeedView(raw)) return raw;
if (raw === "grid") return "cards";
if (raw === "list") return "rows";
return FEED_VIEW_DEFAULT;
}
+5
View File
@@ -17,6 +17,11 @@ export const LS = {
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
feedView: "siftlode.feedView",
channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
channelsTable: "siftlode.channelsTable",
+31 -10
View File
@@ -1,34 +1,55 @@
import { useEffect, useState, type CSSProperties } from "react";
// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with
// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway):
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
export function useScrollFade(fadePx = 20): {
ref: (node: HTMLDivElement | null) => void;
ref: (node: HTMLElement | null) => void;
style: CSSProperties;
} {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const [el, setEl] = useState<HTMLElement | null>(null);
const [fade, setFade] = useState({ up: false, down: false });
useEffect(() => {
if (!el) return;
const update = () =>
setFade({
up: el.scrollTop > 1,
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
});
// Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
// hook sits above the whole page tree, so a new object here would re-render on every frame
// of every scroll. The two booleans only flip at the ends of the range.
const update = () => {
const up = el.scrollTop > 1;
const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down }));
};
update();
el.addEventListener("scroll", update, { passive: true });
// Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in,
// a thread's messages decrypting, a list being filtered — changes neither the scroller's own
// box nor its scrollTop, so without the children nothing would re-evaluate the edges.
const ro = new ResizeObserver(update);
ro.observe(el);
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
if (el.firstElementChild) ro.observe(el.firstElementChild);
for (const child of Array.from(el.children)) ro.observe(child);
// Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires
// again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the
// node observed at mount was the Suspense fallback the real page then replaced. (Same
// lazy-page race that broke BackToTop.) So follow the child list: the records name exactly
// which nodes arrived and left, which keeps this O(1) per change rather than re-observing
// every child of a long list on each append.
const mo = new MutationObserver((records) => {
for (const rec of records) {
for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node);
for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node);
}
update();
});
mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [el]);