release: 0.61.0 (fixpack-cards-nav-plex)

This commit is contained in:
2026-08-11 03:37:57 +02:00
18 changed files with 461 additions and 404 deletions
+1 -1
View File
@@ -1 +1 @@
0.60.10
0.61.0
+5
View File
@@ -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,
)
-15
View File
@@ -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>(() =>
Boolean(readAccount(LS.navCollapsed, true)),
);
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, true)),
@@ -104,11 +100,6 @@ export default function App() {
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
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
+29 -1
View File
@@ -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<ChannelDetail>(qk.channel(id))?.priority,
optimistic: (id, next) =>
qc.setQueryData<ChannelDetail>(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({
<SlidersHorizontal className="w-4 h-4" />
</button>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
/>
</div>
)}
{/* 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({
) : (
<History className="w-4 h-4" />
)}
<span className="hidden sm:inline">
<span className="hidden sm:inline whitespace-nowrap">
{ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")}
</span>
</button>
+18 -210
View File
@@ -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<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const flushPriority = (id: string) => {
const latest = qc
.getQueryData<ManagedChannel[]>(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<ManagedChannel[]>(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<ManagedChannel[]>(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<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority,
optimistic: (id, next) =>
qc.setQueryData<ManagedChannel[]>(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) => (
<PriorityCell
c={c}
<PriorityStepper
priority={c.priority}
onPriority={(d) => 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 (
<button
onPointerDown={(e) => {
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
}
onHoldStart(delta);
}}
onPointerUp={onStop}
onPointerCancel={onStop}
onClick={() => onTap(delta)}
className="text-muted hover:text-accent leading-none"
aria-label={label}
>
<Icon className="w-3.5 h-3.5" />
</button>
);
}
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<ReturnType<typeof setTimeout> | null>(null);
const holdRepeat = useRef<ReturnType<typeof setTimeout> | 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 (
<Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex items-center gap-1.5 cursor-help">
{/* 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. */}
<span className="relative inline-block w-6 h-5 text-right leading-5">
<button
onClick={beginEdit}
aria-label={t("channels.row.editPriority")}
title={t("channels.row.editPriority")}
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
>
{c.priority}
</button>
{editing && (
<input
type="number"
value={draft}
autoFocus
onFocus={(e) => 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"
/>
)}
</span>
<span className="inline-flex flex-col">
<PriorityStepButton
delta={1}
Icon={ArrowUp}
label={t("channels.row.raisePriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
<PriorityStepButton
delta={-1}
Icon={ArrowDown}
label={t("channels.row.lowerPriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
</span>
</div>
</Tooltip>
);
}
// 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.
+56 -122
View File
@@ -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 (
<button
key={p}
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
title={slim ? label : undefined}
aria-current={active ? "page" : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative is-current`}
>
<span className="relative shrink-0">
<Icon className="w-4.5 h-4.5" />
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring
matches the row background so it reads cleanly whether the row is active or not. */}
{show && slim && (
<span
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
{!slim && <span className="truncate">{label}</span>}
{show && !slim && (
<span
className={`ml-auto min-w-4.5 h-4.5 px-1.5 rounded-full text-2xs font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
>
{badge > 99 ? "99+" : badge}
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
<button
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
aria-label={label}
aria-current={active ? "page" : undefined}
className={`${rowBase} justify-center px-0 relative is-current`}
>
<span className="relative shrink-0">
<Icon className="w-4.5 h-4.5" />
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
background so it reads cleanly whether the row is active or not. */}
{show && (
<span
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
)}
</button>
</button>
</Tooltip>
);
};
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).
<div className={`relative shrink-0 transition-[width] ${pinned ? "w-58" : "w-20"}`}>
// 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.
<div className="relative shrink-0 w-14">
<nav
// Peek handlers sit on the CARD, not the slot: the slot is wider than the card by its
// margin, and a hover zone out there would fire from the dead space next to the rail —
// leaving the side panel to the left would pop the rail open with nothing under the cursor.
{...peek.handlers}
className={`glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-14 px-2" : "w-52 px-3"
}`}
className="glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 my-3 w-14 px-2"
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
</button>
)}
<button
onClick={onToggleCollapse}
title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
</div>
<div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
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.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
) : (
<div className="mt-4 mb-1 px-2.5 flex items-center gap-2">
<span className="text-3xs font-semibold uppercase tracking-wider text-muted/70">
{t("nav.adminSection")}
</span>
<span className="flex-1 border-t border-border" />
</div>
))}
{systemItems.length > 0 && (
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
)}
{systemItems.map(renderItem)}
</div>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
slim ? "flex-col" : "flex-row"
}`}
>
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
onClick={onOpenAbout}
@@ -301,23 +238,25 @@ export default function NavSidebar({
<LogOut className="w-5 h-5" />
</button>
</div>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
aria-label={t("header.account.settings")}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} justify-center px-0 is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
</button>
</Tooltip>
<div>
<button
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
title={`${name} · ${roleLabel}`}
aria-label={`${name} · ${roleLabel}`}
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`}
>
<span className="relative shrink-0">
<AvatarImg
@@ -326,11 +265,6 @@ export default function NavSidebar({
className="w-5.5 h-5.5 rounded-full text-3xs"
/>
</span>
{!slim && (
<span title={roleLabel} className="truncate flex-1 text-left">
{name}
</span>
)}
</button>
<PopoverSurface
+15 -9
View File
@@ -473,7 +473,7 @@ function PlexPosterCard({
onOpen();
}
}}
className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={card.thumb}
@@ -566,7 +566,7 @@ function PlexPosterCard({
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
@@ -710,7 +710,7 @@ function SeasonCard({
onOpen();
}
}}
className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={se.thumb}
@@ -849,7 +849,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
<div className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border">
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
<img
src={r.thumb}
alt=""
@@ -862,7 +862,9 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{r.title}</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
{r.title}
</div>
</button>
))}
</div>
@@ -1240,6 +1242,10 @@ function PlexSeasonView({
const [busy, setBusy] = useState(false);
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
// straight into the next season's first — the same cross-season order the show page already uses.
// seasonKeys stays only for the season-scoped "add to playlist" action.
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
const watched = se?.status === "watched";
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
useArtBackdrop(d?.show.art, artBg);
@@ -1282,7 +1288,7 @@ function PlexSeasonView({
/>
</div>
<button
onClick={() => se.resume && onPlay(se.resume.id, seasonKeys)}
onClick={() => se.resume && onPlay(se.resume.id, allKeys)}
disabled={!se.resume}
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
@@ -1313,7 +1319,7 @@ function PlexSeasonView({
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, seasonKeys)}
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
@@ -1321,7 +1327,7 @@ function PlexSeasonView({
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, seasonKeys)}
onClick={() => onPlay(se.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
@@ -1358,7 +1364,7 @@ function PlexSeasonView({
<EpisodeCard
key={ep.id}
ep={ep}
onPlay={() => onPlay(ep.id, seasonKeys)}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
+4 -1
View File
@@ -767,10 +767,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
if (nextId) go(nextId);
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
// instead of stranding a black, ended <video> on screen.
else onClose();
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go]);
}, [id, nextId, go, onClose]);
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
const wake = useCallback(() => {
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import Tooltip from "./Tooltip";
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 (
<button
onPointerDown={(e) => {
// Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
}
onHoldStart(delta);
}}
onPointerUp={onStop}
onPointerCancel={onStop}
onClick={() => onTap(delta)}
className="text-muted hover:text-accent leading-none"
aria-label={label}
>
<Icon className="w-3.5 h-3.5" />
</button>
);
}
/**
* A press-and-hold-repeat priority control with an inline-editable value (negatives allowed).
* Shared by the channel manager table (a cell) and the channel detail page (a header control), so
* the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold
* auto-repeat), `onSet(value)` writes a typed-in absolute value.
*/
export function PriorityStepper({
priority,
onPriority,
onSet,
}: {
priority: number;
onPriority: (delta: number) => void;
onSet: (value: number) => void;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdRepeat = useRef<ReturnType<typeof setTimeout> | 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 !== priority) onSet(parsed);
setEditing(false);
};
const beginEdit = () => {
setDraft(String(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 (
<Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex items-center gap-1.5 cursor-help">
{/* 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. */}
<span className="relative inline-block w-6 h-5 text-right leading-5">
<button
onClick={beginEdit}
aria-label={t("channels.row.editPriority")}
title={t("channels.row.editPriority")}
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
>
{priority}
</button>
{editing && (
<input
type="number"
value={draft}
autoFocus
onFocus={(e) => 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"
/>
)}
</span>
<span className="inline-flex flex-col">
<PriorityStepButton
delta={1}
Icon={ArrowUp}
label={t("channels.row.raisePriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
<PriorityStepButton
delta={-1}
Icon={ArrowDown}
label={t("channels.row.lowerPriority")}
onHoldStart={start}
onStop={stop}
onTap={onTap}
/>
</span>
</div>
</Tooltip>
);
}
+10 -2
View File
@@ -46,7 +46,11 @@ export default function SidePanel({
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
<div
{...peek.handlers}
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
// on this full-height slot — that made the whole column next to the nav rail a live target, so
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
// handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-67" : "w-9"}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
@@ -56,6 +60,7 @@ export default function SidePanel({
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<button
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
@@ -77,7 +82,10 @@ export default function SidePanel({
</button>
{expanded && (
<aside className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden">
<aside
{...peek.hoverHandlers}
className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden"
>
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
+14 -7
View File
@@ -2,22 +2,29 @@ import { useState, useSyncExternalStore } from "react";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom";
type Side = "top" | "bottom" | "left" | "right";
/** Wrap any element to show a short glass hint caption on hover — but only while the
/** Wrap any element to show a short glass hint caption on hover — by default only while the
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge. */
* and slides back inside the viewport near an edge.
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel). */
export default function Tooltip({
hint,
side = "top",
always = false,
openDelay = 0,
children,
}: {
hint: string;
side?: Side;
always?: boolean;
openDelay?: number;
children: React.ReactNode;
}) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const enabled = always || hintsOn;
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open: open && enabled && !!hint,
@@ -25,9 +32,9 @@ export default function Tooltip({
mode: "hover",
placement: side,
offset: 8,
// Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent
// default suits interactive cards but makes a fast sweep across an icon row surface no hints.
hoverDelay: { open: 0, close: 0 },
// Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
hoverDelay: { open: openDelay, close: 0 },
});
if (!enabled || !hint) return <>{children}</>;
+2 -2
View File
@@ -239,7 +239,7 @@ function Thumb({
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover"
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
) : (
<div className="w-full h-full" />
@@ -598,7 +598,7 @@ function VideoCard({
data-testid="video-card"
data-video-id={video.id}
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55",
+1
View File
@@ -28,6 +28,7 @@ export interface ChannelDetail {
blocked: boolean;
stored_videos: number;
deep_requested: boolean;
priority: number;
backfill_done: boolean;
from_explore: boolean;
details_synced: boolean;
+11 -5
View File
@@ -77,13 +77,18 @@ export function Filterable({
);
}
// Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style),
// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art.
// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over
// it. Painted on the app root (#root) — which is exactly the viewport and sits BEHIND the transparent
// nav / header / content — so the art fills the whole page edge-to-edge (under the rail + floating
// header too), not just the inset <main> box. (A `position:fixed` layer at negative z-index does NOT
// work here: the body's opaque background is painted over it, so it's invisible; #root's background,
// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed`
// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art.
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
useLayoutEffect(() => {
const main = document.querySelector("main");
if (!main) return;
const s = main.style;
const el = document.getElementById("root");
if (!el) return;
const s = el.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
@@ -92,6 +97,7 @@ export function useArtBackdrop(art: string | null | undefined, enabled: boolean)
s.backgroundAttachment = "";
};
if (enabled && art) {
// The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used).
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
+15
View File
@@ -14,6 +14,21 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.61.0",
date: "2026-08-11",
features: [
"You can now set a channel's feed priority right from its own page — the same stepper as the channel manager, with press-and-hold to change it quickly or click the number to type a value.",
"Playing a Plex series now rolls straight into the next season: when a season's last episode ends, the following season's first episode plays automatically.",
],
fixes: [
"Refreshed hover on the feed and Plex cards: the thumbnail or poster now gently zooms in place instead of the whole card lifting, and Plex posters gained the same depth and title alignment as video cards.",
"The left navigation is now a slim icon rail — each icon shows its name in a small label when you hover it or tab to it with the keyboard.",
"The filter sidebar no longer springs open just because you moved the pointer toward the navigation; it opens only when you actually point at its tab.",
"With a background image on, Plex artwork now fills the whole screen behind the app instead of only the content area.",
"When a video finishes and nothing is queued next, the player returns to the previous page instead of leaving a black screen.",
],
},
{
version: "0.60.10",
date: "2026-08-11",
-1
View File
@@ -39,7 +39,6 @@ export const LS = {
auditTable: "siftlode.auditTable",
statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef } from "react";
import { api } from "./api";
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
* press-and-hold sends ONE request, not one per step, and pending flushes fire on unmount so a
* last-moment bump isn't lost. The cache-shape-specific bits are injected (the manager maps over a
* ManagedChannel[]; the detail patches one ChannelDetail), so the two call sites can't drift — the
* same drift the sibling `useChannelTagActions` hook prevents for tags.
*
* Timers are keyed by channel id, so a pending flush survives the caller switching channels: it
* fires later and reads the right cache via `read(id)`, rather than needing the caller to flush on
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through a ref so the returned
* callbacks stay stable — the unmount flush then runs only on unmount, never on every render. */
export function useChannelPriority(opts: {
/** Current priority for this channel from whichever cache the caller owns (undefined if absent). */
read: (channelId: string) => number | undefined;
/** Apply the optimistic priority to the caller's cache. */
optimistic: (channelId: string, next: number) => void;
/** Called after the server write lands (e.g. invalidate sibling caches). Optional. */
onSuccess?: (channelId: string) => void;
/** Called if the server write fails — revert by refetching the owning cache. */
onError: (channelId: string) => void;
/** Debounce window in ms; defaults to 400. */
delay?: number;
}) {
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const optsRef = useRef(opts);
optsRef.current = opts;
const flush = useCallback((channelId: string) => {
const latest = optsRef.current.read(channelId);
if (latest == null) return;
api
.updateChannel(channelId, { priority: latest })
.then(() => optsRef.current.onSuccess?.(channelId))
.catch(() => optsRef.current.onError(channelId));
}, []);
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
// optimistically + a debounced flush.
const setPriority = useCallback(
(channelId: string, next: number) => {
optsRef.current.optimistic(channelId, next);
const pending = timers.current.get(channelId);
if (pending) clearTimeout(pending);
timers.current.set(
channelId,
setTimeout(() => {
timers.current.delete(channelId);
flush(channelId);
}, optsRef.current.delay ?? 400),
);
},
[flush],
);
// Read the LATEST value from the cache each step (not a render closure) so a hold-repeat
// accumulates correctly.
const bumpPriority = useCallback(
(channelId: string, delta: number) => {
setPriority(channelId, (optsRef.current.read(channelId) ?? 0) + delta);
},
[setPriority],
);
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach((timer, channelId) => {
clearTimeout(timer);
flush(channelId);
});
pending.clear();
};
}, [flush]);
return { setPriority, bumpPriority };
}
+39 -28
View File
@@ -1,29 +1,37 @@
import { useEffect, useState, type FocusEvent } from "react";
import { useCallback, useEffect, useRef, useState, type FocusEvent } from "react";
/**
* "Peek open on hover or keyboard focus" for a container — the shared brain behind the nav rail
* and the floating side panels, which sit slim until you point at them or tab into them.
* "Peek open on hover or keyboard focus" for a floating panel — the shared brain behind the side
* panels, which sit slim until you point at them or tab into them.
*
* Spread `handlers` on the container; `active` is true while the pointer is over it (or any
* descendant) or focus is inside it.
* Returns `active` plus THREE handler groups so a consumer can scope hover and focus independently:
* - `hoverHandlers` — pointer enter/leave, with a short GRACE period on leave so the pointer can
* cross a gap between two separate peek regions (e.g. the docked tab and the expanded card, which
* are separate absolutely-positioned elements) without the panel collapsing mid-move. Put these on
* ONLY the visible affordances (the tab handle + the card), so approaching empty gutter next to the
* panel does not pop it open.
* - `focusHandlers` — focus/blur, tracked through the REACT tree so a portaled child menu still
* counts as "inside". Put these on the outer container so keyboard focus anywhere within holds it.
* - `handlers` — both groups merged, for the simple single-element case.
*
* Focus is tracked through the REACT tree, so a portaled menu rendered by a child (the language
* switcher, the account popover) still counts as "inside" and holds the container open while you
* use it. The one case that misses: such a portal unmounts while focused, and browsers fire NO blur
* when a focused node is removed — focus silently falls to the body and `active` would latch on
* forever. Hence the document-level safety net: when focus has landed nowhere, clear it.
* The one focus case that misses: a portaled menu unmounts while focused — browsers fire NO blur when
* a focused node is removed, so focus silently falls to the body and would latch `focused` on forever.
* Hence the document-level safety net: when focus has landed nowhere, clear it.
*/
export function useHoverFocusWithin(): {
active: boolean;
handlers: {
onMouseEnter: () => void;
onMouseLeave: () => void;
onFocus: () => void;
onBlur: (e: FocusEvent) => void;
};
} {
export function useHoverFocusWithin() {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const enter = useCallback(() => {
clearTimeout(leaveTimer.current);
setHovered(true);
}, []);
const leaveSoon = useCallback(() => {
clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => setHovered(false), 120);
}, []);
useEffect(() => () => clearTimeout(leaveTimer.current), []);
useEffect(() => {
let timer: number | undefined;
@@ -46,15 +54,18 @@ export function useHoverFocusWithin(): {
};
}, []);
return {
active: hovered || focused,
handlers: {
onMouseEnter: () => setHovered(true),
onMouseLeave: () => setHovered(false),
onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
},
const hoverHandlers = { onMouseEnter: enter, onMouseLeave: leaveSoon };
const focusHandlers = {
onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
},
};
return {
active: hovered || focused,
hoverHandlers,
focusHandlers,
handlers: { ...hoverHandlers, ...focusHandlers },
};
}