Files
silo-server/web/src/components/AppSidebar.tsx
T
QuickandGitHub 4cd51d8f6c feat(settings): add mobile-first settings overviews (#537)
* feat(settings): add mobile-first settings overview

* feat(admin): add mobile settings overview

* fix(settings): address mobile navigation review
2026-08-03 22:28:31 -04:00

969 lines
41 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
import { Link, useLocation, useParams } from "react-router";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import {
getProfileMenuSide,
groupAppNavLinks,
isSidebarExpanded,
isSidebarRailCollapsed,
libraryRowShift,
sidebarSurfaceStyle,
type AppNavLink,
} from "@/components/AppSidebar.logic";
import { SiloBrand } from "@/components/SiloBrand";
import { useAuth } from "@/hooks/useAuth";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useUserLibraries } from "@/hooks/queries/libraries";
import { useUnreadNotificationCount } from "@/hooks/queries/notifications";
import { useNotificationCapability } from "@/hooks/queries/notificationWebhooks";
import { usePluginSettingsList } from "@/hooks/queries/pluginSettings";
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
import { useSidebarPins, useToggleSidebarPin } from "@/hooks/queries/sidebarPins";
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
import { pluginRouteHref } from "@/lib/pluginRouteHref";
import {
buildLibraryCollectionCatalogHref,
buildPersonalCatalogHref,
buildQueryCatalogHref,
buildSectionCatalogHref,
parseCatalogSearchParams,
} from "@/pages/catalogSearchParams";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Film,
Tv,
Library,
Search,
Heart,
List,
Clock,
FolderOpen,
LogOut,
UserCircle,
Shield,
Settings,
Sparkles,
CalendarDays,
Home,
UsersRound,
ChevronDown,
ChevronRight,
PinOff,
LayoutGrid,
Puzzle,
BookHeadphones,
Send,
Bell,
} from "lucide-react";
import { useTheme } from "@/hooks/useTheme";
import { CURATED_THEME_IDS, THEMES } from "@/lib/themes";
import { cn } from "@/lib/utils";
function getLibraryIcon(type: string) {
switch (type) {
case "movies":
return <Film className="h-[18px] w-[18px]" />;
case "series":
return <Tv className="h-[18px] w-[18px]" />;
case "audiobook":
case "audiobooks":
return <BookHeadphones className="h-[18px] w-[18px]" />;
default:
return <Library className="h-[18px] w-[18px]" />;
}
}
function getLibraryIdFromPathname(pathname: string): number | null {
const match = pathname.match(/^\/library\/(\d+)(?:\/|$)/);
if (!match) return null;
const id = Number(match[1]);
return Number.isInteger(id) && id > 0 ? id : null;
}
/**
* Nav row label. The surface never changes width, so labels keep their full
* layout box in both states and only fade — the moving frame hides them. Animating
* `max-width` here used to reflow the whole nav subtree on every frame.
*/
function SidebarLabel({ children, show }: { children: ReactNode; show: boolean }) {
return (
<span className={`sidebar-fade max-w-[180px] truncate ${show ? "opacity-100" : "opacity-0"}`}>
{children}
</span>
);
}
function SidebarSectionHeader({
children,
show,
collapsible = false,
expanded = false,
onToggle,
}: {
children: ReactNode;
show: boolean;
collapsible?: boolean;
expanded?: boolean;
onToggle?: () => void;
}) {
const textClass = "text-muted-foreground text-[10px] font-semibold tracking-[0.22em] uppercase";
// Centred on the 64px rail, not on the 260px surface. `-left-3` cancels the
// nav's own `px-3`, so this box starts at the sidebar's left edge and spans
// exactly the rail — otherwise the dash sits 12px right of centre.
const collapsedDivider = (
<div
className={`sidebar-fade absolute inset-y-0 -left-3 flex w-16 items-center justify-center ${
show ? "pointer-events-none opacity-0" : "opacity-100"
}`}
aria-hidden="true"
>
<div className="bg-sidebar-section-divider h-px w-5 rounded-full" />
</div>
);
if (collapsible) {
return (
<div className="relative mb-2">
{collapsedDivider}
<button
type="button"
onClick={onToggle}
disabled={!show}
aria-hidden={!show}
aria-expanded={expanded}
aria-label={expanded ? `Collapse ${String(children)}` : `Expand ${String(children)}`}
tabIndex={show ? 0 : -1}
className={`${textClass} sidebar-fade hover:text-sidebar-foreground flex h-5 w-full items-center gap-1 px-3 ${
show ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span>{children}</span>
</button>
</div>
);
}
return (
<div className="relative mb-2 px-3">
{collapsedDivider}
<div
aria-hidden={!show}
className={`${textClass} sidebar-fade flex h-5 items-center ${
show ? "opacity-100" : "opacity-0"
}`}
>
{children}
</div>
</div>
);
}
interface AppSidebarProps {
onNavigate?: () => void;
collapsed?: boolean;
}
export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebarProps) {
const location = useLocation();
const navigate = useViewTransitionNavigate();
const params = useParams<{ libraryId: string }>();
const { user, logout, clearProfile } = useAuth();
const { profile } = useCurrentProfile();
const { theme, setTheme, previewTheme, resetPreviewTheme } = useTheme();
const showAdminNav = useIsActingAdmin();
const { data: libraries } = useUserLibraries();
const { pins } = useSidebarPins();
const { togglePin } = useToggleSidebarPin();
const { data: pluginSettings } = usePluginSettingsList();
const requestStatus = useRequestFeatureStatus();
const showRequestsNav = requestStatus.data?.requests_enabled === true;
// Optimistic while loading (the setting defaults to on, so hiding until the
// capability resolves would flash); hidden when the admin kill switch is off
// or the server has no notifications API (worker modes → query errors).
const notificationCapability = useNotificationCapability();
const showNotificationsNav = notificationCapability.isError
? false
: (notificationCapability.data?.in_app.enabled ?? true);
const { data: unreadNotifications } = useUnreadNotificationCount(
Boolean(profile) && showNotificationsNav,
);
const pluginNavLinks = useMemo(() => {
const installations = pluginSettings?.installations ?? [];
const links: AppNavLink[] = [];
for (const inst of installations) {
for (const route of inst.routes) {
if (!route.navigable || route.navigation_kind !== "user") continue;
links.push({
id: `${inst.id}:${route.id || route.path}`,
basePath: pluginRouteHref(inst.id, route.path),
label: route.navigation_label || inst.plugin_id,
pluginId: inst.plugin_id,
category: inst.category,
});
}
}
return links;
}, [pluginSettings]);
// Grouped view of the Apps entries (null → keep the flat list under the
// single "Apps" header). See groupAppNavLinks for the SDK category contract.
const pluginNavGroups = useMemo(() => groupAppNavLinks(pluginNavLinks), [pluginNavLinks]);
const catalogState = useMemo(
() =>
location.pathname === "/catalog"
? parseCatalogSearchParams(new URLSearchParams(location.search))
: null,
[location.pathname, location.search],
);
const activeLibraryId =
params.libraryId && Number.isInteger(Number(params.libraryId))
? Number(params.libraryId)
: (getLibraryIdFromPathname(location.pathname) ??
(catalogState?.source === "section" && catalogState.scope === "library"
? (catalogState.library_id ?? null)
: catalogState?.source === "library_collection"
? (catalogState.library_id ?? null)
: null));
// Hover-to-expand when collapsed (150ms enter delay prevents accidental expansion)
const [hovered, setHovered] = useState(false);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback(() => {
if (!collapsed) return;
hoverTimerRef.current = setTimeout(() => setHovered(true), 150);
}, [collapsed]);
const handleMouseLeave = useCallback(() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
if (profileMenuOpen) return;
setHovered(false);
}, [profileMenuOpen]);
useEffect(() => {
return () => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
};
}, []);
const sidebarExpanded = isSidebarExpanded(collapsed, hovered, profileMenuOpen);
const showLabels = sidebarExpanded;
const railCollapsed = isSidebarRailCollapsed(collapsed, sidebarExpanded);
const [librariesExpanded, setLibrariesExpanded] = useState(true);
const [expandedLibraries, setExpandedLibraries] = useState<Set<number>>(
() =>
new Set(
Object.keys(pins)
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0),
),
);
const toggleLibraryExpand = useCallback((libId: number) => {
setExpandedLibraries((prev) => {
const next = new Set(prev);
if (next.has(libId)) {
next.delete(libId);
} else {
next.add(libId);
}
return next;
});
}, []);
function isActive(href: string, exact?: boolean) {
if (exact) return location.pathname === href;
return location.pathname === href || location.pathname.startsWith(`${href}/`);
}
function isCatalogSourceActive(source: "query" | "favorites" | "watchlist" | "history") {
return location.pathname === "/catalog" && catalogState?.source === source;
}
function isPinnedCatalogActive(
libId: number,
pin: { type: "collection" | "section"; id: string },
) {
if (location.pathname !== "/catalog" || !catalogState) {
return false;
}
if (pin.type === "collection") {
return catalogState.source === "library_collection" && catalogState.collection_id === pin.id;
}
return (
catalogState.source === "section" &&
catalogState.scope === "library" &&
catalogState.library_id === libId &&
catalogState.section_id === pin.id
);
}
const navLinkClass = (href: string, exact?: boolean) =>
navLinkClassForState(isActive(href, exact));
const navLinkClassForState = (active: boolean) =>
`relative flex items-center gap-2.5 rounded-xl px-3 py-3 text-[13px] font-medium transition-all duration-200 ${
active
? "text-sidebar-accent-foreground bg-sidebar-accent/90 shadow-[0_16px_30px_-24px_rgba(0,0,0,0.7)]"
: "text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent/70"
}`;
const renderAppNavList = (links: AppNavLink[]) => (
<ul className="list-none space-y-0.5">
{links.map((link) => (
<li key={link.id}>
<a
href={link.basePath}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(link.basePath);
onNavigate?.();
}}
className={navLinkClassForState(false)}
title={link.pluginId}
>
<Puzzle className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>{link.label}</SidebarLabel>
</a>
</li>
))}
</ul>
);
return (
// The surface is 260px wide in every state; `data-collapsed` only drives the
// moving overflow frame that narrows it to the 64px rail. The right border lives on a
// border box, so the border rides along and lands exactly on the rail edge.
<aside
data-collapsed={railCollapsed ? "true" : undefined}
className="border-sidebar-border/70 sidebar-surface fixed top-0 bottom-0 left-0 z-40 w-[260px] overflow-hidden border-r"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={sidebarSurfaceStyle({ collapsed, sidebarExpanded })}
>
{/* Counter-translated by exactly what the frame above moves, so every
pixel of the sidebar stays put on screen while the frame's edge wipes
across it. The blur lives here rather than on the frame precisely
because this layer never moves relative to the viewport — its 40px
backdrop is sampled from a region that never changes. */}
<div className="bg-sidebar/88 sidebar-inner flex h-full w-[260px] flex-col backdrop-blur-2xl">
{/* Logo — the wordmark and the mark cross-fade in place. Swapping the
variant outright would pop at the very start of the collapse. */}
<div className="sidebar-logo relative flex h-24 items-center">
<span
aria-hidden={!showLabels}
className={`sidebar-fade absolute left-5 ${showLabels ? "opacity-100" : "opacity-0"}`}
>
<SiloBrand variant="wordmark" className="h-12 w-[112px]" />
</span>
<span
aria-hidden={showLabels}
className={`sidebar-fade absolute left-3.5 ${showLabels ? "opacity-0" : "opacity-100"}`}
>
<SiloBrand variant="mark" className="h-9 w-9" />
</span>
</div>
{/* Main nav */}
<nav
aria-label="Main navigation"
className="sidebar-nav sidebar-scroll flex-1 space-y-7 overflow-y-auto px-3 pb-5"
>
{/* Home */}
<ul className="list-none space-y-0.5">
<li>
<ViewTransitionLink
to="/"
onClick={onNavigate}
className={navLinkClass("/", true)}
aria-current={isActive("/", true) ? "page" : undefined}
>
{isActive("/", true) && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Home className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Home</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
{/* Libraries */}
{libraries && libraries.length > 0 && (
<div className="sidebar-libraries">
<SidebarSectionHeader
show={showLabels}
collapsible
expanded={librariesExpanded}
onToggle={() => setLibrariesExpanded((v) => !v)}
>
Libraries
</SidebarSectionHeader>
{(showLabels ? librariesExpanded : true) && (
<ul className="list-none space-y-0.5">
{libraries.map((lib) => {
const baseHref = `/library/${lib.id}`;
const active = activeLibraryId === lib.id;
const href =
active && location.pathname === baseHref
? `${baseHref}${location.search}`
: baseHref;
const libraryPins = pins[String(lib.id)] ?? [];
const hasPins = libraryPins.length > 0;
const isExpanded = hasPins && !expandedLibraries.has(lib.id);
return (
<li key={lib.id}>
{/* Library row: chevron (if pins) + icon + name link */}
<div
className={`relative flex items-center rounded-lg text-[13px] font-medium transition-colors duration-150 ${
active
? "text-sidebar-accent-foreground bg-sidebar-accent"
: "text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent"
}`}
>
{active && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
{/* The chevron slot is reserved in both states so the
row never reflows; on the rail the whole row slides
left by the slot width instead, which lines the
library icons up with the rest of the column. */}
<div
className="sidebar-row-shift flex flex-1 items-center"
style={
{ "--sidebar-row-shift": libraryRowShift(hasPins) } as CSSProperties
}
>
{hasPins ? (
<button
type="button"
onClick={() => toggleLibraryExpand(lib.id)}
disabled={!showLabels}
aria-hidden={!showLabels}
tabIndex={showLabels ? 0 : -1}
className={`sidebar-fade flex h-full items-center py-3 pr-1 pl-3 ${
showLabels ? "opacity-100" : "pointer-events-none opacity-0"
}`}
aria-label={
isExpanded ? `Collapse ${lib.name}` : `Expand ${lib.name}`
}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
) : (
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
)}
</button>
) : (
<span className="w-3 shrink-0 pl-3" />
)}
<ViewTransitionLink
to={href}
onClick={onNavigate}
className="flex flex-1 items-center gap-2.5 py-2.5 pr-3"
aria-current={active ? "page" : undefined}
>
<span className="flex w-[18px] flex-shrink-0 items-center justify-center">
{getLibraryIcon(lib.type)}
</span>
<SidebarLabel show={showLabels}>{lib.name}</SidebarLabel>
</ViewTransitionLink>
</div>
</div>
{/* Pinned items under this library — hidden when collapsed */}
{showLabels && hasPins && isExpanded && (
<div className="border-sidebar-border ml-3 space-y-0.5 border-l pl-2">
{libraryPins.map((pin) => {
const pinHref =
pin.type === "collection"
? buildLibraryCollectionCatalogHref(pin.id, pin.label)
: buildSectionCatalogHref({
scope: "library",
libraryId: lib.id,
sectionId: pin.id,
title: pin.label,
});
const pinActive = isPinnedCatalogActive(lib.id, pin);
return (
<div
key={`${pin.type}-${pin.id}`}
className="group/pin relative flex items-center"
>
<ViewTransitionLink
to={pinHref}
onClick={onNavigate}
className={`flex flex-1 items-center gap-2 rounded-xl px-2.5 py-2.5 text-[12.5px] font-medium transition-colors duration-150 ${
pinActive
? "text-sidebar-accent-foreground bg-sidebar-accent/90"
: "text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent/70"
}`}
>
{pin.type === "collection" ? (
<FolderOpen className="h-3.5 w-3.5 opacity-60" />
) : (
<LayoutGrid className="h-3.5 w-3.5 opacity-60" />
)}
<span className="truncate">{pin.label}</span>
</ViewTransitionLink>
<button
type="button"
onClick={() =>
togglePin(lib.id, {
type: pin.type,
id: pin.id,
label: pin.label,
})
}
className="text-muted-foreground hover:text-destructive absolute right-1 rounded p-1 opacity-0 transition-opacity group-hover/pin:opacity-100"
title="Unpin"
>
<PinOff className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
)}
{/* Discover */}
<div>
<SidebarSectionHeader show={showLabels}>Discover</SidebarSectionHeader>
<ul className="list-none space-y-0.5">
<li>
<ViewTransitionLink
to={buildQueryCatalogHref()}
onClick={onNavigate}
className={navLinkClassForState(isCatalogSourceActive("query"))}
aria-current={isCatalogSourceActive("query") ? "page" : undefined}
>
{isCatalogSourceActive("query") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Search className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Search</SidebarLabel>
<kbd
aria-hidden={!showLabels}
className={`bg-muted text-muted-foreground sidebar-fade pointer-events-none ml-auto hidden rounded border px-1.5 py-0.5 text-[10px] font-medium select-none lg:inline-flex ${
showLabels ? "opacity-100" : "opacity-0"
}`}
>
{"\u2318"}K
</kbd>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/recommendations"
onClick={onNavigate}
className={navLinkClass("/recommendations")}
aria-current={isActive("/recommendations") ? "page" : undefined}
>
{isActive("/recommendations") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Sparkles className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Recommendations</SidebarLabel>
</ViewTransitionLink>
</li>
{showRequestsNav && (
<li>
<ViewTransitionLink
to="/requests"
onClick={onNavigate}
className={navLinkClass("/requests")}
aria-current={isActive("/requests") ? "page" : undefined}
>
{isActive("/requests") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Send className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Requests</SidebarLabel>
</ViewTransitionLink>
</li>
)}
<li>
<ViewTransitionLink
to="/calendar"
onClick={onNavigate}
className={navLinkClass("/calendar")}
aria-current={isActive("/calendar") ? "page" : undefined}
>
{isActive("/calendar") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<CalendarDays className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
</ViewTransitionLink>
</li>
{showNotificationsNav && (
<li>
<ViewTransitionLink
to="/notifications"
onClick={onNavigate}
className={navLinkClass("/notifications")}
aria-current={isActive("/notifications") ? "page" : undefined}
>
{isActive("/notifications") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<span className="relative shrink-0">
<Bell className="h-[18px] w-[18px] shrink-0" />
{(unreadNotifications ?? 0) > 0 && (
<span
aria-hidden="true"
className={`sidebar-fade absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ${
showLabels ? "opacity-0" : "opacity-100"
}`}
style={{ background: "var(--primary)" }}
/>
)}
</span>
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
{(unreadNotifications ?? 0) > 0 && (
<span
className={`sidebar-fade ml-auto rounded-full px-1.5 py-0.5 text-[10px] leading-none font-semibold ${
showLabels ? "opacity-100" : "opacity-0"
}`}
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
>
{(unreadNotifications ?? 0) > 99 ? "99+" : unreadNotifications}
</span>
)}
</ViewTransitionLink>
</li>
)}
</ul>
</div>
{/* Your Stuff */}
<div className="sidebar-personal">
<SidebarSectionHeader show={showLabels}>Your Stuff</SidebarSectionHeader>
<ul className="list-none space-y-0.5">
<li>
<ViewTransitionLink
to={buildPersonalCatalogHref("favorites")}
onClick={onNavigate}
className={navLinkClassForState(isCatalogSourceActive("favorites"))}
aria-current={isCatalogSourceActive("favorites") ? "page" : undefined}
>
{isCatalogSourceActive("favorites") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Heart className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Favorites</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to={buildPersonalCatalogHref("watchlist")}
onClick={onNavigate}
className={navLinkClassForState(isCatalogSourceActive("watchlist"))}
aria-current={isCatalogSourceActive("watchlist") ? "page" : undefined}
>
{isCatalogSourceActive("watchlist") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<List className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Watchlist</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/rooms/join"
onClick={onNavigate}
className={navLinkClass("/rooms/join")}
aria-current={isActive("/rooms/join") ? "page" : undefined}
>
{isActive("/rooms/join") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<UsersRound className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Watch Party</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/collections"
onClick={onNavigate}
className={navLinkClass("/collections")}
aria-current={isActive("/collections") ? "page" : undefined}
>
{isActive("/collections") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<FolderOpen className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Collections</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to={buildPersonalCatalogHref("history")}
onClick={onNavigate}
className={navLinkClassForState(isCatalogSourceActive("history"))}
aria-current={isCatalogSourceActive("history") ? "page" : undefined}
>
{isCatalogSourceActive("history") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Clock className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>History</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
</div>
{/* Apps (plugin-supplied user navigation), grouped by the first
segment of the manifest's slash-delimited category when 2+
distinct categories exist; flat list otherwise. */}
{pluginNavLinks.length > 0 && (
<div className="sidebar-apps">
<SidebarSectionHeader show={showLabels}>Apps</SidebarSectionHeader>
{pluginNavGroups ? (
<div className="space-y-3">
{pluginNavGroups.map((group) => (
<div key={group.category} className="sidebar-apps-group">
<SidebarSectionHeader show={showLabels}>
{group.category}
</SidebarSectionHeader>
{renderAppNavList(group.links)}
</div>
))}
</div>
) : (
renderAppNavList(pluginNavLinks)
)}
</div>
)}
</nav>
{/* Footer */}
<div className="sidebar-footer border-sidebar-border/70 space-y-2 border-t px-3 py-3">
{showAdminNav && (
<Link
to="/admin"
onClick={onNavigate}
className={navLinkClass("/admin")}
aria-current={isActive("/admin") ? "page" : undefined}
>
<Shield className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Admin</SidebarLabel>
</Link>
)}
{/* User profile dropdown */}
<DropdownMenu
onOpenChange={(open) => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
setProfileMenuOpen(open);
if (!open && !collapsed) return;
setHovered(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`${profile?.name ?? user?.username ?? "User"} menu`}
// Left-anchored in both states: `mx-auto` would centre the avatar
// in the 260px surface, i.e. far outside the 64px rail, and any
// width swap here would pop at the start of the collapse.
className="hover:bg-sidebar-accent/70 flex w-full items-center gap-2.5 rounded-xl px-3 py-3 transition-colors duration-150"
>
{/* Same 18px slot every nav icon sits in. The avatar is 28px, so
left-aligning it like an icon would push its centre 5px right
of the rail's icon column; centring it on the slot lines it up
with the shield above in the rail, and lines the name below up
with the nav labels when expanded. */}
<span className="flex w-[18px] shrink-0 items-center justify-center">
<Avatar className="h-7 w-7 shrink-0">
{profile?.avatar_url ? (
<AvatarImage src={profile.avatar_url} alt={profile.name} />
) : null}
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-bold shadow-[0_14px_32px_-20px_rgba(0,0,0,0.8)]">
{profile?.name?.charAt(0).toUpperCase() ??
user?.username?.charAt(0).toUpperCase() ??
"?"}
</AvatarFallback>
</Avatar>
</span>
<span
className={`text-sidebar-foreground sidebar-fade max-w-[180px] truncate text-[13px] font-medium ${
showLabels ? "opacity-100" : "opacity-0"
}`}
>
{profile?.name ?? user?.username ?? "User"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side={getProfileMenuSide(collapsed)}
align="start"
className="w-[min(20rem,calc(100vw-1.5rem))] rounded-2xl p-2"
>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
<Avatar className="h-10 w-10">
{profile?.avatar_url ? (
<AvatarImage src={profile.avatar_url} alt={profile.name} />
) : null}
<AvatarFallback className="bg-primary text-primary-foreground text-sm font-bold">
{(profile?.name ?? user?.username ?? "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate text-[14px] font-semibold">
{profile?.name ?? user?.username ?? "User"}
</span>
{profile && user?.username && user.username !== profile.name && (
<span className="text-muted-foreground truncate text-[11px] font-normal">
{user.username}
</span>
)}
</div>
</DropdownMenuLabel>
<div
className="flex items-center justify-between gap-2 px-2.5 pt-1 pb-1.5"
role="group"
aria-label="Theme"
>
<span className="text-muted-foreground text-[10px] font-medium tracking-[0.14em] uppercase">
Theme
</span>
<div className="flex items-center gap-1.5">
{CURATED_THEME_IDS.map((id) => {
const def = THEMES[id];
const isActive = theme === id;
return (
<DropdownMenuItem
key={id}
onSelect={(event) => {
event.preventDefault();
setTheme(id);
}}
onMouseEnter={() => previewTheme(id)}
onMouseLeave={resetPreviewTheme}
onFocus={() => previewTheme(id)}
onBlur={resetPreviewTheme}
aria-label={def.label}
title={def.label}
className={cn(
"relative h-6 w-6 flex-none cursor-pointer rounded-full border p-0 transition-transform hover:scale-110 focus:scale-110",
isActive
? "ring-primary ring-offset-popover border-transparent ring-2 ring-offset-2"
: "border-border/60",
)}
style={{ backgroundColor: def.previewBg }}
>
<span
aria-hidden="true"
className="absolute top-1/2 left-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ backgroundColor: def.previewAccent }}
/>
</DropdownMenuItem>
);
})}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
navigate("/settings");
}}
className="gap-2.5 rounded-lg px-2.5 py-2 text-[13px]"
>
<Settings className="h-[18px] w-[18px]" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 pt-0.5">
{profile && (
<DropdownMenuItem
onClick={() => {
clearProfile();
navigate("/profiles");
}}
className="border-sidebar-border/60 bg-sidebar-accent/40 hover:bg-sidebar-accent focus:bg-sidebar-accent gap-3 rounded-xl border px-3 py-3 text-[13px] font-semibold"
>
<UserCircle className="h-[18px] w-[18px]" />
Switch Profile
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={logout}
className="border-sidebar-border/60 bg-sidebar-accent/40 hover:bg-sidebar-accent focus:bg-sidebar-accent gap-3 rounded-xl border px-3 py-3 text-[13px] font-semibold"
>
<LogOut className="h-[18px] w-[18px]" />
Logout
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
);
}