diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 9eea1664..34b6682a 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -4,6 +4,7 @@ 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, @@ -169,46 +170,21 @@ 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(); - } + // 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() }); }, - [flushPriority], - ); + 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 @@ -423,15 +399,12 @@ export default function ChannelPage({ )} {ch?.subscribed && !me.is_demo && ( -
+
{t("channels.cols.priority")} bumpPriority(channelId, d)} + onSet={(v) => setPriority(channelId, v)} />
)} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 69b02039..fe388946 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -28,6 +28,7 @@ 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"; @@ -143,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 diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index c78ca683..6d31f480 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -17,9 +17,9 @@ 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, @@ -34,10 +34,10 @@ export default function NavSidebar({ const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); - // 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; + // 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 @@ -198,8 +198,6 @@ export default function NavSidebar({ }; 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.
@@ -213,28 +211,15 @@ export default function NavSidebar({ style={listFade.style} > {userItems.map(renderItem)} - {systemItems.length > 0 && - (slim ? ( - // Collapsed rail: a short, thicker centred rule is the clearest "new section" cue - // when there's no room for a label. -
- ) : ( -
- - {t("nav.adminSection")} - - -
- ))} + {systemItems.length > 0 && ( + // A short, thicker centred rule is the clearest "new section" cue on an icon-only rail. +
+ )} {systemItems.map(renderItem)}
-
+