449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import * as LucideIcons from 'lucide-react';
|
|
const { ChevronDown, Lock } = LucideIcons;
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { useUIStore } from '@/stores/uiStore';
|
|
import { useAccountStore } from '@/stores/accountStore';
|
|
import { useSchemaStore } from '@/stores/schemaStore';
|
|
import {
|
|
visibleLayouts,
|
|
findFirstVisibleLinkInLayout,
|
|
findFirstAccessibleLinkInLayout,
|
|
isLinkEnterprise,
|
|
isLinkVisible,
|
|
} from '@/lib/layout';
|
|
import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited';
|
|
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
|
|
|
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
|
const formatted = name
|
|
.split('-')
|
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
.join('');
|
|
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
|
if (!IconComp) return <LucideIcons.Circle className={className} />;
|
|
return <IconComp className={className} />;
|
|
}
|
|
|
|
function resolveViewPath(sectionName: string, viewName: string): string {
|
|
return `/${sectionName}/${viewName}`;
|
|
}
|
|
|
|
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
|
|
const base = `/${sectionName}/${viewName}`;
|
|
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
|
|
if (viewName === 'CustomComponent/Dashboard') {
|
|
const dashBase = `/${sectionName}/Dashboard/`;
|
|
return currentPath.startsWith(dashBase);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
|
|
for (const item of items) {
|
|
if (item.type === 'link') {
|
|
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
|
|
} else if (item.type === 'container') {
|
|
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
|
|
for (const item of items) {
|
|
if (item.type === 'link') {
|
|
if (!checkLinkVisible(item.viewName)) continue;
|
|
const enterprise = checkIsEnterprise(item.viewName);
|
|
if (enterprise && edition === 'oss') continue;
|
|
return true;
|
|
} else if (item.type === 'container') {
|
|
if (subtreeHasVisibleLink(item.items, edition)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
interface AccordionLevelContextValue {
|
|
openId: string | null;
|
|
setOpenId: (id: string | null) => void;
|
|
}
|
|
|
|
const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
|
|
|
|
// Sibling collapsibles share a single open id, so expanding one collapses the
|
|
// others at the same level (accordion behavior).
|
|
function AccordionLevel({ children }: { children: React.ReactNode }) {
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const value = useMemo(() => ({ openId, setOpenId }), [openId]);
|
|
return <AccordionLevelContext.Provider value={value}>{children}</AccordionLevelContext.Provider>;
|
|
}
|
|
|
|
// A collapsible wired to its accordion level. It opens itself whenever the
|
|
// active page lands inside it, while still allowing manual toggling.
|
|
function AccordionCollapsible({
|
|
id,
|
|
containsActive,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
containsActive: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const level = useContext(AccordionLevelContext);
|
|
if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel');
|
|
const { openId, setOpenId } = level;
|
|
// Layout effect so the branch containing the active page is already open on
|
|
// the first paint after a navigation.
|
|
useLayoutEffect(() => {
|
|
if (containsActive) setOpenId(id);
|
|
}, [containsActive, id, setOpenId]);
|
|
return (
|
|
<Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
|
|
{children}
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
// Softer than the default ghost hover, closer to documentation sidebars:
|
|
// muted text that brightens with a faint background instead of a strong fill.
|
|
const sidebarItemClass =
|
|
'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground';
|
|
|
|
// In square mode the sidebar follows documentation conventions (better-auth):
|
|
// full-bleed rows with a barely-there hover wash instead of inset pills.
|
|
const sidebarItemSquareClass =
|
|
"[[data-radius='square']_&]:hover:bg-foreground/[0.03] [[data-radius='square']_&]:hover:text-foreground/90";
|
|
|
|
function checkLinkVisible(viewName: string): boolean {
|
|
const schema = useSchemaStore.getState().schema;
|
|
if (!schema) return true;
|
|
|
|
const accountStore = useAccountStore.getState();
|
|
return isLinkVisible(
|
|
schema,
|
|
viewName,
|
|
accountStore.edition,
|
|
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
|
|
(perm: string) => accountStore.hasPermission(perm),
|
|
);
|
|
}
|
|
|
|
function checkIsEnterprise(viewName: string): boolean {
|
|
const schema = useSchemaStore.getState().schema;
|
|
if (!schema) return false;
|
|
const edition = useAccountStore.getState().edition;
|
|
return isLinkEnterprise(schema, viewName, edition);
|
|
}
|
|
|
|
interface SidebarSubItemProps {
|
|
item: LayoutSubItem;
|
|
depth: number;
|
|
sectionName: string;
|
|
currentPath: string;
|
|
navigate: ReturnType<typeof useNavigate>;
|
|
edition: string;
|
|
onUpsell: () => void;
|
|
}
|
|
|
|
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
|
if (item.type === 'link') {
|
|
if (!checkLinkVisible(item.viewName)) return null;
|
|
|
|
const path = resolveViewPath(sectionName, item.viewName);
|
|
const isActive = pathMatchesView(currentPath, sectionName, item.viewName);
|
|
const enterprise = checkIsEnterprise(item.viewName);
|
|
const isLocked = enterprise && edition === 'community';
|
|
const isHidden = enterprise && edition === 'oss';
|
|
|
|
if (isHidden) return null;
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
data-sidebar-active={isActive || undefined}
|
|
className={cn(
|
|
sidebarItemClass,
|
|
sidebarItemSquareClass,
|
|
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
|
depth > 0 && 'text-sm',
|
|
)}
|
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
|
onClick={() => {
|
|
if (isLocked) {
|
|
onUpsell();
|
|
} else {
|
|
setLastVisitedSection(sectionName, item.viewName);
|
|
navigate(path);
|
|
}
|
|
}}
|
|
>
|
|
<span className="truncate">{item.name || 'Overview'}</span>
|
|
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (item.type === 'container') {
|
|
if (!subtreeHasVisibleLink(item.items, edition)) return null;
|
|
|
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
|
return (
|
|
<AccordionCollapsible id={item.name} containsActive={containsActive}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className={cn(sidebarItemClass, sidebarItemSquareClass, 'text-sm')}
|
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
|
>
|
|
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
|
<span className="truncate">{item.name}</span>
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<AccordionLevel>
|
|
{item.items.map((sub) => (
|
|
<SidebarSubItem
|
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
|
item={sub}
|
|
depth={depth + 1}
|
|
sectionName={sectionName}
|
|
currentPath={currentPath}
|
|
navigate={navigate}
|
|
edition={edition}
|
|
onUpsell={onUpsell}
|
|
/>
|
|
))}
|
|
</AccordionLevel>
|
|
</CollapsibleContent>
|
|
</AccordionCollapsible>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface SidebarTopItemProps {
|
|
item: LayoutItem;
|
|
sectionName: string;
|
|
currentPath: string;
|
|
navigate: ReturnType<typeof useNavigate>;
|
|
edition: string;
|
|
onUpsell: () => void;
|
|
}
|
|
|
|
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
|
if ('link' in item) {
|
|
const { name, icon, viewName } = item.link;
|
|
|
|
if (!checkLinkVisible(viewName)) return null;
|
|
|
|
const path = resolveViewPath(sectionName, viewName);
|
|
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
|
const enterprise = checkIsEnterprise(viewName);
|
|
const isLocked = enterprise && edition === 'community';
|
|
const isHidden = enterprise && edition === 'oss';
|
|
|
|
if (isHidden) return null;
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
data-sidebar-active={isActive || undefined}
|
|
className={cn(
|
|
sidebarItemClass,
|
|
sidebarItemSquareClass,
|
|
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
|
)}
|
|
onClick={() => {
|
|
if (isLocked) {
|
|
onUpsell();
|
|
} else {
|
|
setLastVisitedSection(sectionName, viewName);
|
|
navigate(path);
|
|
}
|
|
}}
|
|
>
|
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{name}</span>
|
|
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if ('container' in item) {
|
|
const { name, icon, items } = item.container;
|
|
if (!subtreeHasVisibleLink(items, edition)) return null;
|
|
|
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
|
|
|
return (
|
|
<AccordionCollapsible id={name} containsActive={containsActive}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" className={cn(sidebarItemClass, sidebarItemSquareClass)}>
|
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{name}</span>
|
|
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<AccordionLevel>
|
|
{items.map((sub) => (
|
|
<SidebarSubItem
|
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
|
item={sub}
|
|
depth={1}
|
|
sectionName={sectionName}
|
|
currentPath={currentPath}
|
|
navigate={navigate}
|
|
edition={edition}
|
|
onUpsell={onUpsell}
|
|
/>
|
|
))}
|
|
</AccordionLevel>
|
|
</CollapsibleContent>
|
|
</AccordionCollapsible>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const activeSection = useUIStore((s) => s.activeSection);
|
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
|
const schema = useSchemaStore((s) => s.schema);
|
|
const edition = useAccountStore((s) => s.edition);
|
|
const permissions = useAccountStore((s) => s.permissions);
|
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
|
const navRef = useRef<HTMLElement>(null);
|
|
|
|
// Build the permission checks from the permissions array itself: the store
|
|
// accessors are stable refs, so depending on them alone would keep a stale
|
|
// layout list after access data finishes loading.
|
|
const layouts = useMemo(() => {
|
|
if (!schema) return [];
|
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
|
return visibleLayouts(schema, edition, canGet, hasPermission);
|
|
}, [schema, edition, permissions, hasPermission]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (window.matchMedia('(max-width: 767px)').matches) {
|
|
setSidebarOpen(false);
|
|
}
|
|
}, [location.pathname, setSidebarOpen]);
|
|
|
|
// Keep the active page visible in the sidebar after any navigation
|
|
// (e.g. from the command palette or an external link).
|
|
useEffect(() => {
|
|
const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
|
|
active?.scrollIntoView({ block: 'nearest' });
|
|
}, [location.pathname, activeSection]);
|
|
|
|
if (!sidebarOpen || !schema) return null;
|
|
|
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
|
if (!layout) return null;
|
|
|
|
const handleSectionClick = (target: Layout) => {
|
|
setActiveSection(target.name);
|
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
|
const last = findLastVisitedLinkInLayout(schema, target, edition, canGet, hasPermission);
|
|
const first =
|
|
last ??
|
|
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
|
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
|
if (first) navigate(`/${target.name}/${first}`);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
aria-hidden="true"
|
|
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
|
|
<ScrollArea className="flex-1">
|
|
<nav
|
|
ref={navRef}
|
|
className="flex flex-col gap-0.5 px-2 py-2 [[data-radius='square']_&]:gap-0 [[data-radius='square']_&]:px-0"
|
|
>
|
|
<AccordionLevel>
|
|
{layout.items.map((item) => (
|
|
<SidebarTopItem
|
|
key={'link' in item ? item.link.viewName : item.container.name}
|
|
item={item}
|
|
sectionName={layout.name}
|
|
currentPath={location.pathname}
|
|
navigate={navigate}
|
|
edition={edition}
|
|
onUpsell={() => setUpsellOpen(true)}
|
|
/>
|
|
))}
|
|
</AccordionLevel>
|
|
</nav>
|
|
</ScrollArea>
|
|
|
|
{layouts.length > 1 && (
|
|
<TooltipProvider>
|
|
<div className="flex items-center justify-around border-t bg-background px-2 py-2 [[data-radius='square']_&]:px-0 [[data-radius='square']_&]:py-0">
|
|
{layouts.map((target) => {
|
|
const Icon = (LucideIcons as Record<string, unknown>)[
|
|
target.icon
|
|
.split('-')
|
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
.join('')
|
|
] as LucideIcons.LucideIcon | undefined;
|
|
const isActive = target.name === activeSection;
|
|
return (
|
|
<Tooltip key={target.name}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={target.name}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
onClick={() => handleSectionClick(target)}
|
|
className={cn(
|
|
'h-9 w-9',
|
|
"[[data-radius='square']_&]:h-12 [[data-radius='square']_&]:flex-1 [[data-radius='square']_&]:border-r [[data-radius='square']_&]:border-border [[data-radius='square']_&]:last:border-r-0 [[data-radius='square']_&]:hover:bg-foreground/[0.03]",
|
|
isActive && 'bg-accent text-accent-foreground',
|
|
)}
|
|
>
|
|
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">{target.name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
)}
|
|
|
|
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|