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:
+22
-13
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
|
import { parseFeedView, type FeedView } from "./lib/feedView";
|
||||||
import {
|
import {
|
||||||
accountKey,
|
accountKey,
|
||||||
getAccountRaw,
|
getAccountRaw,
|
||||||
@@ -53,6 +54,7 @@ import NavSidebar from "./components/NavSidebar";
|
|||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import PlaylistsRail from "./components/PlaylistsRail";
|
import PlaylistsRail from "./components/PlaylistsRail";
|
||||||
import BackToTop from "./components/BackToTop";
|
import BackToTop from "./components/BackToTop";
|
||||||
|
import PageScroller from "./components/PageScroller";
|
||||||
import GlassTuner from "./components/GlassTuner";
|
import GlassTuner from "./components/GlassTuner";
|
||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
import Toaster from "./components/Toaster";
|
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.
|
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
|
||||||
type EditablePrefs = {
|
type EditablePrefs = {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
view: "grid" | "list";
|
|
||||||
performanceMode: boolean;
|
performanceMode: boolean;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
notifications: NotifSettings;
|
notifications: NotifSettings;
|
||||||
@@ -170,14 +171,16 @@ export default function App() {
|
|||||||
const [initialHadUrlFilters] = useState(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search))
|
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).
|
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
||||||
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||||
const [hints, setHints] = useState(() => hintsEnabled());
|
const [hints, setHints] = useState(() => hintsEnabled());
|
||||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||||
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
||||||
theme: loadLocalTheme(),
|
theme: loadLocalTheme(),
|
||||||
view: "grid",
|
|
||||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: hintsEnabled(),
|
hints: hintsEnabled(),
|
||||||
notifications: getNotifSettings(),
|
notifications: getNotifSettings(),
|
||||||
@@ -387,6 +390,11 @@ export default function App() {
|
|||||||
writeAccount(LS.navCollapsed, next);
|
writeAccount(LS.navCollapsed, next);
|
||||||
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function setView(next: FeedView) {
|
||||||
|
setViewState(next);
|
||||||
|
writeAccount(LS.feedView, next);
|
||||||
|
api.savePrefs({ view: next }).catch(() => {});
|
||||||
|
}
|
||||||
function setFilterCollapsed(next: boolean) {
|
function setFilterCollapsed(next: boolean) {
|
||||||
setFilterCollapsedState(next);
|
setFilterCollapsedState(next);
|
||||||
writeAccount(LS.filterCollapsed, next);
|
writeAccount(LS.filterCollapsed, next);
|
||||||
@@ -603,7 +611,6 @@ export default function App() {
|
|||||||
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
||||||
const adopted: EditablePrefs = {
|
const adopted: EditablePrefs = {
|
||||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||||
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
|
|
||||||
performanceMode:
|
performanceMode:
|
||||||
typeof prefs.performanceMode === "boolean"
|
typeof prefs.performanceMode === "boolean"
|
||||||
? prefs.performanceMode
|
? prefs.performanceMode
|
||||||
@@ -615,13 +622,16 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
setThemeState(adopted.theme);
|
setThemeState(adopted.theme);
|
||||||
saveLocalTheme(adopted.theme);
|
saveLocalTheme(adopted.theme);
|
||||||
setView(adopted.view);
|
|
||||||
setPerf(adopted.performanceMode);
|
setPerf(adopted.performanceMode);
|
||||||
setHints(adopted.hints);
|
setHints(adopted.hints);
|
||||||
setNotif(adopted.notifications);
|
setNotif(adopted.notifications);
|
||||||
setSavedPrefs(adopted);
|
setSavedPrefs(adopted);
|
||||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||||
const prefsRec = prefs as Record<string, unknown>;
|
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) => {
|
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
||||||
const raw = prefsRec[PREF_KEY[p]];
|
const raw = prefsRec[PREF_KEY[p]];
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -689,11 +699,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefsDirty =
|
const prefsDirty =
|
||||||
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
|
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
|
||||||
JSON.stringify(savedPrefs);
|
JSON.stringify(savedPrefs);
|
||||||
|
|
||||||
const savePrefs = useCallback(() => {
|
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);
|
window.clearTimeout(saveMsgTimer.current);
|
||||||
setPrefsSaveState("saving");
|
setPrefsSaveState("saving");
|
||||||
api
|
api
|
||||||
@@ -709,12 +719,11 @@ export default function App() {
|
|||||||
setPrefsSaveState("error");
|
setPrefsSaveState("error");
|
||||||
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
||||||
});
|
});
|
||||||
}, [theme, view, perf, hints, notif]);
|
}, [theme, perf, hints, notif]);
|
||||||
|
|
||||||
const discardPrefs = useCallback(() => {
|
const discardPrefs = useCallback(() => {
|
||||||
setThemeState(savedPrefs.theme);
|
setThemeState(savedPrefs.theme);
|
||||||
saveLocalTheme(savedPrefs.theme);
|
saveLocalTheme(savedPrefs.theme);
|
||||||
setView(savedPrefs.view);
|
|
||||||
setPerf(savedPrefs.performanceMode);
|
setPerf(savedPrefs.performanceMode);
|
||||||
setHints(savedPrefs.hints);
|
setHints(savedPrefs.hints);
|
||||||
setNotif(savedPrefs.notifications);
|
setNotif(savedPrefs.notifications);
|
||||||
@@ -725,8 +734,6 @@ export default function App() {
|
|||||||
const prefsCtl: PrefsController = {
|
const prefsCtl: PrefsController = {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
view,
|
|
||||||
setView,
|
|
||||||
perf,
|
perf,
|
||||||
setPerf,
|
setPerf,
|
||||||
hints,
|
hints,
|
||||||
@@ -842,6 +849,7 @@ export default function App() {
|
|||||||
initialName={channelView.name}
|
initialName={channelView.name}
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
onBack={closeChannel}
|
onBack={closeChannel}
|
||||||
onOpenChannel={openChannel}
|
onOpenChannel={openChannel}
|
||||||
onShowInFeed={() => {
|
onShowInFeed={() => {
|
||||||
@@ -891,7 +899,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{meQuery.data!.is_demo && <DemoBanner />}
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
<PageScroller>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels
|
<Channels
|
||||||
@@ -962,6 +970,7 @@ export default function App() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
isDemo={meQuery.data!.is_demo}
|
isDemo={meQuery.data!.is_demo}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
@@ -972,7 +981,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</PageScroller>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
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
|
// 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.
|
// 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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const fade = useScrollFade();
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
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">
|
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||||
{t("playlists.addToPlaylist")}
|
{t("playlists.addToPlaylist")}
|
||||||
</div>
|
</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 && (
|
{lists.length === 0 && !membership.isLoading && (
|
||||||
<div className="text-xs text-muted px-1.5 py-2">
|
<div className="text-xs text-muted px-1.5 py-2">
|
||||||
{t("playlists.noneYet")}
|
{t("playlists.noneYet")}
|
||||||
|
|||||||
@@ -4,29 +4,43 @@ import { ArrowUp } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// A floating "back to top" button that fades in once the current page's scroll container is
|
// 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
|
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
|
||||||
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
|
// <main class="overflow-y-auto"> — the normal pages share App's, the channel page renders its own
|
||||||
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
|
// — so this follows whichever one is mounted. Portaled to <body> so its fixed position is
|
||||||
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
|
// viewport-relative regardless of transformed ancestors.
|
||||||
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
|
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const scrollerRef = useRef<HTMLElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const el = document.querySelector("main");
|
const el = document.querySelector("main");
|
||||||
scrollerRef.current = el;
|
scrollerRef.current = el;
|
||||||
if (!el) {
|
setShow(!!el && el.scrollTop > threshold);
|
||||||
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);
|
|
||||||
}, [dep, 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)
|
// 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"].
|
// 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(
|
return createPortal(
|
||||||
<button
|
<button
|
||||||
onClick={toTop}
|
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"
|
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>
|
<span className="sr-only">{t("common.backToTop")}</span>
|
||||||
</button>,
|
</button>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -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 { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
|
import PageScroller from "./PageScroller";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
|
||||||
// --- About-tab metadata helpers ---
|
// --- About-tab metadata helpers ---
|
||||||
@@ -54,6 +56,7 @@ export default function ChannelPage({
|
|||||||
initialName,
|
initialName,
|
||||||
me,
|
me,
|
||||||
view,
|
view,
|
||||||
|
setView,
|
||||||
onBack,
|
onBack,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
onShowInFeed,
|
onShowInFeed,
|
||||||
@@ -61,7 +64,10 @@ export default function ChannelPage({
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
me: Me;
|
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;
|
onBack: () => void;
|
||||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
// 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
|
// 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";
|
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
|
<PageScroller>
|
||||||
// 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]">
|
|
||||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
<div className="relative px-4 sm:px-6 pt-3">
|
||||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||||
@@ -371,6 +374,7 @@ export default function ChannelPage({
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
canRead={me.can_read}
|
canRead={me.can_read}
|
||||||
isDemo={me.is_demo}
|
isDemo={me.is_demo}
|
||||||
onOpenWizard={() => {}}
|
onOpenWizard={() => {}}
|
||||||
@@ -464,6 +468,6 @@ export default function ChannelPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</PageScroller>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { api, type ManagedChannel, type Tag } from "../lib/api";
|
|||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
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
|
// 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.
|
// otherwise clip it against the scroll container), as long as there's room above.
|
||||||
const [openUp, setOpenUp] = useState(false);
|
const [openUp, setOpenUp] = useState(false);
|
||||||
|
const fade = useScrollFade();
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
useDismiss(open, () => setOpen(false), ref);
|
useDismiss(open, () => setOpen(false), ref);
|
||||||
@@ -723,7 +725,9 @@ function TagsCell({
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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"
|
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
||||||
@@ -28,6 +29,7 @@ export default function ChatThread({
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [plain, setPlain] = useState<Record<number, string>>({});
|
const [plain, setPlain] = useState<Record<number, string>>({});
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fade = useScrollFade();
|
||||||
const ready = useKeyState() === "ready";
|
const ready = useKeyState() === "ready";
|
||||||
const needsKey = !isSystem && !ready;
|
const needsKey = !isSystem && !ready;
|
||||||
|
|
||||||
@@ -132,7 +134,11 @@ export default function ChatThread({
|
|||||||
|
|
||||||
return (
|
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 ? (
|
{items.length === 0 ? (
|
||||||
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
|
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
|
||||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
// 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
|
// 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
|
// 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.
|
// 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> =
|
type ColumnFilter<T> =
|
||||||
| { kind: "text"; get: (row: T) => string }
|
| { kind: "text"; get: (row: T) => string }
|
||||||
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
|
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
|
||||||
@@ -218,7 +235,7 @@ export default function DataTable<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{f.kind === "multi" && (
|
{f.kind === "multi" && (
|
||||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
|
<FilterOptionList>
|
||||||
{f.options.length === 0 && (
|
{f.options.length === 0 && (
|
||||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -249,7 +266,7 @@ export default function DataTable<T>({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</FilterOptionList>
|
||||||
)}
|
)}
|
||||||
{isActive(val) && (
|
{isActive(val) && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
@@ -14,8 +28,10 @@ import {
|
|||||||
SORT_KEYS,
|
SORT_KEYS,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "../lib/feedSort";
|
} from "../lib/feedSort";
|
||||||
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import ViewSwitcher from "./ViewSwitcher";
|
||||||
import VirtualFeed from "./VirtualFeed";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
@@ -32,6 +48,15 @@ const CONTENT = [
|
|||||||
] as const;
|
] as const;
|
||||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
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
|
// 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.
|
// (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.
|
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
|
||||||
@@ -56,6 +81,7 @@ export default function Feed({
|
|||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
view,
|
view,
|
||||||
|
setView,
|
||||||
canRead,
|
canRead,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
@@ -67,7 +93,10 @@ export default function Feed({
|
|||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
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;
|
canRead: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
@@ -621,6 +650,16 @@ export default function Feed({
|
|||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { api, type Video } from "../lib/api";
|
|||||||
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// 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
|
// 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 { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const descFade = useScrollFade();
|
||||||
// Browser/mouse Back closes the player instead of leaving the page.
|
// Browser/mouse Back closes the player instead of leaving the page.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [active.id]);
|
}, [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
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
|
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 ? (
|
{detail.isLoading ? (
|
||||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||||
) : detail.data?.description ? (
|
) : 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, {
|
{renderDescription(detail.data.description, {
|
||||||
currentId: currentVideoId,
|
currentId: currentVideoId,
|
||||||
onSeek: seekTo,
|
onSeek: seekTo,
|
||||||
@@ -938,6 +949,7 @@ export default function PlayerModal({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -226,8 +227,10 @@ export default function PlexBrowse({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub.view.kind === "player") {
|
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 (
|
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} />
|
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, Plus } from "lucide-react";
|
import { Check, Plus } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ export default function PlexCollectionEditor({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const editableFade = useScrollFade();
|
||||||
|
const othersFade = useScrollFade();
|
||||||
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
|
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
@@ -146,7 +149,11 @@ export default function PlexCollectionEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editable collections with in/out toggle */}
|
{/* 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 ? (
|
{listQ.isLoading ? (
|
||||||
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||||
) : shown.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
@@ -197,7 +204,11 @@ export default function PlexCollectionEditor({
|
|||||||
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
|
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
|
||||||
{t("plex.collEditor.others", { count: eligible.length })}
|
{t("plex.collEditor.others", { count: eligible.length })}
|
||||||
</summary>
|
</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) => (
|
{eligible.map((c) => (
|
||||||
<div key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
|
<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>
|
<span className="min-w-0 flex-1 truncate">{c.title}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type WheelEvent as ReactWheelEvent,
|
type WheelEvent as ReactWheelEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
@@ -36,6 +37,7 @@ import { api, type PlexMarker } from "../lib/api";
|
|||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
// 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 menuOpenRef = useRef(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const tracksRef = useRef<HTMLDivElement>(null);
|
const tracksRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
audioRef.current = audioOrd;
|
audioRef.current = audioOrd;
|
||||||
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
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).
|
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||||
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||||
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
|
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.
|
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const helpOpenRef = useRef(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.
|
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
|
||||||
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
|
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
|
<div
|
||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
|
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>
|
</button>
|
||||||
{tracksOpen && (
|
{tracksOpen && (
|
||||||
<div
|
<div
|
||||||
ref={tracksRef}
|
ref={setTracksNode}
|
||||||
|
style={tracksFade.style}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onWheel={(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 && (
|
{detail.audio_streams.length > 1 && (
|
||||||
<>
|
<>
|
||||||
@@ -1606,7 +1625,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, Minus, Plus } from "lucide-react";
|
import { Check, Minus, Plus } from "lucide-react";
|
||||||
import { api, type PlexPlaylist } from "../lib/api";
|
import { api, type PlexPlaylist } from "../lib/api";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import Modal from "./Modal";
|
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
|
// 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.
|
// 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 }) {
|
export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fade = useScrollFade();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -101,7 +103,7 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{listQ.isLoading ? (
|
||||||
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||||
) : playlists.length === 0 ? (
|
) : playlists.length === 0 ? (
|
||||||
|
|||||||
@@ -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 { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export default function PlexSidebar({
|
|||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const collFade = useScrollFade();
|
||||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
const [newPlaylist, setNewPlaylist] = useState("");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -258,7 +260,11 @@ export default function PlexSidebar({
|
|||||||
placeholder={t("plex.filter.collectionSearch")}
|
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"
|
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) => (
|
{collections.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export type PrefsSaveState = SaveState;
|
|||||||
export interface PrefsController {
|
export interface PrefsController {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
setTheme: (t: ThemePrefs) => void;
|
setTheme: (t: ThemePrefs) => void;
|
||||||
view: "grid" | "list";
|
|
||||||
setView: (v: "grid" | "list") => void;
|
|
||||||
perf: boolean;
|
perf: boolean;
|
||||||
setPerf: (v: boolean) => void;
|
setPerf: (v: boolean) => void;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
@@ -128,7 +126,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
// 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).
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -186,13 +184,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<SettingRow
|
||||||
label={t("settings.appearance.performanceMode")}
|
label={t("settings.appearance.performanceMode")}
|
||||||
hint={t("settings.appearance.performanceModeHint")}
|
hint={t("settings.appearance.performanceModeHint")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { api, type Tag } from "../lib/api";
|
import { api, type Tag } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ function TagRow({
|
|||||||
const [name, setName] = useState(tag.name);
|
const [name, setName] = useState(tag.name);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pos, setPos] = useState({ left: 0, top: 0 });
|
const [pos, setPos] = useState({ left: 0, top: 0 });
|
||||||
|
const popFade = useScrollFade();
|
||||||
const countRef = useRef<HTMLSpanElement | null>(null);
|
const countRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const closeTimer = useRef<number | undefined>(undefined);
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
@@ -77,10 +79,11 @@ function TagRow({
|
|||||||
{open &&
|
{open &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={popFade.ref}
|
||||||
onMouseEnter={openPop}
|
onMouseEnter={openPop}
|
||||||
onMouseLeave={closeSoon}
|
onMouseLeave={closeSoon}
|
||||||
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60 }}
|
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60, ...popFade.style }}
|
||||||
className="glass-menu w-56 max-h-72 overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
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">
|
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
|
||||||
{t("tagManager.onChannels")}
|
{t("tagManager.onChannels")}
|
||||||
@@ -111,6 +114,7 @@ export default function TagManager({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const listFade = useScrollFade();
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
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>
|
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
// Cap at ~8 rows tall, then scroll — the list can grow long.
|
// 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) => (
|
{userTags.map((tag) => (
|
||||||
<TagRow
|
<TagRow
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Play,
|
Play,
|
||||||
|
Radio,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
|
||||||
|
Video as VideoIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
function Actions({
|
function Actions({
|
||||||
@@ -21,11 +26,14 @@ function Actions({
|
|||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
|
dense = false,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => 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 { t } = useTranslation();
|
||||||
const act = (status: string) => (e: React.MouseEvent) => {
|
const act = (status: string) => (e: React.MouseEvent) => {
|
||||||
@@ -41,15 +49,24 @@ function Actions({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSave(video.id, !video.saved);
|
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 (
|
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
|
<button
|
||||||
onClick={act("watched")}
|
onClick={act("watched")}
|
||||||
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
|
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
|
||||||
className={clsx(
|
className={clsx(btn, video.status === "watched" && "text-accent")}
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
||||||
video.status === "watched" && "text-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{video.status === "watched" ? (
|
{video.status === "watched" ? (
|
||||||
<CheckCheck className="w-4 h-4" />
|
<CheckCheck className="w-4 h-4" />
|
||||||
@@ -60,22 +77,16 @@ function Actions({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSave}
|
onClick={toggleSave}
|
||||||
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
||||||
className={clsx(
|
className={clsx(btn, video.saved && "fill-current text-accent")}
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
||||||
video.saved && "fill-current text-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<AddToPlaylist videoId={video.id} />
|
<AddToPlaylist videoId={video.id} className={btn} />
|
||||||
<DownloadButton videoId={video.id} title={video.title} />
|
<DownloadButton videoId={video.id} title={video.title} className={btn} />
|
||||||
<button
|
<button
|
||||||
onClick={act("hidden")}
|
onClick={act("hidden")}
|
||||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||||
className={clsx(
|
className={clsx(btn, video.status === "hidden" && "text-accent")}
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
||||||
video.status === "hidden" && "text-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{video.status === "hidden" ? (
|
{video.status === "hidden" ? (
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
@@ -91,7 +102,7 @@ function Actions({
|
|||||||
onResetState(video.id);
|
onResetState(video.id);
|
||||||
}}
|
}}
|
||||||
title={t("card.resetState")}
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -112,23 +123,95 @@ function openInApp(
|
|||||||
onOpen(video);
|
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({
|
function Thumb({
|
||||||
video,
|
video,
|
||||||
className,
|
className,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
small = false,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
className?: string;
|
className?: string;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
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();
|
const { t } = useTranslation();
|
||||||
// A non-zero saved position on an unfinished video = "in progress": show a resume
|
const inProgress = isInProgress(video);
|
||||||
// progress bar and offer Continue / Restart instead of a plain Play.
|
const pct = resumePct(video);
|
||||||
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;
|
|
||||||
|
|
||||||
// Open in the in-app player at an explicit position (null = resume from saved).
|
// Open in the in-app player at an explicit position (null = resume from saved).
|
||||||
const open = (startAt: number | null) => (e: React.MouseEvent) => {
|
const open = (startAt: number | null) => (e: React.MouseEvent) => {
|
||||||
@@ -192,27 +275,39 @@ function Thumb({
|
|||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
onClick={open(null)}
|
||||||
title={t("card.continueTitle")}
|
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" />
|
<Play className="w-4 h-4 fill-current" />
|
||||||
{t("card.continue")}
|
{!small && t("card.continue")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={open(0)}
|
onClick={open(0)}
|
||||||
title={t("card.restartTitle")}
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("card.restart")}
|
{!small && t("card.restart")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
onClick={open(null)}
|
||||||
title={t("card.play")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.9–1.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({
|
function VideoCard({
|
||||||
video,
|
video,
|
||||||
view,
|
view,
|
||||||
|
width,
|
||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
@@ -237,7 +372,9 @@ function VideoCard({
|
|||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
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;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
@@ -245,33 +382,53 @@ function VideoCard({
|
|||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const spec = FEED_VIEW_SPEC[view];
|
||||||
const watched = video.status === "watched";
|
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)}
|
{relativeTime(video.published_at)}
|
||||||
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
{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 titleRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const channelRef = useRef<HTMLButtonElement>(null);
|
||||||
const [titleClamped, setTitleClamped] = useState(false);
|
const [titleClamped, setTitleClamped] = useState(false);
|
||||||
|
const [channelClamped, setChannelClamped] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = titleRef.current;
|
const cut = (el: HTMLElement) =>
|
||||||
if (!el) return;
|
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
||||||
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 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();
|
check();
|
||||||
const ro = new ResizeObserver(check);
|
const ro = new ResizeObserver(check);
|
||||||
ro.observe(el);
|
els.forEach(([el]) => el && ro.observe(el));
|
||||||
return () => ro.disconnect();
|
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 = (
|
const title = (
|
||||||
<a
|
<a
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
@@ -280,61 +437,173 @@ function VideoCard({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
title={titleClamped ? video.title ?? undefined : undefined}
|
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}
|
{video.title}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
|
const channelButton = (
|
||||||
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
|
<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 = (
|
const textBlock = (
|
||||||
<>
|
<>
|
||||||
{title}
|
{title}
|
||||||
<button
|
<div className="mt-0.5">{channelButton}</div>
|
||||||
onClick={(e) => {
|
<div className="text-xs text-muted mt-0.5">
|
||||||
e.stopPropagation();
|
{views}
|
||||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
{views && " · "}
|
||||||
}}
|
{published}
|
||||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
</div>
|
||||||
>
|
|
||||||
{video.channel_title}
|
|
||||||
</button>
|
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (view === "list") {
|
if (spec.family === "row") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
|
||||||
className={clsx(
|
<Thumb
|
||||||
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
video={video}
|
||||||
watched && "opacity-55"
|
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
||||||
)}
|
onOpen={onOpen}
|
||||||
>
|
small
|
||||||
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
/>
|
||||||
<div className="min-w-0 flex-1">{textBlock}</div>
|
<div className="min-w-0 flex-1 flex flex-col">
|
||||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
{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>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
|
||||||
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
|
||||||
<Avatar
|
and would spill straight out of it. */}
|
||||||
src={video.channel_thumbnail}
|
<Thumb video={video} className="aspect-video" onOpen={onOpen} small={spec.dense} />
|
||||||
fallback={video.channel_title ?? ""}
|
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||||
className="w-9 h-9 rounded-full shrink-0 mt-0.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. */}
|
||||||
<div className="min-w-0 flex-1">
|
{!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}
|
{textBlock}
|
||||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
<div className="mt-auto pt-1">{actions}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
|
||||||
// Grid column sizing mirrors the CSS the feed used before virtualization
|
// Column sizing mirrors the CSS the feed used before virtualization
|
||||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
|
||||||
const GRID_MIN_COL = 260;
|
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
|
||||||
const GRID_GAP = 16;
|
const GRID_GAP = 16;
|
||||||
const LIST_GAP = 4;
|
const LIST_GAP = 4;
|
||||||
// Estimated row heights before measurement; real heights are measured per-row so
|
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||||
// these only affect the very first paint and the scrollbar's initial guess.
|
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||||
const GRID_ROW_EST = 340;
|
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||||
const LIST_ROW_EST = 96;
|
// 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
|
// Start fetching the next page this many rows before the end scrolls into view
|
||||||
// (keeps the old IntersectionObserver's generous prefetch feel).
|
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||||
const PREFETCH_ROWS = 4;
|
const PREFETCH_ROWS = 4;
|
||||||
@@ -37,7 +55,7 @@ function getScrollParent(node: HTMLElement | null): HTMLElement {
|
|||||||
|
|
||||||
export interface VirtualFeedProps {
|
export interface VirtualFeedProps {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
view: "grid" | "list";
|
view: FeedView;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpenChannel: (channelId: string, channelName: string) => void;
|
onOpenChannel: (channelId: string, channelName: string) => void;
|
||||||
@@ -62,10 +80,18 @@ export default function VirtualFeed({
|
|||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(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
|
// Distance from the scroll element's content top to where this list starts (the
|
||||||
// feed toolbar sits above it inside the same scroll area).
|
// feed toolbar sits above it inside the same scroll area).
|
||||||
const [scrollMargin, setScrollMargin] = useState(0);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||||
@@ -83,10 +109,12 @@ export default function VirtualFeed({
|
|||||||
scrollEl.scrollTop;
|
scrollEl.scrollTop;
|
||||||
setScrollMargin(m);
|
setScrollMargin(m);
|
||||||
}
|
}
|
||||||
setColCount(
|
const w = node.clientWidth;
|
||||||
view === "list"
|
const cols = columnsFor(view, w);
|
||||||
? 1
|
setColCount(cols);
|
||||||
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
|
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();
|
measure();
|
||||||
@@ -101,9 +129,9 @@ export default function VirtualFeed({
|
|||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
getScrollElement: () => scrollEl,
|
getScrollElement: () => scrollEl,
|
||||||
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
|
estimateSize: () => ROW_EST[view],
|
||||||
overscan: 4,
|
overscan: 4,
|
||||||
gap: view === "grid" ? GRID_GAP : LIST_GAP,
|
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||||
scrollMargin,
|
scrollMargin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,37 +165,34 @@ export default function VirtualFeed({
|
|||||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{view === "grid" ? (
|
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
|
||||||
<div
|
get their reading-width cap instead of grid columns. */}
|
||||||
className="grid gap-4"
|
<div
|
||||||
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
|
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||||
>
|
style={
|
||||||
{row.map((v) => (
|
singleCol
|
||||||
<VideoCard
|
? { maxWidth: maxCol ?? undefined }
|
||||||
key={v.id}
|
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||||
video={v}
|
}
|
||||||
view="grid"
|
>
|
||||||
onState={onState}
|
{row.map((v) => (
|
||||||
onToggleSave={onToggleSave}
|
|
||||||
onOpenChannel={onOpenChannel}
|
|
||||||
onResetState={onResetState}
|
|
||||||
onOpen={onOpen}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-4xl mx-auto pb-1">
|
|
||||||
<VideoCard
|
<VideoCard
|
||||||
video={row[0]}
|
key={v.id}
|
||||||
view="list"
|
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}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Check, RotateCcw, Save } from "lucide-react";
|
import { Check, RotateCcw, Save } from "lucide-react";
|
||||||
|
|
||||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
// 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;
|
if (!dirty && state === "idle") return null;
|
||||||
const saving = state === "saving";
|
const saving = state === "saving";
|
||||||
const wrap =
|
const floating = variant === "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))]"
|
? "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";
|
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
|
||||||
return (
|
// 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}>
|
<div className={wrap}>
|
||||||
<span className="text-sm text-muted">
|
<span className="text-sm text-muted">
|
||||||
{state === "saved" ? (
|
{state === "saved" ? (
|
||||||
@@ -72,4 +77,5 @@ export function DraftSaveBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
return floating ? createPortal(bar, document.body) : bar;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"watchedUnmark": "Watched — click to unmark",
|
"watchedUnmark": "Watched — click to unmark",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"savedRemove": "Saved — click to remove",
|
"savedRemove": "Saved — click to remove",
|
||||||
|
"savedMark": "Saved",
|
||||||
"saveForLater": "Save for later",
|
"saveForLater": "Save for later",
|
||||||
"unhide": "Unhide",
|
"unhide": "Unhide",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
"videoCount_one": "{{formattedCount}} video",
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
"videoCount_other": "{{formattedCount}} videos",
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
"sortLabel": "Sort",
|
"sortLabel": "Sort",
|
||||||
|
"viewLabel": "View",
|
||||||
|
"view": {
|
||||||
|
"cards": "Large cards",
|
||||||
|
"cardsSmall": "Small cards",
|
||||||
|
"rows": "Rows",
|
||||||
|
"rowsCompact": "Compact rows",
|
||||||
|
"tiles": "Tiles"
|
||||||
|
},
|
||||||
"dirAsc": "Ascending",
|
"dirAsc": "Ascending",
|
||||||
"dirDesc": "Descending",
|
"dirDesc": "Descending",
|
||||||
"sortKey": {
|
"sortKey": {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
"backgroundImage": "Background image",
|
"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.",
|
"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",
|
"backgroundFade": "Image fade",
|
||||||
"listView": "List view",
|
|
||||||
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
|
||||||
"performanceMode": "Performance mode",
|
"performanceMode": "Performance mode",
|
||||||
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
||||||
"showHints": "Show hints",
|
"showHints": "Show hints",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
|
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"savedRemove": "Mentve — kattints az eltávolításhoz",
|
"savedRemove": "Mentve — kattints az eltávolításhoz",
|
||||||
|
"savedMark": "Mentve",
|
||||||
"saveForLater": "Mentés későbbre",
|
"saveForLater": "Mentés későbbre",
|
||||||
"unhide": "Megjelenítés",
|
"unhide": "Megjelenítés",
|
||||||
"hide": "Elrejtés",
|
"hide": "Elrejtés",
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
"videoCount_one": "{{formattedCount}} videó",
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
"videoCount_other": "{{formattedCount}} videó",
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
"sortLabel": "Rendezés",
|
"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ő",
|
"dirAsc": "Növekvő",
|
||||||
"dirDesc": "Csökkenő",
|
"dirDesc": "Csökkenő",
|
||||||
"sortKey": {
|
"sortKey": {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
"backgroundImage": "Háttérkép",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"showHints": "Tippek megjelenítése",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ export const LS = {
|
|||||||
defaultViewFilters: "siftlode.defaultViewFilters",
|
defaultViewFilters: "siftlode.defaultViewFilters",
|
||||||
page: "siftlode.page",
|
page: "siftlode.page",
|
||||||
perfMode: "siftlode.perfMode",
|
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",
|
channelFilter: "siftlode.channelFilter",
|
||||||
channelsView: "siftlode.channelsView",
|
channelsView: "siftlode.channelsView",
|
||||||
channelsTable: "siftlode.channelsTable",
|
channelsTable: "siftlode.channelsTable",
|
||||||
|
|||||||
@@ -1,34 +1,55 @@
|
|||||||
import { useEffect, useState, type CSSProperties } from "react";
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
|
||||||
export function useScrollFade(fadePx = 20): {
|
export function useScrollFade(fadePx = 20): {
|
||||||
ref: (node: HTMLDivElement | null) => void;
|
ref: (node: HTMLElement | null) => void;
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
} {
|
} {
|
||||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||||
const [fade, setFade] = useState({ up: false, down: false });
|
const [fade, setFade] = useState({ up: false, down: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const update = () =>
|
// Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
|
||||||
setFade({
|
// hook sits above the whole page tree, so a new object here would re-render on every frame
|
||||||
up: el.scrollTop > 1,
|
// of every scroll. The two booleans only flip at the ends of the range.
|
||||||
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
|
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();
|
update();
|
||||||
el.addEventListener("scroll", update, { passive: true });
|
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);
|
const ro = new ResizeObserver(update);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
|
for (const child of Array.from(el.children)) ro.observe(child);
|
||||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
// 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 () => {
|
return () => {
|
||||||
el.removeEventListener("scroll", update);
|
el.removeEventListener("scroll", update);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
};
|
};
|
||||||
}, [el]);
|
}, [el]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user