diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index dda483404..6e206e16b 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5902,6 +5902,9 @@ title = "Policies & Privacy" [settings.preferences] title = "Preferences" +[settings.search] +placeholder = "Search settings pages..." + [settings.security] description = "Update your password to keep your account secure." title = "Security" diff --git a/frontend/src/core/components/shared/AppConfigModal.css b/frontend/src/core/components/shared/AppConfigModal.css index 5db477a12..67deb733c 100644 --- a/frontend/src/core/components/shared/AppConfigModal.css +++ b/frontend/src/core/components/shared/AppConfigModal.css @@ -163,6 +163,16 @@ padding-top: 1rem; } +.settings-search-select { + min-width: 10rem; +} + +.settings-search-option { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + .confirm-modal-content { display: flex; flex-direction: column; @@ -214,4 +224,4 @@ .settings-section-content { padding-bottom: 4rem; } -} \ No newline at end of file +} diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 26806bcf5..30253ea45 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core'; import { useNavigate, useLocation } from 'react-router-dom'; import LocalIcon from '@app/components/shared/LocalIcon'; @@ -10,6 +10,7 @@ import { useIsMobile } from '@app/hooks/useIsMobile'; import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { useLicenseAlert } from '@app/hooks/useLicenseAlert'; import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext'; +import { SettingsSearchBar } from '@app/components/shared/config/SettingsSearchBar'; interface AppConfigModalProps { opened: boolean; @@ -24,6 +25,7 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) const { config } = useAppConfig(); const licenseAlert = useLicenseAlert(); const { confirmIfDirty } = useUnsavedChanges(); + const closeButtonRef = useRef(null); // Extract section from URL path (e.g., /settings/people -> people) const getSectionFromPath = (pathname: string): NavKey | null => { @@ -46,6 +48,13 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) } }, [location.pathname, opened, navigate]); + useEffect(() => { + if (opened) { + // Keep search closed initially by moving autofocus away from the searchable Select input. + closeButtonRef.current?.focus(); + } + }, [opened]); + // Handle custom events for backwards compatibility useEffect(() => { const handler = (ev: Event) => { @@ -108,7 +117,7 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) const handleNavigation = useCallback(async (key: NavKey) => { const canProceed = await confirmIfDirty(); if (!canProceed) return; - + setActive(key); navigate(`/settings/${key}`); }, [confirmIfDirty, navigate]); @@ -158,12 +167,16 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) const navItemContent = (
handleNavigation(item.key)} + onClick={() => { + if (!isDisabled) { + handleNavigation(item.key); + } + }} className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} style={{ background: isActive ? colors.navItemActiveBg : 'transparent', opacity: isDisabled ? 0.6 : 1, - cursor: 'pointer', + cursor: isDisabled ? 'not-allowed' : 'pointer', }} data-tour={`admin-${item.key}-nav`} > @@ -218,9 +231,22 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) }} > {activeLabel} - - - + + + + + +
{activeComponent} diff --git a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx new file mode 100644 index 000000000..7dd1f55b7 --- /dev/null +++ b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx @@ -0,0 +1,217 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { Select, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections'; + +interface SettingsSearchBarProps { + configNavSections: ConfigNavSection[]; + onNavigate: (key: NavKey) => Promise; + isMobile: boolean; +} + +interface SettingsSearchOption { + value: NavKey; + label: string; + sectionTitle: string; + destinationPath: string; + searchableContent: string[]; + matchedContext?: string; +} + +const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = { + general: ['settings.general'], + hotkeys: ['settings.hotkeys'], + account: ['account'], + people: ['settings.workspace'], + teams: ['settings.workspace', 'settings.team'], + 'api-keys': ['settings.developer'], + connectionMode: ['settings.connection'], + planBilling: ['settings.planBilling'], + adminGeneral: ['admin.settings.general'], + adminFeatures: ['admin.settings.features'], + adminEndpoints: ['admin.settings.endpoints'], + adminDatabase: ['admin.settings.database'], + adminAdvanced: ['admin.settings.advanced'], + adminSecurity: ['admin.settings.security'], + adminConnections: [ + 'admin.settings.connections', + 'admin.settings.mail', + 'admin.settings.security', + 'admin.settings.telegram', + 'admin.settings.premium', + 'admin.settings.general', + 'settings.securityAuth', + 'settings.connection', + ], + adminPlan: ['settings.planBilling', 'admin.settings.premium', 'settings.licensingAnalytics'], + adminAudit: ['settings.licensingAnalytics'], + adminUsage: ['settings.licensingAnalytics'], + adminLegal: ['admin.settings.legal'], + adminPrivacy: ['admin.settings.privacy'], +}; + +const getTranslationPrefixesForNavKey = (key: string): string[] => { + const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? []; + + const inferredPrefixes: string[] = []; + + if (key.startsWith('admin')) { + const adminSuffix = key.replace(/^admin/, ''); + const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); + inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); + } else { + inferredPrefixes.push(`settings.${key}`); + } + + return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); +}; + +const flattenTranslationStrings = (value: unknown): string[] => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (Array.isArray(value)) { + return value.flatMap(flattenTranslationStrings); + } + + if (value && typeof value === 'object') { + return Object.values(value as Record).flatMap(flattenTranslationStrings); + } + + return []; +}; + +const buildMatchSnippet = (text: string, query: string): string => { + const normalizedText = text.toLocaleLowerCase(); + const normalizedQuery = query.toLocaleLowerCase(); + const matchIndex = normalizedText.indexOf(normalizedQuery); + + if (matchIndex === -1) { + return text; + } + + const maxLength = 84; + const contextPadding = 28; + const start = Math.max(0, matchIndex - contextPadding); + const end = Math.min(text.length, matchIndex + query.length + contextPadding); + const snippet = text.slice(start, end); + + if (snippet.length <= maxLength) { + return `${start > 0 ? '…' : ''}${snippet}${end < text.length ? '…' : ''}`; + } + + return `${start > 0 ? '…' : ''}${snippet.slice(0, maxLength)}${end < text.length ? '…' : ''}`; +}; + +export const SettingsSearchBar: React.FC = ({ + configNavSections, + onNavigate, + isMobile, +}) => { + const { t } = useTranslation(); + const [searchValue, setSearchValue] = useState(''); + + // Build a global index from every accessible settings tab in the modal navigation. + // This does not render section components, so API calls still happen only when a tab is opened. + const searchableSections = useMemo(() => { + return configNavSections.flatMap((section) => + section.items + .filter((item: ConfigNavItem) => !item.disabled) + .map((item: ConfigNavItem) => { + const translationPrefixes = getTranslationPrefixesForNavKey(item.key); + const translationContent = translationPrefixes.flatMap((prefix) => + flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)) + ); + + const searchableContent = Array.from( + new Set([ + item.label, + section.title, + `/settings/${item.key}`, + ...translationContent, + ]) + ); + + return { + value: item.key, + label: item.label, + sectionTitle: section.title, + destinationPath: `/settings/${item.key}`, + searchableContent, + }; + }) + ); + }, [configNavSections, t]); + + const filteredSearchableSections = useMemo(() => { + const query = searchValue.trim(); + if (!query) { + return searchableSections; + } + + const normalizedQuery = query.toLocaleLowerCase(); + + return searchableSections.reduce((accumulator, option) => { + const matchedEntry = option.searchableContent.find((entry) => + entry.toLocaleLowerCase().includes(normalizedQuery) + ); + + if (!matchedEntry) { + return accumulator; + } + + accumulator.push({ + ...option, + matchedContext: buildMatchSnippet(matchedEntry, query), + }); + + return accumulator; + }, []); + }, [searchValue, searchableSections]); + + const handleSearchNavigation = useCallback(async (value: string | null) => { + if (!value) return; + if (!VALID_NAV_KEYS.includes(value as NavKey)) return; + await onNavigate(value as NavKey); + setSearchValue(''); + }, [onNavigate]); + + return ( +