mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
refactor(frontend): convert settings to vertical sidebar layout (#10971)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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",
|
||||
];
|
||||
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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: <UserIcon width={16} height={16} />,
|
||||
to: "/settings/user",
|
||||
text: "COMMON$USER_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={16} height={16} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={16} height={16} />,
|
||||
to: "/settings/app",
|
||||
text: "COMMON$APPLICATION_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={16} height={16} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <CreditCardIcon width={16} height={16} />,
|
||||
to: "/settings/billing",
|
||||
text: "SETTINGS$NAV_BILLING",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
to: "/settings/secrets",
|
||||
text: "SETTINGS$NAV_SECRETS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
to: "/settings/api-keys",
|
||||
text: "SETTINGS$NAV_API_KEYS",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={16} height={16} />,
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
{
|
||||
icon: <CircuitIcon width={16} height={16} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={16} height={16} />,
|
||||
to: "/settings/mcp",
|
||||
text: "COMMON$MODEL_CONTEXT_PROTOCOL_MCP",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={16} height={16} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={16} height={16} />,
|
||||
to: "/settings/app",
|
||||
text: "COMMON$APPLICATION_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
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<SVGSVGElement>),
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<Link key={to} to={to} className="text-decoration-none">
|
||||
|
||||
@@ -51,7 +51,7 @@ export function PaymentForm() {
|
||||
<form
|
||||
action={billingFormAction}
|
||||
data-testid="billing-settings"
|
||||
className="flex flex-col gap-6 px-11 py-9"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { UpgradeBanner } from "./upgrade-banner";
|
||||
export { UpgradeButton } from "./upgrade-button";
|
||||
export { BannerMessage } from "./banner-message";
|
||||
export { MobileHeader } from "./mobile-header";
|
||||
export { SettingsNavigation } from "./settings-navigation";
|
||||
export { SettingsLayout } from "./settings-layout";
|
||||
|
||||
55
frontend/src/components/features/settings/mobile-header.tsx
Normal file
55
frontend/src/components/features/settings/mobile-header.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings-gear.svg?react";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
isMobileMenuOpen: boolean;
|
||||
onToggleMenu: () => void;
|
||||
}
|
||||
|
||||
export function MobileHeader({
|
||||
isMobileMenuOpen,
|
||||
onToggleMenu,
|
||||
}: MobileHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4 md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<Typography.H2>{t(I18nKey.SETTINGS$TITLE)}</Typography.H2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMenu}
|
||||
className="p-2 rounded-md bg-tertiary hover:bg-[#454545] transition-colors"
|
||||
aria-label="Toggle settings menu"
|
||||
>
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full px-[14px] pt-8">
|
||||
{/* Mobile header */}
|
||||
<MobileHeader
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onToggleMenu={toggleMobileMenu}
|
||||
/>
|
||||
|
||||
{/* Desktop layout with navigation and main content */}
|
||||
<div className="flex flex-1 overflow-hidden gap-10">
|
||||
{/* Navigation */}
|
||||
<SettingsNavigation
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={closeMobileMenu}
|
||||
navigationItems={navigationItems}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
|
||||
onClick={onCloseMobileMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 transition-transform duration-300 ease-in-out",
|
||||
// Mobile: full screen overlay
|
||||
"fixed inset-0 z-50 w-full bg-base-secondary p-4 transform md:transform-none",
|
||||
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full",
|
||||
// Desktop: static sidebar
|
||||
"md:relative md:translate-x-0 md:w-64 md:p-0 md:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 ml-1 sm:ml-4.5">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<Typography.H2>{t(I18nKey.SETTINGS$TITLE)}</Typography.H2>
|
||||
</div>
|
||||
{/* Close button - only visible on mobile */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseMobileMenu}
|
||||
className="md:hidden p-0.5 hover:bg-[#454545] rounded-md transition-colors"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<CloseIcon width={32} height={32} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{navigationItems.map(({ to, icon, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={onCloseMobileMenu}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 p-1 sm:px-[14px] sm:py-2 rounded-md transition-colors",
|
||||
isActive ? "bg-[#454545]" : "hover:bg-[#454545]",
|
||||
)
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
{isSaas && to === "/settings" && <ProPill />}
|
||||
</div>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<UpgradeBanner
|
||||
message={t("SETTINGS$UPGRADE_BANNER_MESSAGE")}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
className="sticky top-0 z-30"
|
||||
className="sticky top-0 z-30 mb-6"
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,7 @@ export function UpgradeBanner({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-base flex items-center justify-center gap-3 p-2 w-full",
|
||||
"bg-primary text-base flex items-center justify-center gap-3 p-2 w-full rounded",
|
||||
className,
|
||||
)}
|
||||
data-testid="upgrade-banner"
|
||||
|
||||
84
frontend/src/constants/settings-nav.tsx
Normal file
84
frontend/src/constants/settings-nav.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import CreditCardIcon from "#/icons/credit-card.svg?react";
|
||||
import KeyIcon from "#/icons/key.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";
|
||||
|
||||
export interface SettingsNavItem {
|
||||
icon: React.ReactElement;
|
||||
to: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
{
|
||||
icon: <UserIcon width={22} height={22} />,
|
||||
to: "/settings/user",
|
||||
text: "SETTINGS$NAV_USER",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={22} height={22} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={22} height={22} />,
|
||||
to: "/settings/app",
|
||||
text: "SETTINGS$NAV_APPLICATION",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={22} height={22} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <CreditCardIcon width={22} height={22} />,
|
||||
to: "/settings/billing",
|
||||
text: "SETTINGS$NAV_BILLING",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={22} height={22} />,
|
||||
to: "/settings/secrets",
|
||||
text: "SETTINGS$NAV_SECRETS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={22} height={22} />,
|
||||
to: "/settings/api-keys",
|
||||
text: "SETTINGS$NAV_API_KEYS",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={22} height={22} />,
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
];
|
||||
|
||||
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
{
|
||||
icon: <CircuitIcon width={22} height={22} />,
|
||||
to: "/settings",
|
||||
text: "SETTINGS$NAV_LLM",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={22} height={22} />,
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon width={22} height={22} />,
|
||||
to: "/settings/integrations",
|
||||
text: "SETTINGS$NAV_INTEGRATIONS",
|
||||
},
|
||||
{
|
||||
icon: <SettingsGearIcon width={22} height={22} />,
|
||||
to: "/settings/app",
|
||||
text: "SETTINGS$NAV_APPLICATION",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={22} height={22} />,
|
||||
to: "/settings/secrets",
|
||||
text: "SETTINGS$NAV_SECRETS",
|
||||
},
|
||||
];
|
||||
@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
|
||||
|
||||
function ApiKeysScreen() {
|
||||
return (
|
||||
<div className="flex flex-col grow overflow-auto p-9">
|
||||
<div className="flex flex-col grow overflow-auto">
|
||||
<ApiKeysManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -187,7 +187,7 @@ function AppSettingsScreen() {
|
||||
>
|
||||
{shouldBeLoading && <AppSettingsInputsSkeleton />}
|
||||
{!shouldBeLoading && (
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<LanguageInput
|
||||
name="language-input"
|
||||
defaultKey={settings.LANGUAGE}
|
||||
@@ -282,7 +282,7 @@ function AppSettingsScreen() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
variant="primary"
|
||||
|
||||
@@ -123,7 +123,7 @@ function GitSettingsScreen() {
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="p-9 flex flex-col">
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 flex flex-col">
|
||||
@@ -202,7 +202,7 @@ function GitSettingsScreen() {
|
||||
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<>
|
||||
<BrandButton
|
||||
|
||||
@@ -476,7 +476,7 @@ function LlmSettingsScreen() {
|
||||
)}
|
||||
inert={shouldShowUpgradeBanner}
|
||||
>
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
@@ -751,7 +751,7 @@ function LlmSettingsScreen() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
|
||||
@@ -126,7 +126,7 @@ function MCPSettingsScreen() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-300 rounded w-1/4 mb-4" />
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mb-8" />
|
||||
@@ -137,7 +137,7 @@ function MCPSettingsScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
{view === "list" && (
|
||||
<>
|
||||
<BrandButton
|
||||
|
||||
@@ -62,10 +62,7 @@ function SecretsSettingsScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="secrets-settings-screen"
|
||||
className="px-11 py-9 flex flex-col gap-5"
|
||||
>
|
||||
<div data-testid="secrets-settings-screen" className="flex flex-col gap-5">
|
||||
{isLoadingSecrets && view === "list" && (
|
||||
<ul>
|
||||
<SecretListItemSkeleton />
|
||||
|
||||
@@ -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: <CircuitIcon width={22} height={22} />,
|
||||
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 (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
className="bg-base-secondary border border-tertiary h-full rounded-xl flex flex-col"
|
||||
>
|
||||
<header className="px-3 py-1.5 border-b border-b-tertiary flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-6 px-3 md:px-9 border-b border-tertiary"
|
||||
>
|
||||
{navItems.map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5 px-4 min-w-[40px] flex items-center justify-center relative",
|
||||
isActive && "border-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[#F9FBFE] text-sm">{t(text)}</span>
|
||||
{isSaas && to === "/settings" && <ProPill className="ml-2" />}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col grow overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
<main data-testid="settings-screen" className="h-full">
|
||||
<SettingsLayout navigationItems={navItems} isSaas={isSaas}>
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ function UserSettingsScreen() {
|
||||
|
||||
return (
|
||||
<div data-testid="user-settings-screen" className="flex flex-col h-full">
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse h-8 w-64 bg-tertiary rounded-sm" />
|
||||
) : (
|
||||
|
||||
@@ -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<TypographyProps, "variant">) {
|
||||
return (
|
||||
<Typography variant="h2" className={className} testId={testId}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user