From fb6f688049ad1029946e632e6b6b1fbf9d39d2b6 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:57:37 +0400 Subject: [PATCH] refactor(frontend): convert settings to vertical sidebar layout (#10971) Co-authored-by: openhands --- frontend/__tests__/routes/settings.test.tsx | 2 +- frontend/package-lock.json | 12 --- .../account-settings-context-menu.tsx | 90 ++--------------- .../features/payment/payment-form.tsx | 2 +- .../src/components/features/settings/index.ts | 3 + .../features/settings/mobile-header.tsx | 55 +++++++++++ .../features/settings/settings-layout.tsx | 55 +++++++++++ .../features/settings/settings-navigation.tsx | 96 ++++++++++++++++++ .../settings/upgrade-banner-with-backdrop.tsx | 3 +- .../features/settings/upgrade-banner.tsx | 2 +- frontend/src/constants/settings-nav.tsx | 84 ++++++++++++++++ frontend/src/routes/api-keys.tsx | 2 +- frontend/src/routes/app-settings.tsx | 4 +- frontend/src/routes/git-settings.tsx | 4 +- frontend/src/routes/llm-settings.tsx | 4 +- frontend/src/routes/mcp-settings.tsx | 4 +- frontend/src/routes/secrets-settings.tsx | 5 +- frontend/src/routes/settings.tsx | 99 ++++++++----------- frontend/src/routes/user-settings.tsx | 2 +- frontend/src/ui/typography.tsx | 14 +++ 20 files changed, 373 insertions(+), 169 deletions(-) create mode 100644 frontend/src/components/features/settings/mobile-header.tsx create mode 100644 frontend/src/components/features/settings/settings-layout.tsx create mode 100644 frontend/src/components/features/settings/settings-navigation.tsx create mode 100644 frontend/src/constants/settings-nav.tsx diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index c433e091aa..09031d4f86 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -133,7 +133,7 @@ describe("Settings Screen", () => { "user", "integrations", "application", - "billing", // The nav item shows "billing" text and routes to /billing + "billing", // The nav item shows "Billing" text and routes to /billing "secrets", "api keys", ]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72b7dbede6..46c6b9b0d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10779,18 +10779,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", - "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index b3040f4896..8c212dd5fc 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ContextMenu } from "#/ui/context-menu"; @@ -6,91 +7,14 @@ import { Divider } from "#/ui/divider"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; -import CreditCardIcon from "#/icons/credit-card.svg?react"; -import KeyIcon from "#/icons/key.svg?react"; import LogOutIcon from "#/icons/log-out.svg?react"; -import ServerProcessIcon from "#/icons/server-process.svg?react"; -import SettingsGearIcon from "#/icons/settings-gear.svg?react"; -import CircuitIcon from "#/icons/u-circuit.svg?react"; -import PuzzlePieceIcon from "#/icons/u-puzzle-piece.svg?react"; -import UserIcon from "#/icons/user.svg?react"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; interface AccountSettingsContextMenuProps { onLogout: () => void; onClose: () => void; } -const SAAS_NAV_ITEMS = [ - { - icon: , - to: "/settings/user", - text: "COMMON$USER_SETTINGS", - }, - { - icon: , - to: "/settings/integrations", - text: "SETTINGS$NAV_INTEGRATIONS", - }, - { - icon: , - to: "/settings/app", - text: "COMMON$APPLICATION_SETTINGS", - }, - { - icon: , - to: "/settings", - text: "COMMON$LANGUAGE_MODEL_LLM", - }, - { - icon: , - to: "/settings/billing", - text: "SETTINGS$NAV_BILLING", - }, - { - icon: , - to: "/settings/secrets", - text: "SETTINGS$NAV_SECRETS", - }, - { - icon: , - to: "/settings/api-keys", - text: "SETTINGS$NAV_API_KEYS", - }, - { - icon: , - to: "/settings/mcp", - text: "SETTINGS$NAV_MCP", - }, -]; - -const OSS_NAV_ITEMS = [ - { - icon: , - to: "/settings", - text: "COMMON$LANGUAGE_MODEL_LLM", - }, - { - icon: , - to: "/settings/mcp", - text: "COMMON$MODEL_CONTEXT_PROTOCOL_MCP", - }, - { - icon: , - to: "/settings/integrations", - text: "SETTINGS$NAV_INTEGRATIONS", - }, - { - icon: , - to: "/settings/app", - text: "COMMON$APPLICATION_SETTINGS", - }, - { - icon: , - to: "/settings/secrets", - text: "SETTINGS$NAV_SECRETS", - }, -]; - export function AccountSettingsContextMenu({ onLogout, onClose, @@ -100,7 +24,13 @@ export function AccountSettingsContextMenu({ const { data: config } = useConfig(); const isSaas = config?.APP_MODE === "saas"; - const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + const navItems = (isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS).map((item) => ({ + ...item, + icon: React.cloneElement(item.icon, { + width: 16, + height: 16, + } as React.SVGProps), + })); const handleNavigationClick = () => { onClose(); @@ -112,7 +42,7 @@ export function AccountSettingsContextMenu({ testId="account-settings-context-menu" ref={ref} alignment="right" - className="mt-0 md:right-full md:left-full md:bottom-0 ml-0 z-10 w-fit z-[9999]" + className="mt-0 md:right-full md:left-full md:bottom-0 ml-0 w-fit z-[9999]" > {navItems.map(({ to, text, icon }) => ( diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 9f82e38e5f..8555c3341d 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -51,7 +51,7 @@ export function PaymentForm() {
void; +} + +export function MobileHeader({ + isMobileMenuOpen, + onToggleMenu, +}: MobileHeaderProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {t(I18nKey.SETTINGS$TITLE)} +
+ +
+ ); +} diff --git a/frontend/src/components/features/settings/settings-layout.tsx b/frontend/src/components/features/settings/settings-layout.tsx new file mode 100644 index 0000000000..73b47f7768 --- /dev/null +++ b/frontend/src/components/features/settings/settings-layout.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { MobileHeader } from "./mobile-header"; +import { SettingsNavigation } from "./settings-navigation"; + +interface NavigationItem { + to: string; + icon: React.ReactNode; + text: string; +} + +interface SettingsLayoutProps { + children: React.ReactNode; + navigationItems: NavigationItem[]; + isSaas: boolean; +} + +export function SettingsLayout({ + children, + navigationItems, + isSaas, +}: SettingsLayoutProps) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + const toggleMobileMenu = () => { + setIsMobileMenuOpen(!isMobileMenuOpen); + }; + + const closeMobileMenu = () => { + setIsMobileMenuOpen(false); + }; + + return ( +
+ {/* Mobile header */} + + + {/* Desktop layout with navigation and main content */} +
+ {/* Navigation */} + + + {/* Main content */} +
{children}
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx new file mode 100644 index 0000000000..7eb5be0c71 --- /dev/null +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from "react-i18next"; +import { NavLink } from "react-router"; +import { cn } from "#/utils/utils"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import SettingsIcon from "#/icons/settings-gear.svg?react"; +import CloseIcon from "#/icons/close.svg?react"; +import { ProPill } from "./pro-pill"; + +interface NavigationItem { + to: string; + icon: React.ReactNode; + text: string; +} + +interface SettingsNavigationProps { + isMobileMenuOpen: boolean; + onCloseMobileMenu: () => void; + navigationItems: NavigationItem[]; + isSaas: boolean; +} + +export function SettingsNavigation({ + isMobileMenuOpen, + onCloseMobileMenu, + navigationItems, + isSaas, +}: SettingsNavigationProps) { + const { t } = useTranslation(); + + return ( + <> + {/* Mobile backdrop */} + {isMobileMenuOpen && ( +
+ )} + + {/* Navigation sidebar */} + + + ); +} diff --git a/frontend/src/components/features/settings/upgrade-banner-with-backdrop.tsx b/frontend/src/components/features/settings/upgrade-banner-with-backdrop.tsx index 369869aa92..506bb71ec2 100644 --- a/frontend/src/components/features/settings/upgrade-banner-with-backdrop.tsx +++ b/frontend/src/components/features/settings/upgrade-banner-with-backdrop.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { useTranslation } from "react-i18next"; import { UpgradeBanner } from "#/components/features/settings"; @@ -18,7 +17,7 @@ export function UpgradeBannerWithBackdrop({
, + to: "/settings/user", + text: "SETTINGS$NAV_USER", + }, + { + icon: , + to: "/settings/integrations", + text: "SETTINGS$NAV_INTEGRATIONS", + }, + { + icon: , + to: "/settings/app", + text: "SETTINGS$NAV_APPLICATION", + }, + { + icon: , + to: "/settings", + text: "COMMON$LANGUAGE_MODEL_LLM", + }, + { + icon: , + to: "/settings/billing", + text: "SETTINGS$NAV_BILLING", + }, + { + icon: , + to: "/settings/secrets", + text: "SETTINGS$NAV_SECRETS", + }, + { + icon: , + to: "/settings/api-keys", + text: "SETTINGS$NAV_API_KEYS", + }, + { + icon: , + to: "/settings/mcp", + text: "SETTINGS$NAV_MCP", + }, +]; + +export const OSS_NAV_ITEMS: SettingsNavItem[] = [ + { + icon: , + to: "/settings", + text: "SETTINGS$NAV_LLM", + }, + { + icon: , + to: "/settings/mcp", + text: "SETTINGS$NAV_MCP", + }, + { + icon: , + to: "/settings/integrations", + text: "SETTINGS$NAV_INTEGRATIONS", + }, + { + icon: , + to: "/settings/app", + text: "SETTINGS$NAV_APPLICATION", + }, + { + icon: , + to: "/settings/secrets", + text: "SETTINGS$NAV_SECRETS", + }, +]; diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx index 96669b37e6..e5d733ecb7 100644 --- a/frontend/src/routes/api-keys.tsx +++ b/frontend/src/routes/api-keys.tsx @@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager" function ApiKeysScreen() { return ( -
+
); diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index f6b64c0b01..71d0230d5d 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -187,7 +187,7 @@ function AppSettingsScreen() { > {shouldBeLoading && } {!shouldBeLoading && ( -
+
)} -
+
{!isLoading && ( -
+
{shouldRenderExternalConfigureButtons && !isLoading && ( <>
@@ -202,7 +202,7 @@ function GitSettingsScreen() { {isLoading && } -
+
{!shouldRenderExternalConfigureButtons && ( <> -
+
-
+
+
@@ -137,7 +137,7 @@ function MCPSettingsScreen() { } return ( -
+
{view === "list" && ( <> +
{isLoadingSecrets && view === "list" && (
    diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 6c88ab7526..88398acd66 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,14 +1,17 @@ -import { NavLink, Outlet, redirect } from "react-router"; +import { useMemo } from "react"; +import { Outlet, redirect, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; -import SettingsIcon from "#/icons/settings.svg?react"; -import { cn } from "#/utils/utils"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; -import { ProPill } from "#/components/features/settings/pro-pill"; import { GetConfigResponse } from "#/api/option-service/option.types"; +import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import CircuitIcon from "#/icons/u-circuit.svg?react"; +import { Typography } from "#/ui/typography"; +import { SettingsLayout } from "#/components/features/settings/settings-layout"; const SAAS_ONLY_PATHS = [ "/settings/user", @@ -17,25 +20,6 @@ const SAAS_ONLY_PATHS = [ "/settings/api-keys", ]; -const SAAS_NAV_ITEMS = [ - { to: "/settings/user", text: "SETTINGS$NAV_USER" }, - { to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" }, - { to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" }, - { to: "/settings", text: "SETTINGS$NAV_LLM" }, - { to: "/settings/billing", text: "SETTINGS$NAV_BILLING" }, - { to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" }, - { to: "/settings/api-keys", text: "SETTINGS$NAV_API_KEYS" }, - { to: "/settings/mcp", text: "SETTINGS$NAV_MCP" }, -]; - -const OSS_NAV_ITEMS = [ - { to: "/settings", text: "SETTINGS$NAV_LLM" }, - { to: "/settings/mcp", text: "SETTINGS$NAV_MCP" }, - { to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" }, - { to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" }, - { to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" }, -]; - export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; @@ -59,46 +43,45 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { function SettingsScreen() { const { t } = useTranslation(); const { data: config } = useConfig(); + const { data: subscriptionAccess } = useSubscriptionAccess(); + const location = useLocation(); const isSaas = config?.APP_MODE === "saas"; - const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + // Navigation items configuration + const navItems = useMemo(() => { + const items = []; + if (isSaas) { + if (subscriptionAccess) { + items.push({ + icon: , + to: "/settings", + text: "SETTINGS$NAV_LLM" as I18nKey, + }); + } + items.push(...SAAS_NAV_ITEMS); + } else { + items.push(...OSS_NAV_ITEMS); + } + return items; + }, [isSaas, !!subscriptionAccess]); + + // Current section title for the main content area + const currentSectionTitle = useMemo(() => { + const currentItem = navItems.find((item) => item.to === location.pathname); + return currentItem ? currentItem.text : "SETTINGS$NAV_LLM"; + }, [navItems, location.pathname]); return ( -
    -
    - -

    {t(I18nKey.SETTINGS$TITLE)}

    -
    - - - -
    - -
    +
    + +
    + {t(currentSectionTitle)} +
    + +
    +
    +
    ); } diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx index a5fb51bbf6..93366574b0 100644 --- a/frontend/src/routes/user-settings.tsx +++ b/frontend/src/routes/user-settings.tsx @@ -203,7 +203,7 @@ function UserSettingsScreen() { return (
    -
    +
    {isLoading ? (
    ) : ( diff --git a/frontend/src/ui/typography.tsx b/frontend/src/ui/typography.tsx index 86668efa07..fc574d6f51 100644 --- a/frontend/src/ui/typography.tsx +++ b/frontend/src/ui/typography.tsx @@ -5,6 +5,7 @@ const typographyVariants = cva("", { variants: { variant: { h1: "text-[32px] text-white font-bold leading-5", + h2: "text-xl font-semibold leading-6 -tracking-[0.02em] text-white", h3: "text-sm font-semibold text-gray-300", span: "text-sm font-normal text-white leading-5.5", codeBlock: @@ -53,6 +54,18 @@ export function H1({ ); } +export function H2({ + className, + testId, + children, +}: Omit) { + return ( + + {children} + + ); +} + export function H3({ className, testId, @@ -91,6 +104,7 @@ export function CodeBlock({ // Attach components to Typography for the expected API Typography.H1 = H1; +Typography.H2 = H2; Typography.H3 = H3; Typography.Text = Text; Typography.CodeBlock = CodeBlock;