From 0ed58e3cb15853b883b4ef833ddd6174d09c9c7b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 17 Jul 2026 03:14:48 +0200 Subject: [PATCH] improvement(scroll): fade every capped list's clipped edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep's own three (the thread, the Plex collections filter, the tag manager) plus the seven the spec missed — the playlist picker, the row tag menu, the Plex playlist/collection dialogs, the player's track menu, the video description and the table's multi-select filter. Same idiom throughout: a height-capped list that silently clipped its content now says so, and hides its scrollbar like the rail and the panels already do. The table's option list is its own module-level component because it owns a hook: FilterPopover is redeclared per DataTable render, so React remounts it. --- frontend/src/components/AddToPlaylist.tsx | 4 +++- frontend/src/components/Channels.tsx | 6 +++++- frontend/src/components/ChatThread.tsx | 8 ++++++- frontend/src/components/DataTable.tsx | 21 +++++++++++++++++-- frontend/src/components/PlayerModal.tsx | 8 ++++++- .../src/components/PlexCollectionEditor.tsx | 15 +++++++++++-- frontend/src/components/PlexPlayer.tsx | 19 ++++++++++++++--- frontend/src/components/PlexPlaylistAdd.tsx | 4 +++- frontend/src/components/PlexSidebar.tsx | 8 ++++++- frontend/src/components/TagManager.tsx | 14 ++++++++++--- 10 files changed, 91 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index a3b174e..cdb2405 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -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(null); const panelRef = useRef(null); const [open, setOpen] = useState(false); @@ -134,7 +136,7 @@ export default function AddToPlaylist({
{t("playlists.addToPlaylist")}
-
+
{lists.length === 0 && !membership.isLoading && (
{t("playlists.noneYet")} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b405605..89ea1d6 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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(null); const btnRef = useRef(null); useDismiss(open, () => setOpen(false), ref); @@ -723,7 +725,9 @@ function TagsCell({ {open && (
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index c577740..7b246bd 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -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>({}); const bottomRef = useRef(null); + const fade = useScrollFade(); const ready = useKeyState() === "ready"; const needsKey = !isSystem && !ready; @@ -132,7 +134,11 @@ export default function ChatThread({ return ( <> -
+
{items.length === 0 ? (
{t("messages.threadEmpty")}
) : ( diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 88b4d67..f08bb64 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -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 ( +
+ {children} +
+ ); +} + type ColumnFilter = | { 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({
)} {f.kind === "multi" && ( -
+ {f.options.length === 0 && ( {t("datatable.noOptions")} )} @@ -249,7 +266,7 @@ export default function DataTable({ ); })} -
+ )} {isActive(val) && (
) : detail.data?.description ? ( -
+
{renderDescription(detail.data.description, { currentId: currentVideoId, onSeek: seekTo, diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index fe657e6..6831554 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -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>(() => 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 */} -
+
{listQ.isLoading ? (

{t("plex.loading")}

) : shown.length === 0 ? ( @@ -197,7 +204,11 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.others", { count: eligible.length })} -
+
{eligible.map((c) => (
{c.title} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index dd98ae7..317c1e0 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -36,6 +36,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 +246,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const menuOpenRef = useRef(false); const menuRef = useRef(null); const menuTriggerRef = useRef(null); - const tracksRef = useRef(null); + const tracksRef = useRef(null); const tracksTriggerRef = useRef(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); @@ -1222,10 +1234,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {tracksOpen && (
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 && ( <> diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index fc6c629..5e28988 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -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
-
+
{listQ.isLoading ? (

{t("plex.loading")}

) : playlists.length === 0 ? ( diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 40c0117..7cbbc52 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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" /> -
+
{collections.map((c) => (