release: 0.61.0 (fixpack-cards-nav-plex)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}")`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user