From 9d7a2a75d89587845b85c946ae7dc453faa1d7ea Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 02:20:52 +0200 Subject: [PATCH] feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap). 1B Extract the manager's press-and-hold priority stepper into a shared PriorityStepper and add it to the channel detail header (subscribed, non-demo); backend channel_detail now returns the per-subscription `priority`. 2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same glass-card 3D depth + accent hover border as the feed cards (shadow-md is a transparent no-op here, so it's glass-card that provides the depth); clamp Plex card titles to a fixed 2-line height so 1- vs 2-line titles align. 3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the navCollapsed pref plumbing. 4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole full-height slot next to the rail), so approaching the nav no longer pops the filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out grace period + split hover/focus handler groups. 5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the transparent app) instead of only the inset
box. 6 Plex season binge continues across seasons: the season view's play queue now spans the whole show (allKeys), so a season's last episode rolls into the next season's first (seasonKeys stays for "add season to playlist"). 7 When a Plex video/episode ends with nothing queued next, the player returns to the previous page (onEnded → onClose) instead of stranding a black screen. --- backend/app/routes/channels.py | 5 + frontend/src/App.tsx | 15 -- frontend/src/components/ChannelPage.tsx | 57 ++++++- frontend/src/components/Channels.tsx | 164 +------------------- frontend/src/components/NavSidebar.tsx | 146 ++++++----------- frontend/src/components/PlexBrowse.tsx | 24 +-- frontend/src/components/PlexPlayer.tsx | 5 +- frontend/src/components/PriorityStepper.tsx | 162 +++++++++++++++++++ frontend/src/components/SidePanel.tsx | 12 +- frontend/src/components/Tooltip.tsx | 21 ++- frontend/src/components/VideoCard.tsx | 4 +- frontend/src/lib/api/channels.ts | 1 + frontend/src/lib/plexDetailUi.tsx | 41 ++--- frontend/src/lib/useHoverFocusWithin.ts | 67 ++++---- 14 files changed, 376 insertions(+), 348 deletions(-) create mode 100644 frontend/src/components/PriorityStepper.tsx diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 2bb2c1ba..f3626d55 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -230,6 +230,7 @@ def _channel_detail_dict( blocked: bool, stored: int, deep_requested: bool, + priority: int, tag_ids: list[int], ) -> dict: return { @@ -255,6 +256,9 @@ def _channel_detail_dict( # Deep-backfill state, so the channel page can offer "get full history" when the catalog # holds only the recent-backfill/RSS subset (video_count is YouTube's raw total). "deep_requested": deep_requested, + # This user's per-subscription feed priority (0 when not subscribed) so the channel page can + # offer the same priority stepper as the manager. Editable only while subscribed. + "priority": priority, "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, @@ -315,6 +319,7 @@ def channel_detail( blocked=blocked, stored=int(stored), deep_requested=bool(sub.deep_requested) if sub else False, + priority=int(sub.priority) if sub else 0, tag_ids=tag_ids, ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e792daa6..0475ee4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -92,10 +92,6 @@ export default function App() { // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the // user's preferences so it follows the account across devices; a localStorage cache seeds the // initial render synchronously so nothing flashes open before the server prefs arrive. - // Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice. - const [navCollapsed, setNavCollapsedState] = useState(() => - Boolean(readAccount(LS.navCollapsed, true)), - ); // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. const [filterCollapsed, setFilterCollapsedState] = useState(() => Boolean(readAccount(LS.filterCollapsed, true)), @@ -104,11 +100,6 @@ export default function App() { const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => Boolean(readAccount(LS.playlistsCollapsed, true)), ); - function setNavCollapsed(next: boolean) { - setNavCollapsedState(next); - writeAccount(LS.navCollapsed, next); - api.savePrefs({ navCollapsed: next }).catch(() => {}); - } function setFilterCollapsed(next: boolean) { setFilterCollapsedState(next); writeAccount(LS.filterCollapsed, next); @@ -236,10 +227,6 @@ export default function App() { saveLayoutLocal(p, l); } }); - if (typeof prefs.navCollapsed === "boolean") { - setNavCollapsedState(prefs.navCollapsed); - writeAccount(LS.navCollapsed, prefs.navCollapsed); - } if (typeof prefs.filterCollapsed === "boolean") { setFilterCollapsedState(prefs.filterCollapsed); writeAccount(LS.filterCollapsed, prefs.filterCollapsed); @@ -346,8 +333,6 @@ export default function App() { onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} - collapsed={navCollapsed} - onToggleCollapse={() => setNavCollapsed(!navCollapsed)} /> {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. A floating collapsible column beside the nav rail; hidden while a channel page overlays the diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index ef4dac60..9eea1664 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { useNavigationActions } from "./NavigationProvider"; +import { PriorityStepper } from "./PriorityStepper"; import { ArrowLeft, Ban, @@ -168,6 +169,47 @@ export default function ChannelPage({ onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), }); + // Feed priority, editable here with the same stepper the manager uses. Optimistic on this channel's + // detail cache + a debounced flush (~400ms after the last notch) so a press-and-hold sends ONE + // request, not one per step; the pending flush fires on unmount so a last bump isn't lost. + const prioFlush = useRef | null>(null); + const flushPriority = useCallback(() => { + const latest = qc.getQueryData(qk.channel(channelId))?.priority; + if (latest == null) return; + api + .updateChannel(channelId, { priority: latest }) + .then(() => { + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.feed() }); + }) + .catch(() => qc.invalidateQueries({ queryKey: qk.channel(channelId) })); + }, [qc, channelId]); + const setPriority = (next: number) => { + qc.setQueryData(qk.channel(channelId), (old) => + old ? { ...old, priority: next } : old, + ); + if (prioFlush.current) clearTimeout(prioFlush.current); + prioFlush.current = setTimeout(() => { + prioFlush.current = null; + flushPriority(); + }, 400); + }; + // Read the LATEST value from the cache each step (not a render closure) so a hold-repeat accumulates + // correctly, exactly like the manager's arrows. + const bumpPriority = (delta: number) => { + const cur = qc.getQueryData(qk.channel(channelId))?.priority ?? 0; + setPriority(cur + delta); + }; + useEffect( + () => () => { + if (prioFlush.current) { + clearTimeout(prioFlush.current); + flushPriority(); + } + }, + [flushPriority], + ); + // Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic // on this channel's detail cache; a failed attach/detach reverts by refetching, and a success // invalidates the manager list so a tag added here shows there too. @@ -380,6 +422,19 @@ export default function ChannelPage({ )} + {ch?.subscribed && !me.is_demo && ( +
+ {t("channels.cols.priority")} + +
+ )} {/* Only the recent subset is stored → offer to pull the channel's full back-catalogue. Once queued (deep_requested) it shows as loading; hidden once the backfill is done. */} {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( @@ -396,7 +451,7 @@ export default function ChannelPage({ ) : ( )} - + {ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 4068bb96..69b02039 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,10 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { - ArrowDown, - ArrowUp, Ban, Check, Eye, @@ -24,6 +22,7 @@ import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; +import { PriorityStepper } from "./PriorityStepper"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; @@ -332,8 +331,8 @@ export default function Channels() { sortValue: (c) => c.priority, filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, render: (c) => ( - bumpPriority(c.id, d)} onSet={(v) => setPriority(c.id, v)} /> @@ -815,161 +814,6 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str ); } -// Module-level (NOT defined inside PriorityCell) so its identity is stable across PriorityCell -// re-renders — otherwise every hold-repeat tick would remount the button and drop the pointer -// capture set on pointerdown, letting a hold run away if the pointer drifts off the arrow. -function PriorityStepButton({ - delta, - Icon, - label, - onHoldStart, - onStop, - onTap, -}: { - delta: number; - Icon: typeof ArrowUp; - label: string; - onHoldStart: (delta: number) => void; - onStop: () => void; - onTap: (delta: number) => void; -}) { - return ( - - ); -} - -function PriorityCell({ - c, - onPriority, - onSet, -}: { - c: ManagedChannel; - onPriority: (delta: number) => void; - onSet: (value: number) => void; -}) { - const { t } = useTranslation(); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(""); - const holdDelay = useRef | null>(null); - const holdRepeat = useRef | null>(null); - const suppressClick = useRef(false); - - const stop = useCallback(() => { - if (holdDelay.current) clearTimeout(holdDelay.current); - if (holdRepeat.current) clearTimeout(holdRepeat.current); - holdDelay.current = holdRepeat.current = null; - }, []); - useEffect(() => stop, [stop]); // clear any running timers on unmount - - // Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so - // a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it - // falls through to onClick — which also keeps the keyboard path (Enter/Space) working. - const start = (delta: number) => { - suppressClick.current = false; - holdDelay.current = setTimeout(() => { - suppressClick.current = true; - let rate = 140; - const tick = () => { - onPriority(delta); - rate = Math.max(40, rate - 12); - holdRepeat.current = setTimeout(tick, rate); - }; - tick(); - }, 350); - }; - const onTap = (delta: number) => { - if (suppressClick.current) { - suppressClick.current = false; - return; - } - onPriority(delta); - }; - - const commitEdit = () => { - const parsed = parseInt(draft, 10); - if (!Number.isNaN(parsed) && parsed !== c.priority) onSet(parsed); - setEditing(false); - }; - const beginEdit = () => { - setDraft(String(c.priority)); - setEditing(true); - }; - - // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the - // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value - // opens an inline edit to type any integer directly (negatives allowed). - return ( - -
- {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely - positioned OVER it (out of flow) so opening the editor never changes the row height/width — - it just overlays, extending left for a longer number. */} - - - {editing && ( - e.currentTarget.select()} - onChange={(e) => setDraft(e.target.value)} - onBlur={commitEdit} - onKeyDown={(e) => { - if (e.key === "Enter") commitEdit(); - else if (e.key === "Escape") setEditing(false); - }} - aria-label={t("channels.row.priorityHint")} - className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" - /> - )} - - - - - -
-
- ); -} - // Status only — the backfill *action* lives in the Actions column. When both recent and // full history are in, collapse to a single "fully synced" chip; otherwise show what's // present plus the missing/queued state. diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0f9642b7..a2e5d286 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -3,16 +3,7 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; -import { - Info, - LogOut, - Pin, - PinOff, - Settings, - Shield, - UserPlus, - type LucideIcon, -} from "lucide-react"; +import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; @@ -20,9 +11,9 @@ import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; -import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; +import Tooltip from "./Tooltip"; import LanguageSwitcher from "./LanguageSwitcher"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; @@ -34,28 +25,19 @@ export default function NavSidebar({ onOpenAbout, onChangeLanguage, language, - collapsed, - onToggleCollapse, }: { me: Me; onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; - // Collapse state is owned by App (persisted to the user's preferences); the rail just reflects - // it and asks App to flip it. - collapsed: boolean; - onToggleCollapse: () => void; }) { const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); - // When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover - // or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before. - // `collapsed` (persisted per-account) now means "unpinned". - const peek = useHoverFocusWithin(); - const pinned = !collapsed; - const expanded = pinned || peek.active; - const slim = !expanded; // visual: icon-only rail + // The rail is ALWAYS slim: a thin icon-only column flush against the left edge. There is no + // expanded/pinned mode anymore — instead each icon reveals a floating name label on hover (a + // rest-to-show flyout via Tooltip), so the names are one glance away without the rail ever growing. + const slim = true; const [acctOpen, setAcctOpen] = useState(false); // The account panel holds mixed content with its own tab order (profile, account switches, two // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose @@ -187,74 +169,43 @@ export default function NavSidebar({ const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg"; const show = !!badge && badge > 0; return ( - + + ); }; return ( // Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below) // overlays content instead of pushing it. Pinned reserves the full width (in-flow). -
+ // Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows. +