Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab.
360 lines
16 KiB
TypeScript
360 lines
16 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, 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 { type LangCode } from "../i18n";
|
|
import { Button } from "./ui/Button";
|
|
import AvatarImg from "./ui/Avatar";
|
|
import Tooltip from "./ui/Tooltip";
|
|
import LanguageSwitcher from "./LanguageSwitcher";
|
|
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
|
|
|
// 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,
|
|
}: {
|
|
me: Me;
|
|
onOpenAbout: () => void;
|
|
onChangeLanguage: (code: LangCode) => void;
|
|
language: LangCode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { page } = useNavigation();
|
|
const { setPage } = useNavigationActions();
|
|
// 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
|
|
// 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 (
|
|
// 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>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
return (
|
|
// 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
|
|
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
|
|
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 && (
|
|
// 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 flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
|
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onOpenAbout}
|
|
title={t("header.account.about")}
|
|
aria-label={t("header.account.about")}
|
|
icon={<Info className="w-5 h-5" />}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={logout}
|
|
title={t("header.account.signOut")}
|
|
aria-label={t("header.account.signOut")}
|
|
icon={<LogOut className="w-5 h-5" />}
|
|
/>
|
|
</div>
|
|
<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={`${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
|
|
src={me.avatar_url}
|
|
fallback={name}
|
|
className="w-5.5 h-5.5 rounded-full text-3xs"
|
|
/>
|
|
</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>
|
|
{/* The address Google answers with, when it isn't the one above. /api/me sends it
|
|
only when the two differ, so for most accounts this line never exists — and for
|
|
someone who registered as `me+siftlode@gmail.com` it answers the question their
|
|
sign-in screen would otherwise leave open: which address gets me in? A text
|
|
label rather than a brand glyph, since the app draws one icon set (lucide) and a
|
|
generic mail icon here would say nothing. */}
|
|
{me.google_email && (
|
|
<div
|
|
className="text-2xs text-muted truncate mt-0.5"
|
|
title={t("header.account.googleAddress", { email: me.google_email })}
|
|
>
|
|
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{me.role === "admin" && (
|
|
<div className="flex items-center gap-1.5 mt-2 text-2xs 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-3xs shrink-0"
|
|
/>
|
|
<span className="min-w-0 text-left">
|
|
<span className="block text-[0.8125rem] text-fg truncate">
|
|
{a.display_name ?? a.email.split("@")[0]}
|
|
</span>
|
|
<span className="block text-2xs 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>
|
|
);
|
|
}
|