diff --git a/VERSION b/VERSION index 8576367b..0b094550 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.60.10 +0.61.0 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..34b6682a 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -3,6 +3,8 @@ 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 { useChannelPriority } from "../lib/useChannelPriority"; import { ArrowLeft, Ban, @@ -168,6 +170,22 @@ export default function ChannelPage({ onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), }); + // Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared + // hook so the two can't drift). Optimistic on this channel's detail cache; a success re-sorts the + // manager list + feed, a failure reverts by refetching the detail. + const { setPriority, bumpPriority } = useChannelPriority({ + read: (id) => qc.getQueryData(qk.channel(id))?.priority, + optimistic: (id, next) => + qc.setQueryData(qk.channel(id), (old) => + old ? { ...old, priority: next } : old, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.feed() }); + }, + onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), + }); + // 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 +398,16 @@ export default function ChannelPage({ )} + {ch?.subscribed && !me.is_demo && ( +
+ {t("channels.cols.priority")} + bumpPriority(channelId, d)} + onSet={(v) => setPriority(channelId, v)} + /> +
+ )} {/* 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 +424,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..fe388946 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,11 +22,13 @@ 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"; import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; +import { useChannelPriority } from "../lib/useChannelPriority"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; @@ -144,56 +144,19 @@ export default function Channels() { qc.invalidateQueries({ queryKey: qk.feedCount() }); }, }); - // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list - // doesn't jump (the server list is priority-sorted; refetching would reorder rows and - // lose your scroll position). The new order takes effect next time the page loads. - // Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the - // accumulated value from the cache, and the server call flushes once ~400ms after the last step - // (instead of one request per notch). Pending flushes are fired on unmount so a last-moment bump - // isn't lost. - const prioFlush = useRef>>(new Map()); - const flushPriority = (id: string) => { - const latest = qc - .getQueryData(qk.channels()) - ?.find((c) => c.id === id)?.priority; - if (latest == null) return; - api - .updateChannel(id, { priority: latest }) - .catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); - }; - // Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit), - // optimistically + a debounced flush. - const setPriority = (id: string, next: number) => { - qc.setQueryData(qk.channels(), (old) => - (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), - ); - const timers = prioFlush.current; - const pending = timers.get(id); - if (pending) clearTimeout(pending); - timers.set( - id, - setTimeout(() => { - timers.delete(id); - flushPriority(id); - }, 400), - ); - }; - const bumpPriority = (id: string, delta: number) => { - const cur = - qc.getQueryData(qk.channels())?.find((c) => c.id === id)?.priority ?? 0; - setPriority(id, cur + delta); - }; - useEffect(() => { - const timers = prioFlush.current; - return () => { - timers.forEach((timer, id) => { - clearTimeout(timer); - flushPriority(id); - }); - timers.clear(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't + // jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll + // position). The new order takes effect next time the page loads. Press-and-hold is debounced + + // flushed on unmount by the shared hook (same flow the channel detail page uses). + const { setPriority, bumpPriority } = useChannelPriority({ + read: (id) => + qc.getQueryData(qk.channels())?.find((c) => c.id === id)?.priority, + optimistic: (id, next) => + qc.setQueryData(qk.channels(), (old) => + (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), + ), + onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), + }); // Tagging is updated optimistically in place (no refetch of the whole channel list, which // made the toggle feel ~2s slow); only revert by refetching if the server call fails. The @@ -332,8 +295,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 +778,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..6d31f480 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,42 +11,33 @@ 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"; -// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The -// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes -// a thin icon-only rail. Account actions sit at the bottom in a popover. +// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar +// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account +// actions sit at the bottom in a popover. export default function NavSidebar({ me, 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 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 or + // keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the + // rail ever growing. 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,102 +169,57 @@ 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. + // The slot is exactly the card's width so the filter tab/bar docks flush against its right edge. +