mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 13:26:08 +00:00
feat: persist sidebar list expansion
This commit is contained in:
@@ -186,6 +186,20 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
|||||||
|
|
||||||
// localStorage key for collapsible section open/closed state
|
// localStorage key for collapsible section open/closed state
|
||||||
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
|
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
|
||||||
|
const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion';
|
||||||
|
|
||||||
|
type SidebarNavSection = 'home' | 'extensions';
|
||||||
|
type SidebarListExpansionState = Record<
|
||||||
|
SidebarNavSection,
|
||||||
|
Partial<Record<EntityCategoryId, boolean>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function createEmptyListExpansionState(): SidebarListExpansionState {
|
||||||
|
return {
|
||||||
|
home: {},
|
||||||
|
extensions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadSectionState(): Record<string, boolean> {
|
function loadSectionState(): Record<string, boolean> {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
@@ -205,6 +219,29 @@ function saveSectionState(state: Record<string, boolean>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadListExpansionState(): SidebarListExpansionState {
|
||||||
|
if (typeof window === 'undefined') return createEmptyListExpansionState();
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SIDEBAR_LIST_EXPANSION_KEY);
|
||||||
|
if (!stored) return createEmptyListExpansionState();
|
||||||
|
const parsed = JSON.parse(stored) as Partial<SidebarListExpansionState>;
|
||||||
|
return {
|
||||||
|
home: parsed.home ?? {},
|
||||||
|
extensions: parsed.extensions ?? {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return createEmptyListExpansionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveListExpansionState(state: SidebarListExpansionState) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_LIST_EXPANSION_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Maximum number of entity sub-items visible before "More" toggle
|
// Maximum number of entity sub-items visible before "More" toggle
|
||||||
const MAX_VISIBLE_ITEMS = 5;
|
const MAX_VISIBLE_ITEMS = 5;
|
||||||
|
|
||||||
@@ -270,7 +307,7 @@ function NavItems({
|
|||||||
}: {
|
}: {
|
||||||
selectedChild: SidebarChildVO | undefined;
|
selectedChild: SidebarChildVO | undefined;
|
||||||
onChildClick: (child: SidebarChildVO) => void;
|
onChildClick: (child: SidebarChildVO) => void;
|
||||||
section: 'home' | 'extensions';
|
section: SidebarNavSection;
|
||||||
sectionOpenState: Record<string, boolean>;
|
sectionOpenState: Record<string, boolean>;
|
||||||
onSectionToggle: (id: string, open: boolean) => void;
|
onSectionToggle: (id: string, open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -284,8 +321,8 @@ function NavItems({
|
|||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Track which entity categories have their full list expanded
|
// Track which entity categories have their full list expanded
|
||||||
const [expandedLists, setExpandedLists] = useState<Record<string, boolean>>(
|
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
|
||||||
{},
|
loadListExpansionState,
|
||||||
);
|
);
|
||||||
// Track popover open state for collapsed sidebar entity categories
|
// Track popover open state for collapsed sidebar entity categories
|
||||||
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
|
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
|
||||||
@@ -356,6 +393,21 @@ function NavItems({
|
|||||||
|
|
||||||
const sectionItems = sidebarConfigList.filter((c) => c.section === section);
|
const sectionItems = sidebarConfigList.filter((c) => c.section === section);
|
||||||
|
|
||||||
|
function handleListExpansionToggle(id: EntityCategoryId, expanded: boolean) {
|
||||||
|
setExpandedLists(() => {
|
||||||
|
const latest = loadListExpansionState();
|
||||||
|
const next = {
|
||||||
|
...latest,
|
||||||
|
[section]: {
|
||||||
|
...latest[section],
|
||||||
|
[id]: expanded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveListExpansionState(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Persist open state for sections that become active through navigation,
|
// Persist open state for sections that become active through navigation,
|
||||||
// so they remain expanded when the user switches to a different section.
|
// so they remain expanded when the user switches to a different section.
|
||||||
const sectionOpenRef = useRef(sectionOpenState);
|
const sectionOpenRef = useRef(sectionOpenState);
|
||||||
@@ -392,8 +444,9 @@ function NavItems({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Entity categories: collapsible with sub-items
|
// Entity categories: collapsible with sub-items
|
||||||
const entityKey = ENTITY_KEY_MAP[config.id];
|
const categoryId = config.id;
|
||||||
const isExtensionsCategory = config.id === 'plugins';
|
const entityKey = ENTITY_KEY_MAP[categoryId];
|
||||||
|
const isExtensionsCategory = categoryId === 'plugins';
|
||||||
const items: SidebarEntityItem[] = isExtensionsCategory
|
const items: SidebarEntityItem[] = isExtensionsCategory
|
||||||
? [
|
? [
|
||||||
...sidebarData.plugins.map((p) => ({
|
...sidebarData.plugins.map((p) => ({
|
||||||
@@ -410,14 +463,14 @@ function NavItems({
|
|||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
: sidebarData[entityKey];
|
: sidebarData[entityKey];
|
||||||
const routePrefix = ENTITY_ROUTE_MAP[config.id];
|
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
|
||||||
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id);
|
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
|
||||||
const canCreate = CREATABLE_CATEGORIES.includes(config.id);
|
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
|
||||||
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
|
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
|
||||||
const isPlugin = config.id === 'plugins';
|
const isPlugin = categoryId === 'plugins';
|
||||||
const isSkill = config.id === 'skills';
|
const isSkill = categoryId === 'skills';
|
||||||
const isBot = config.id === 'bots';
|
const isBot = categoryId === 'bots';
|
||||||
const isMCP = config.id === 'mcp';
|
const isMCP = categoryId === 'mcp';
|
||||||
|
|
||||||
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||||
if (item.extensionType === 'mcp') {
|
if (item.extensionType === 'mcp') {
|
||||||
@@ -431,12 +484,12 @@ function NavItems({
|
|||||||
: routePrefix;
|
: routePrefix;
|
||||||
};
|
};
|
||||||
const isActive =
|
const isActive =
|
||||||
selectedChild?.id === config.id ||
|
selectedChild?.id === categoryId ||
|
||||||
pathname === routePrefix ||
|
pathname === routePrefix ||
|
||||||
pathname.startsWith(routePrefix + '/');
|
pathname.startsWith(routePrefix + '/');
|
||||||
|
|
||||||
// Use stored open state if available, otherwise default to active state
|
// Use stored open state if available, otherwise default to active state
|
||||||
const isOpen = sectionOpenState[config.id] ?? isActive;
|
const isOpen = sectionOpenState[categoryId] ?? isActive;
|
||||||
|
|
||||||
// When sidebar is collapsed on desktop and category is collapse-only,
|
// When sidebar is collapsed on desktop and category is collapse-only,
|
||||||
// show a popover flyout instead of the hidden collapsible sub-items
|
// show a popover flyout instead of the hidden collapsible sub-items
|
||||||
@@ -452,7 +505,7 @@ function NavItems({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
: sortByRecent(items);
|
: sortByRecent(items);
|
||||||
const isExpanded = expandedLists[config.id] ?? false;
|
const isExpanded = expandedLists[section]?.[categoryId] ?? false;
|
||||||
const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
|
const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
|
||||||
const visibleItems =
|
const visibleItems =
|
||||||
sortedItems.length > maxItems && !isExpanded
|
sortedItems.length > maxItems && !isExpanded
|
||||||
@@ -530,7 +583,7 @@ function NavItems({
|
|||||||
navigate(itemRoute);
|
navigate(itemRoute);
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[categoryId]: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -692,10 +745,7 @@ function NavItems({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedLists((prev) => ({
|
handleListExpansionToggle(categoryId, !isExpanded)
|
||||||
...prev,
|
|
||||||
[config.id]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
@@ -711,12 +761,7 @@ function NavItems({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-center rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
|
className="flex w-full items-center justify-center rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
|
||||||
onClick={() =>
|
onClick={() => handleListExpansionToggle(categoryId, true)}
|
||||||
setExpandedLists((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[config.id]: true,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('common.more', { count: hiddenCount })}
|
{t('common.more', { count: hiddenCount })}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div
|
<div className="flex w-full items-center gap-2 px-4">
|
||||||
className={`mx-auto flex w-full items-center gap-2 px-4 ${HOME_CONTENT_MAX_WIDTH}`}
|
|
||||||
>
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
|
|||||||
Reference in New Issue
Block a user