mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
Add searchable settings selector to AppConfigModal and improve nav behavior (#5873)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppConfigModalProps> = ({ opened, onClose })
|
||||
const { config } = useAppConfig();
|
||||
const licenseAlert = useLicenseAlert();
|
||||
const { confirmIfDirty } = useUnsavedChanges();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(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<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ opened, onClose })
|
||||
const navItemContent = (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => 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<AppConfigModalProps> = ({ opened, onClose })
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={handleClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<SettingsSearchBar
|
||||
configNavSections={configNavSections}
|
||||
onNavigate={handleNavigation}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
data-autofocus
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
|
||||
217
frontend/src/core/components/shared/config/SettingsSearchBar.tsx
Normal file
217
frontend/src/core/components/shared/config/SettingsSearchBar.tsx
Normal file
@@ -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<void>;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
interface SettingsSearchOption {
|
||||
value: NavKey;
|
||||
label: string;
|
||||
sectionTitle: string;
|
||||
destinationPath: string;
|
||||
searchableContent: string[];
|
||||
matchedContext?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
|
||||
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<string, unknown>).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<SettingsSearchBarProps> = ({
|
||||
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<SettingsSearchOption[]>(() => {
|
||||
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<SettingsSearchOption[]>(() => {
|
||||
const query = searchValue.trim();
|
||||
if (!query) {
|
||||
return searchableSections;
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
|
||||
return searchableSections.reduce<SettingsSearchOption[]>((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 (
|
||||
<Select
|
||||
className="settings-search-select"
|
||||
data={filteredSearchableSections}
|
||||
value={null}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
onChange={handleSearchNavigation}
|
||||
placeholder={t('settings.search.placeholder', 'Search settings pages...')}
|
||||
leftSection={<LocalIcon icon="search-rounded" width={16} height={16} />}
|
||||
aria-label={t('navbar.search', 'Search')}
|
||||
nothingFoundMessage={t('search.noResults', 'No results found')}
|
||||
searchable
|
||||
clearable={false}
|
||||
w={isMobile ? 170 : 320}
|
||||
filter={({ options }) => options}
|
||||
renderOption={({ option }) => {
|
||||
const searchOption = option as unknown as SettingsSearchOption;
|
||||
return (
|
||||
<div className="settings-search-option">
|
||||
<Text size="sm" fw={600}>{searchOption.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{searchOption.sectionTitle} · {searchOption.matchedContext || searchOption.destinationPath}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user