feat(settings): add search functionality to settings modal (P2)

Implement Phase 2 search bar feature for settings modal:

- Add search input to settings modal header with icon and clear button
- Implement real-time filtering with 150ms debounce for performance
- Add visual highlighting for matched search terms using accent color
- Implement empty search results state with user-friendly message
- Add keyboard shortcuts (Escape to clear search)
- Auto-expand sections containing matching content during search
- Fix header layout to prevent overlap with close button
- Update progress tracker documenting P2 completion
- Add translation keys for search feature (placeholder, clear, no results)
- Sync translations across all language files

Files changed:
- templates/components/modals/settings_modal.html
- static/css/components/modal/settings-modal.css
- static/js/managers/SettingsManager.js
- locales/*.json (10 language files)
- docs/ui-ux-optimization/progress-tracker.md
This commit is contained in:
Will Miao
2026-02-24 06:36:49 +08:00
parent 528225ffbd
commit 3f0227ba9d
14 changed files with 1403 additions and 788 deletions

View File

@@ -3,7 +3,7 @@
## Project Overview
**Goal**: Optimize Settings Modal UI/UX with left navigation sidebar
**Started**: 2026-02-23
**Current Phase**: P0 - Left Navigation Sidebar
**Current Phase**: P2 - Search Bar (Completed)
---
@@ -74,25 +74,42 @@ None currently
## Phase 1: Section Collapse/Expand (P1)
### Status: Planned
### Status: Completed ✓
### Completion Notes
- All sections now have collapse/expand functionality
- Chevron icon rotates smoothly on toggle
- State persistence via localStorage working correctly
- CSS animations for smooth height transitions
- Settings order reorganized to match sidebar navigation
### Tasks
- [ ] Add collapse/expand toggle to section headers
- [ ] Add chevron icon with rotation animation
- [ ] Implement localStorage for state persistence
- [ ] Add CSS animations for smooth transitions
- [x] Add collapse/expand toggle to section headers
- [x] Add chevron icon with rotation animation
- [x] Implement localStorage for state persistence
- [x] Add CSS animations for smooth transitions
- [x] Reorder settings sections to match sidebar navigation
---
## Phase 2: Search Bar (P1)
### Status: Planned
### Status: Completed ✓
### Completion Notes
- Search input added to settings modal header with icon and clear button
- Real-time filtering with debounced input (150ms delay)
- Highlight matching terms with accent color background
- Handle empty search results with user-friendly message
- Keyboard shortcuts: Escape to clear search
- Sections with matches are automatically expanded
- All translation keys added and synchronized across languages
### Tasks
- [ ] Add search input to header area
- [ ] Implement real-time filtering
- [ ] Add highlight for matched terms
- [ ] Handle empty search results
- [x] Add search input to header area
- [x] Implement real-time filtering
- [x] Add highlight for matched terms
- [x] Handle empty search results
---
@@ -121,7 +138,29 @@ None currently
## Change Log
### 2026-02-23
### 2026-02-23 (P2)
- Completed Phase 2: Search Bar
- Added search input to settings modal header with search icon and clear button
- Implemented real-time filtering with 150ms debounce for performance
- Added visual highlighting for matched search terms using accent color
- Implemented empty search results state with user-friendly message
- Added keyboard shortcuts (Escape to clear search)
- Sections with matching content are automatically expanded during search
- Updated SettingsManager.js with search initialization and filtering logic
- Added comprehensive CSS styles for search input, highlights, and responsive design
- Added translation keys for search feature (placeholder, clear, no results)
- Synchronized translations across all language files
### 2026-02-23 (P1)
- Completed Phase 1: Section Collapse/Expand
- Added collapse/expand functionality to all settings sections
- Implemented chevron icon with smooth rotation animation
- Added localStorage persistence for collapse state
- Reorganized settings sections to match sidebar navigation order
- Updated SettingsManager.js with section collapse initialization
- Added CSS styles for smooth transitions and animations
### 2026-02-23 (P0)
- Created project documentation
- Started Phase 0 implementation
- Analyzed existing code structure

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "Portabler Modus",
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."

View File

@@ -275,6 +275,11 @@
"download": "Download",
"advanced": "Advanced"
},
"search": {
"placeholder": "Search settings...",
"clear": "Clear search",
"noResults": "No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "Portable mode",
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "Modo portátil",
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "Mode portable",
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "מצב נייד",
"locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "ポータブルモード",
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "휴대용 모드",
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "Портативный режим",
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "便携模式",
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"

View File

@@ -275,6 +275,11 @@
"download": "[TODO: Translate] Download",
"advanced": "[TODO: Translate] Advanced"
},
"search": {
"placeholder": "[TODO: Translate] Search settings...",
"clear": "[TODO: Translate] Clear search",
"noResults": "[TODO: Translate] No settings found matching \"{query}\""
},
"storage": {
"locationLabel": "可攜式模式",
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"

View File

@@ -120,9 +120,115 @@
.settings-header {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-1);
margin-bottom: var(--space-2);
padding-right: 40px; /* Space for close button */
}
.settings-header .settings-search-wrapper {
margin-left: auto;
}
/* Search Input Styles */
.settings-search-wrapper {
position: relative;
display: flex;
align-items: center;
width: 240px;
}
.settings-search-icon {
position: absolute;
left: 10px;
color: var(--text-color);
opacity: 0.5;
font-size: 0.9em;
pointer-events: none;
}
.settings-search-input {
width: 100%;
padding: 6px 28px 6px 32px;
height: 32px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.9em;
transition: all 0.2s ease;
}
.settings-search-input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.settings-search-input::placeholder {
color: var(--text-color);
opacity: 0.5;
}
.settings-search-clear {
position: absolute;
right: 6px;
width: 20px;
height: 20px;
border: none;
background: rgba(var(--border-color-rgb, 148, 163, 184), 0.3);
color: var(--text-color);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
opacity: 0.6;
transition: all 0.2s ease;
}
.settings-search-clear:hover {
opacity: 1;
background: rgba(var(--border-color-rgb, 148, 163, 184), 0.5);
}
/* Search Highlight Styles */
.settings-search-highlight {
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.3);
color: var(--lora-accent);
padding: 0 2px;
border-radius: 2px;
font-weight: 500;
}
/* Section visibility during search */
.settings-section.search-match,
.setting-item.search-match {
display: block !important;
}
.settings-section.search-hidden,
.setting-item.search-hidden {
display: none !important;
}
/* Empty search results state */
.settings-search-empty {
text-align: center;
padding: var(--space-4);
color: var(--text-color);
opacity: 0.7;
}
.settings-search-empty i {
font-size: 2em;
margin-bottom: var(--space-2);
opacity: 0.5;
}
.settings-search-empty p {
margin: 0;
font-size: 0.95em;
}
.settings-header h2 {
@@ -461,13 +567,66 @@
padding-top: var(--space-2);
}
.settings-section h3 {
font-size: 1.1em;
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
padding: var(--space-1) 0;
margin-bottom: var(--space-2);
transition: opacity 0.2s ease;
}
.settings-section-header:hover {
opacity: 0.8;
}
.settings-section-header h3 {
font-size: 1.1em;
margin: 0;
color: var(--text-color);
opacity: 0.9;
}
.settings-section-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-color);
cursor: pointer;
transition: transform 0.3s ease;
opacity: 0.6;
}
.settings-section-toggle:hover {
opacity: 1;
}
.settings-section-toggle .chevron {
transition: transform 0.3s ease;
}
.settings-section.collapsed .settings-section-toggle .chevron {
transform: rotate(-90deg);
}
.settings-section-content {
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
max-height: 2000px;
opacity: 1;
}
.settings-section.collapsed .settings-section-content {
max-height: 0;
opacity: 0;
}
.setting-item {
display: flex;
flex-direction: column; /* Changed to column for help text placement */
@@ -806,6 +965,17 @@ input:checked + .toggle-slider:before {
flex-direction: column;
}
.settings-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.settings-header .settings-search-wrapper {
margin-left: 0;
width: 100%;
}
.settings-nav {
width: 100%;
max-height: 200px;

View File

@@ -365,6 +365,7 @@ export class SettingsManager {
this.setupPriorityTagInputs();
this.initializeNavigation();
this.initializeSearch();
this.initialized = true;
}
@@ -435,10 +436,266 @@ export class SettingsManager {
}
});
// Initialize section collapse/expand
this.initializeSectionCollapse();
// Initial update
updateActiveNav();
}
initializeSectionCollapse() {
const sections = document.querySelectorAll('.settings-section, .setting-item[id^="section-"]');
const STORAGE_KEY = 'settingsModal_collapsedSections';
// Load collapsed state from localStorage
let collapsedSections = {};
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
collapsedSections = JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load collapsed sections state:', e);
}
sections.forEach(section => {
const sectionId = section.getAttribute('data-section') || section.id;
const header = section.querySelector('.settings-section-header');
const toggleBtn = section.querySelector('.settings-section-toggle');
if (!header || !toggleBtn) return;
// Apply initial collapsed state
if (collapsedSections[sectionId]) {
section.classList.add('collapsed');
}
// Handle toggle click
const toggleSection = () => {
const isCollapsed = section.classList.toggle('collapsed');
// Save state to localStorage
collapsedSections[sectionId] = isCollapsed;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsedSections));
} catch (e) {
console.warn('Failed to save collapsed sections state:', e);
}
};
// Click on header or toggle button
header.addEventListener('click', (e) => {
// Don't toggle if clicking on interactive elements within header
if (e.target.closest('a, button:not(.settings-section-toggle), input, select')) {
return;
}
toggleSection();
});
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleSection();
});
});
}
initializeSearch() {
const searchInput = document.getElementById('settingsSearchInput');
const searchClear = document.getElementById('settingsSearchClear');
if (!searchInput) return;
// Debounced search handler
let searchTimeout;
const debouncedSearch = (query) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.performSearch(query);
}, 150);
};
// Handle input changes
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Show/hide clear button
if (searchClear) {
searchClear.style.display = query ? 'flex' : 'none';
}
debouncedSearch(query);
});
// Handle clear button click
if (searchClear) {
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchClear.style.display = 'none';
searchInput.focus();
this.performSearch('');
});
}
// Handle Escape key to clear search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (searchInput.value) {
searchInput.value = '';
if (searchClear) searchClear.style.display = 'none';
this.performSearch('');
}
}
});
}
performSearch(query) {
const sections = document.querySelectorAll('.settings-section, .setting-item[id^="section-"]');
const settingsForm = document.querySelector('.settings-form');
// Remove existing empty state
const existingEmptyState = settingsForm?.querySelector('.settings-search-empty');
if (existingEmptyState) {
existingEmptyState.remove();
}
if (!query) {
// Reset all sections to visible and remove highlights
sections.forEach(section => {
section.classList.remove('search-hidden', 'search-match');
this.removeSearchHighlights(section);
});
return;
}
const lowerQuery = query.toLowerCase();
let matchCount = 0;
sections.forEach(section => {
const sectionText = this.getSectionSearchableText(section);
const hasMatch = sectionText.includes(lowerQuery);
if (hasMatch) {
section.classList.remove('search-hidden');
section.classList.add('search-match');
this.highlightSearchMatches(section, lowerQuery);
matchCount++;
// Expand section if it has matches
section.classList.remove('collapsed');
} else {
section.classList.add('search-hidden');
section.classList.remove('search-match');
this.removeSearchHighlights(section);
}
});
// Show empty state if no matches found
if (matchCount === 0 && settingsForm) {
const emptyState = document.createElement('div');
emptyState.className = 'settings-search-empty';
emptyState.innerHTML = `
<i class="fas fa-search"></i>
<p>${translate('settings.search.noResults', { query }, `No settings found matching "${query}"`)}</p>
`;
settingsForm.appendChild(emptyState);
}
}
getSectionSearchableText(section) {
// Get all text content from labels, help text, and headers
const labels = section.querySelectorAll('label');
const helpTexts = section.querySelectorAll('.input-help');
const headers = section.querySelectorAll('h3');
let text = '';
labels.forEach(el => text += ' ' + el.textContent);
helpTexts.forEach(el => text += ' ' + el.textContent);
headers.forEach(el => text += ' ' + el.textContent);
return text.toLowerCase();
}
highlightSearchMatches(section, query) {
// Remove existing highlights first
this.removeSearchHighlights(section);
if (!query) return;
// Highlight in labels and help text
const textElements = section.querySelectorAll('label, .input-help, h3');
textElements.forEach(element => {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
if (node.textContent.toLowerCase().includes(query)) {
textNodes.push(node);
}
}
textNodes.forEach(textNode => {
const parent = textNode.parentElement;
const text = textNode.textContent;
const lowerText = text.toLowerCase();
// Split text by query and wrap matches in highlight spans
const parts = [];
let lastIndex = 0;
let index;
while ((index = lowerText.indexOf(query, lastIndex)) !== -1) {
// Add text before match
if (index > lastIndex) {
parts.push(document.createTextNode(text.substring(lastIndex, index)));
}
// Add highlighted match
const highlight = document.createElement('span');
highlight.className = 'settings-search-highlight';
highlight.textContent = text.substring(index, index + query.length);
parts.push(highlight);
lastIndex = index + query.length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(document.createTextNode(text.substring(lastIndex)));
}
// Replace original text node with highlighted version
if (parts.length > 1) {
parts.forEach(part => parent.insertBefore(part, textNode));
parent.removeChild(textNode);
}
});
});
}
removeSearchHighlights(section) {
const highlights = section.querySelectorAll('.settings-search-highlight');
highlights.forEach(highlight => {
const parent = highlight.parentElement;
if (parent) {
// Replace highlight with its text content
parent.insertBefore(document.createTextNode(highlight.textContent), highlight);
parent.removeChild(highlight);
// Normalize to merge adjacent text nodes
parent.normalize();
}
});
}
async openSettingsFileLocation() {
try {
const response = await fetch('/api/lm/settings/open-location', {

File diff suppressed because it is too large Load Diff