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