Files
siftlode/frontend/src/components/NavSidebar.tsx
T
peter 205f18a2e1 fix(a11y): address R9 code-review findings (glass shadow, focus dedup)
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
  box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
  top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
  inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
  buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
  they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
  (has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
  no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
  focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.

Gate green (tsc/eslint/prettier/knip/vitest 79).
2026-08-08 09:28:23 +02:00

417 lines
18 KiB
TypeScript

import { useState, useSyncExternalStore } from "react";
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 { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery";
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 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.
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
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
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
// comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
open: acctOpen,
onOpenChange: setAcctOpen,
mode: "dialog",
role: "dialog",
placement: "right-end",
offset: 8,
});
async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
// drops this tab's override and reloads onto whatever account remains the default.
try {
await api.logout();
} catch {
/* clear locally and reload regardless */
}
// Forget this device's E2EE private key on sign-out: it's otherwise persisted in IndexedDB
// and auto-unlocks on the next visit, so on a shared machine the conversations would stay
// decryptable after logout. Re-entering the message passphrase is required next time.
try {
await e2ee.clearDevice(me.id);
} catch {
/* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */
}
clearActiveAccount();
location.reload();
}
// Accounts that have signed in on this browser (loaded only while the popover is open).
const accountsQuery = useQuery({
queryKey: qk.accounts(),
queryFn: api.accounts,
enabled: acctOpen,
});
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
function switchTo(id: number) {
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
// identity. No server round-trip — req() sends the account header (validated against the
// browser wallet server-side), so the cookie's default account is left untouched.
setActiveAccount(id);
// Drop the previous account's in-module sub-view/overlay before the reload (which would
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
// (often the new account's own id → an empty self-thread). Start at the module root instead.
const st = { ...(window.history.state || {}) };
delete st._sub;
delete st._ov;
window.history.replaceState(st, "");
location.reload(); // cleanest way to reload all per-user state for the new account
}
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, {
intervalMs: 30000,
});
// One indicator for both layers: durable server notifications + the client-side transient
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const msgUnread = msgUnreadQuery.data?.count ?? 0;
// Download center badge = items still working (queued/running/paused). Reuses the same
// lightweight per-user index the feed cards poll, so no extra request shape.
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
const listFade = useScrollFade();
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
// module's icon + label — come from the shared module registry (the same source the header's
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
const BADGE: Partial<Record<Page, number>> = {
notifications: unread,
messages: msgUnread,
downloads: dlActive,
};
const META = (p: Page): Omit<NavItem, "page"> => ({
icon: moduleDef(p).icon,
label: t(moduleLabelKey[p]),
badge: BADGE[p],
});
const sys = new Set<Page>(SYSTEM_PAGES);
const order = moduleOrder(me);
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = order
.filter((p) => !sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const systemItems: NavItem[] = order
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// real role (it's a flag on a `user` row), so surface it first.
const roleKey: "admin" | "user" | "demo" = me.is_demo
? "demo"
: me.role === "admin"
? "admin"
: "user";
const roleLabel = t(`nav.role.${roleKey}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
// On the active row the background is the accent colour, so a same-accent badge would be
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
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 ${
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`}
>
<span className="relative shrink-0">
<Icon className="w-[18px] h-[18px]" />
{/* 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-[16px] h-4 px-1 rounded-full text-[10px] 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-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
>
{badge > 99 ? "99+" : badge}
</span>
)}
</button>
);
};
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-[232px]" : "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-[56px] px-2" : "w-52 px-3"
}`}
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-[10px] font-semibold uppercase tracking-wider text-muted/70">
{t("nav.adminSection")}
</span>
<span className="flex-1 border-t border-border" />
</div>
))}
{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"
}`}
>
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
<button
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<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" : ""} ${
page === "settings"
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Settings className="w-[18px] h-[18px] shrink-0" />
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<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`}
>
<span className="relative shrink-0">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]"
/>
</span>
{!slim && (
<span title={roleLabel} className="truncate flex-1 text-left">
{name}
</span>
)}
</button>
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</PopoverSurface>
</div>
</div>
</nav>
</div>
);
}