feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap). 1B Extract the manager's press-and-hold priority stepper into a shared PriorityStepper and add it to the channel detail header (subscribed, non-demo); backend channel_detail now returns the per-subscription `priority`. 2 Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same glass-card 3D depth + accent hover border as the feed cards (shadow-md is a transparent no-op here, so it's glass-card that provides the depth); clamp Plex card titles to a fixed 2-line height so 1- vs 2-line titles align. 3 Nav rail is now always a slim icon-only column, flush to the left edge; each icon reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the navCollapsed pref plumbing. 4 Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole full-height slot next to the rail), so approaching the nav no longer pops the filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out grace period + split hover/focus handler groups. 5 Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the transparent app) instead of only the inset <main> box. 6 Plex season binge continues across seasons: the season view's play queue now spans the whole show (allKeys), so a season's last episode rolls into the next season's first (seasonKeys stays for "add season to playlist"). 7 When a Plex video/episode ends with nothing queued next, the player returns to the previous page (onEnded → onClose) instead of stranding a black screen.
This commit is contained in:
@@ -3,16 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import {
|
||||
Info,
|
||||
LogOut,
|
||||
Pin,
|
||||
PinOff,
|
||||
Settings,
|
||||
Shield,
|
||||
UserPlus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
||||
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -20,9 +11,9 @@ import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
@@ -34,28 +25,19 @@ export default function NavSidebar({
|
||||
onOpenAbout,
|
||||
onChangeLanguage,
|
||||
language,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
me: Me;
|
||||
onOpenAbout: () => void;
|
||||
onChangeLanguage: (code: LangCode) => void;
|
||||
language: LangCode;
|
||||
// Collapse state is owned by App (persisted to the user's preferences); the rail just reflects
|
||||
// it and asks App to flip it.
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
|
||||
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
||||
// `collapsed` (persisted per-account) now means "unpinned".
|
||||
const peek = useHoverFocusWithin();
|
||||
const pinned = !collapsed;
|
||||
const expanded = pinned || peek.active;
|
||||
const slim = !expanded; // visual: icon-only rail
|
||||
// The rail is ALWAYS slim: a thin icon-only column flush against the left edge. There is no
|
||||
// expanded/pinned mode anymore — instead each icon reveals a floating name label on hover (a
|
||||
// rest-to-show flyout via Tooltip), so the names are one glance away without the rail ever growing.
|
||||
const slim = true;
|
||||
const [acctOpen, setAcctOpen] = useState(false);
|
||||
// The account panel holds mixed content with its own tab order (profile, account switches, two
|
||||
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
|
||||
@@ -187,74 +169,43 @@ export default function NavSidebar({
|
||||
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
|
||||
const show = !!badge && badge > 0;
|
||||
return (
|
||||
<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.
|
||||
<div className="relative shrink-0 w-20">
|
||||
<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"
|
||||
@@ -301,23 +252,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 +279,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
|
||||
|
||||
Reference in New Issue
Block a user