From 37c41bcfe4f5eccfecc03ad7db3d73220a794dba Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 27 Mar 2026 18:53:17 +0800 Subject: [PATCH] feat(web): add popover flyout for collapsed sidebar entity categories --- .../components/home-sidebar/HomeSidebar.tsx | 391 ++++++++++++------ web/src/i18n/locales/en-US.ts | 1 + web/src/i18n/locales/ja-JP.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + 5 files changed, 267 insertions(+), 128 deletions(-) diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 4b3031b1..46570585 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -82,6 +82,11 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; @@ -216,11 +221,14 @@ function NavItems({ const pathname = usePathname(); const searchParams = useSearchParams(); const sidebarData = useSidebarData(); + const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); // Track which entity categories have their full list expanded const [expandedLists, setExpandedLists] = useState>( {}, ); + // Track popover open state for collapsed sidebar entity categories + const [popoverOpen, setPopoverOpen] = useState>({}); // Plugin operation state const [showPluginOpModal, setShowPluginOpModal] = useState(false); @@ -324,6 +332,260 @@ function NavItems({ // Use stored open state if available, otherwise default to active state const isOpen = sectionOpenState[config.id] ?? isActive; + // When sidebar is collapsed on desktop and category is collapse-only, + // show a popover flyout instead of the hidden collapsible sub-items + const isCollapsed = sidebarState === 'collapsed' && !isMobile; + const showPopover = isCollapsed && isCollapseOnly; + + // Shared entity list renderer used by both popover and collapsible + const renderEntityList = (inPopover: boolean) => { + const sortedItems = sortByRecent(items); + const isExpanded = expandedLists[config.id] ?? false; + const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS; + const visibleItems = + sortedItems.length > maxItems && !isExpanded + ? sortedItems.slice(0, maxItems) + : sortedItems; + const hiddenCount = sortedItems.length - maxItems; + + if (sortedItems.length === 0) { + return ( +
+ {t('common.noItems')} +
+ ); + } + + return ( + <> + {visibleItems.map((item) => { + const itemRoute = hasDetailPages + ? `${routePrefix}?id=${encodeURIComponent(item.id)}` + : routePrefix; + const isItemActive = + hasDetailPages && + pathname === routePrefix && + searchParams.get('id') === item.id; + + if (inPopover) { + return ( + + ); + } + + // Normal sidebar sub-item rendering + return ( + + + + + { + e.preventDefault(); + router.push(itemRoute); + }} + > + {item.emoji ? ( + + {item.emoji} + + ) : item.iconURL ? ( + + + {isBot && ( + + )} + + ) : null} + {item.name} + {item.debug && ( + + )} + + + + {item.description && ( + + {item.description.length > 80 + ? item.description.slice(0, 80) + '…' + : item.description} + + )} + + {/* Plugin context menu - shown on hover (not for debug plugins) */} + {isPlugin && !item.debug && ( + handlePluginUpdate(item)} + onDelete={() => handlePluginDelete(item)} + /> + )} + + ); + })} + {/* Show more / less toggle when items exceed limit */} + {sortedItems.length > maxItems && !inPopover && ( + + + + + + )} + {hiddenCount > 0 && inPopover && !isExpanded && ( + + )} + + ); + }; + + // Popover flyout for collapsed sidebar + if (showPopover) { + return ( + + + setPopoverOpen((prev) => ({ ...prev, [config.id]: open })) + } + > + + + {config.icon} + {config.name} + + + +
+ {config.name} + {canCreate && ( + + )} +
+
+ {renderEntityList(true)} +
+
+
+
+ ); + } + + // Normal expanded sidebar with collapsible sub-items return ( - - {(() => { - const sortedItems = sortByRecent(items); - const isExpanded = expandedLists[config.id] ?? false; - const visibleItems = - sortedItems.length > MAX_VISIBLE_ITEMS && !isExpanded - ? sortedItems.slice(0, MAX_VISIBLE_ITEMS) - : sortedItems; - const hiddenCount = sortedItems.length - MAX_VISIBLE_ITEMS; - - return ( - <> - {visibleItems.map((item) => { - // Plugins navigate to the list page; others use ?id= query param - const itemRoute = hasDetailPages - ? `${routePrefix}?id=${encodeURIComponent(item.id)}` - : routePrefix; - const isItemActive = - hasDetailPages && - pathname === routePrefix && - searchParams.get('id') === item.id; - return ( - - - - - { - e.preventDefault(); - router.push(itemRoute); - }} - > - {item.emoji ? ( - - {item.emoji} - - ) : item.iconURL ? ( - - - {isBot && ( - - )} - - ) : null} - - {item.name} - - {item.debug && ( - - )} - - - - {item.description && ( - - {item.description.length > 80 - ? item.description.slice(0, 80) + '…' - : item.description} - - )} - - {/* Plugin context menu - shown on hover (not for debug plugins) */} - {isPlugin && !item.debug && ( - handlePluginUpdate(item)} - onDelete={() => handlePluginDelete(item)} - /> - )} - - ); - })} - {/* Show more / less toggle when items exceed limit */} - {sortedItems.length > MAX_VISIBLE_ITEMS && ( - - - - - - )} - - ); - })()} - + {renderEntityList(false)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 4d9d9ce6..dabb6ec6 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -145,6 +145,7 @@ const enUS = { none: 'None', more: 'More ({{count}})', less: 'Less', + noItems: 'No items', }, notFound: { title: 'Page not found', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d227608a..abad1674 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -147,6 +147,7 @@ none: 'なし', more: 'もっと見る ({{count}})', less: '折りたたむ', + noItems: '項目がありません', }, notFound: { title: 'ページが見つかりません', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9f57580f..489679ab 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -136,6 +136,7 @@ const zhHans = { none: '无', more: '更多 ({{count}})', less: '收起', + noItems: '暂无内容', }, notFound: { title: '页面不存在', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 13793238..3a08fe8e 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -136,6 +136,7 @@ const zhHant = { none: '無', more: '更多 ({{count}})', less: '收起', + noItems: '暫無內容', }, notFound: { title: '頁面不存在',