Merge pull request #472 from christianlouis/copilot/improve-settings-page-ux

feat(ui): redesign settings page with search, sidebar nav, and collapsible sections
This commit is contained in:
Christian Krakau-Louis
2026-03-02 19:20:52 +01:00
committed by GitHub
2 changed files with 466 additions and 265 deletions
+3
View File
@@ -235,3 +235,6 @@ html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; } html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; } html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; } html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
+288 -90
View File
@@ -1,38 +1,73 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Settings - DocuElevate{% endblock %} {% block title %}Settings - DocuElevate{% endblock %}
{# Category icon mapping #}
{% set category_icons = {
'Core': 'fas fa-server',
'Authentication': 'fas fa-shield-alt',
'AI Services': 'fas fa-robot',
'OCR Engines': 'fas fa-file-alt',
'Storage Providers': 'fas fa-cloud-upload-alt',
'Email': 'fas fa-envelope',
'IMAP': 'fas fa-inbox',
'Monitoring': 'fas fa-chart-line',
'Processing': 'fas fa-cogs',
'Notifications': 'fas fa-bell',
'Feature Flags': 'fas fa-toggle-on',
'UI': 'fas fa-palette',
'PDF/A Archival': 'fas fa-archive',
'Deduplication': 'fas fa-clone',
'Security': 'fas fa-lock',
'Other': 'fas fa-ellipsis-h'
} %}
{% block head_extra %} {% block head_extra %}
<style> <style>
.setting-input { .setting-input { font-family: 'Courier New', monospace; }
font-family: 'Courier New', monospace; /* Sidebar navigation */
} .settings-sidebar { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; }
.settings-sidebar::-webkit-scrollbar { width: 4px; }
.settings-sidebar::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 2px; }
/* Category section transitions */
.category-chevron { transition: transform 0.2s ease; }
.category-chevron.rotate-180 { transform: rotate(180deg); }
/* Search highlight */
.settings-match-highlight { background-color: #fef08a; border-radius: 2px; padding: 0 2px; }
html.dark .settings-match-highlight { background-color: #854d0e; }
/* Smooth scroll respect reduced-motion preference (WCAG) */
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
/* No-results state */
.settings-no-results { text-align: center; padding: 3rem 1rem; color: #9ca3af; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-8" x-data="settingsApp()"> <div class="container mx-auto px-4 py-6" x-data="settingsApp()">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-6">
<div class="flex justify-between items-start"> <div class="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
<div> <div>
<h1 class="text-3xl font-bold mb-2">Application Settings</h1> <h1 class="text-3xl font-bold mb-1">Application Settings</h1>
<p class="text-gray-600"> <p class="text-gray-500 text-sm">
This is a convenience feature to view and edit application settings through the web interface. Manage configuration. Priority: <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">DB</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 ml-1">ENV</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 ml-1">DEFAULT</span>
<span class="mx-1.5">·</span> <span class="text-red-600">*</span> = restart required <span class="mx-1.5">·</span> <i class="fas fa-lock text-xs" aria-hidden="true"></i> = encrypted at rest
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-shrink-0">
<a href="/setup?step=1" <a href="/setup?step=1"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-magic mr-2" aria-hidden="true"></i> Setup Wizard <i class="fas fa-magic mr-1.5" aria-hidden="true"></i> Wizard
</a> </a>
<div class="relative" x-data="{ exportOpen: false }"> <div class="relative" x-data="{ exportOpen: false }">
<button @click="exportOpen = !exportOpen" <button @click="exportOpen = !exportOpen"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2" aria-hidden="true"></i> Export .env <i class="fas fa-download mr-1.5" aria-hidden="true"></i> Export
<i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i> <i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i>
</button> </button>
<div x-show="exportOpen" @click.outside="exportOpen = false" x-transition <div x-show="exportOpen" @click.outside="exportOpen = false" x-transition
class="absolute right-0 mt-1 w-52 bg-white border border-gray-200 rounded-md shadow-lg z-10"> class="absolute right-0 mt-1 w-52 bg-white border border-gray-200 rounded-md shadow-lg z-20">
<a href="/api/settings/export-env?source=db" <a href="/api/settings/export-env?source=db"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
@click="exportOpen = false"> @click="exportOpen = false">
@@ -46,83 +81,161 @@
</div> </div>
</div> </div>
<a href="/admin/settings/audit-log" <a href="/admin/settings/audit-log"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-history mr-2" aria-hidden="true"></i> Audit Log <i class="fas fa-history mr-1.5" aria-hidden="true"></i> Audit Log
</a> </a>
</div> </div>
</div> </div>
<div class="bg-blue-50 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">📋 Settings Precedence Order:</p> <!-- Search Bar -->
<ul class="list-disc list-inside ml-4 mt-2"> <div class="relative">
<li><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">DB</span> Database settings (highest priority) - explicitly saved via this UI</li> <i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" aria-hidden="true"></i>
<li><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">ENV</span> Environment variables - from .env file or system environment</li> <input
<li><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">DEFAULT</span> Default values - built-in application defaults</li> type="search"
</ul> x-model="searchQuery"
@input="filterSettings()"
placeholder="Search settings by name, key, or description…"
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
aria-label="Search settings"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
<span x-show="searchQuery" x-text="matchCount + ' found'" x-transition class="text-xs text-gray-400"></span>
<button x-show="searchQuery" @click="searchQuery = ''; filterSettings()" type="button"
class="text-gray-400 hover:text-gray-600" aria-label="Clear search">
<i class="fas fa-times"></i>
</button>
</div> </div>
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 my-4" role="alert">
<p class="font-bold">⚠️ Important Notes:</p>
<ul class="list-disc list-inside ml-4 mt-2">
<li>Settings marked with <span class="text-red-600">*</span> require an application restart to take effect.</li>
<li>Sensitive values (passwords, API keys) are <strong>encrypted at rest</strong> in the database <i class="fas fa-lock text-xs"></i>.</li>
<li>Use the <i class="fas fa-eye"></i> icon to temporarily show/hide sensitive values.</li>
<li>Saving a setting here stores it in the database and overrides environment variables.</li>
<li>Only administrators can access and modify these settings.</li>
<li>All fields are optional - you can save just the settings you want to override.</li>
</ul>
</div> </div>
</div> </div>
<!-- Alert Messages --> <!-- Alert Messages -->
<div x-show="showAlert" x-transition class="mb-4"> <div x-show="showAlert" x-transition class="mb-4">
<div :class="alertType === 'success' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-red-100 border-red-500 text-red-700'" <div :class="alertType === 'success' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-red-100 border-red-500 text-red-700'"
class="border-l-4 p-4" role="alert"> class="border-l-4 p-4 rounded-r-md" role="alert">
<p class="font-bold" x-text="alertTitle"></p> <p class="font-bold" x-text="alertTitle"></p>
<p x-text="alertMessage"></p> <p x-text="alertMessage"></p>
</div> </div>
</div> </div>
<!-- Form --> <!-- Two-column layout -->
<form @submit.prevent="saveSettings"> <div class="flex gap-6">
<!-- Sidebar Navigation (hidden on small screens) -->
<aside class="hidden lg:block w-56 flex-shrink-0" aria-label="Settings categories">
<nav class="settings-sidebar space-y-0.5 bg-white rounded-lg border border-gray-200 p-2 shadow-sm">
<p class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Categories</p>
{% for category, settings_list in settings_data.items() %} {% for category, settings_list in settings_data.items() %}
<div class="bg-white shadow rounded-lg mb-6"> <button
<!-- Category Header --> type="button"
<div class="bg-gray-100 px-6 py-4 border-b border-gray-200"> @click="scrollToCategory('{{ category|e }}')"
<h2 class="text-xl font-semibold text-gray-800">{{ category }}</h2> class="w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors hover:bg-gray-100"
:class="activeCategory === '{{ category|e }}' ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600'"
data-sidebar-category="{{ category }}"
x-show="isCategoryVisible('{{ category|e }}')"
>
<i class="{{ category_icons.get(category, 'fas fa-cog') }} w-4 text-center text-xs" aria-hidden="true"></i>
<span class="truncate flex-1">{{ category }}</span>
<span class="text-xs text-gray-400 tabular-nums" x-text="visibleCount['{{ category|e }}'] ?? {{ settings_list|length }}"></span>
</button>
{% endfor %}
</nav>
</aside>
<!-- Main Content -->
<div class="flex-1 min-w-0">
<!-- Mobile category jump (visible on small screens only) -->
<div class="lg:hidden mb-4">
<label for="mobile-category-select" class="sr-only">Jump to category</label>
<select id="mobile-category-select"
@change="scrollToCategory($event.target.value)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm">
<option value="">Jump to category…</option>
{% for category in settings_data.keys() %}
<option value="{{ category }}">{{ category }} ({{ settings_data[category]|length }})</option>
{% endfor %}
</select>
</div> </div>
<!-- Settings in this category --> <!-- No results state -->
<div class="px-6 py-4 space-y-6"> <div x-show="searchQuery && matchCount === 0" class="settings-no-results bg-white rounded-lg border border-gray-200 shadow-sm">
<i class="fas fa-search text-4xl text-gray-300 mb-3"></i>
<p class="text-gray-500 text-lg font-medium">No settings found</p>
<p class="text-gray-400 text-sm mt-1">Try a different search term or <button type="button" @click="searchQuery = ''; filterSettings()" class="text-blue-500 hover:underline">clear the search</button></p>
</div>
<form @submit.prevent="saveSettings">
{% for category, settings_list in settings_data.items() %}
<section
class="mb-4"
id="category-{{ category|lower|replace(' ', '-')|replace('/', '-') }}"
data-category="{{ category }}"
x-show="isCategoryVisible('{{ category|e }}')"
>
<!-- Category Header -->
<div
class="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-t-lg cursor-pointer hover:bg-gray-50 select-none transition-colors"
@click="toggleCategory('{{ category|e }}')"
role="button"
tabindex="0"
@keydown.enter="toggleCategory('{{ category|e }}')"
@keydown.space.prevent="toggleCategory('{{ category|e }}')"
:aria-expanded="openCategories['{{ category|e }}']"
aria-controls="settings-{{ category|lower|replace(' ', '-')|replace('/', '-') }}"
>
<div class="flex items-center gap-3">
<i class="{{ category_icons.get(category, 'fas fa-cog') }} text-gray-500" aria-hidden="true"></i>
<h2 class="text-lg font-semibold text-gray-800">{{ category }}</h2>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full tabular-nums"
x-text="(visibleCount['{{ category|e }}'] ?? {{ settings_list|length }}) + ' settings'">
{{ settings_list|length }} settings
</span>
</div>
<i class="fas fa-chevron-down text-gray-400 category-chevron"
:class="openCategories['{{ category|e }}'] ? 'rotate-180' : ''"
aria-hidden="true"></i>
</div>
<!-- Category Content -->
<div
x-show="openCategories['{{ category|e }}']"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
id="settings-{{ category|lower|replace(' ', '-')|replace('/', '-') }}"
class="bg-white border border-t-0 border-gray-200 rounded-b-lg shadow-sm"
>
<div class="divide-y divide-gray-100">
{% for setting in settings_list %} {% for setting in settings_list %}
<div class="border-b border-gray-200 pb-6 last:border-b-0"> <div
class="px-4 py-4 hover:bg-gray-50 transition-colors"
data-setting-key="{{ setting.key }}"
data-searchable="{{ setting.key }} {{ setting.key.replace('_', ' ') }} {{ setting.metadata.description|lower }}"
x-show="isSettingVisible('{{ setting.key }}')"
>
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div class="flex-1"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 flex-wrap">
<label for="{{ setting.key }}" class="block text-sm font-medium text-gray-700"> <label for="{{ setting.key }}" class="text-sm font-medium text-gray-700">
{{ setting.key.replace('_', ' ').title() }} {{ setting.key.replace('_', ' ').title() }}
{% if setting.metadata.restart_required %} {% if setting.metadata.restart_required %}
<span class="text-red-600">*</span> <span class="text-red-600" title="Restart required">*</span>
{% endif %} {% endif %}
{% if setting.metadata.required %} {% if setting.metadata.required %}
<span class="text-red-600 text-xs">(required)</span> <span class="text-red-600 text-xs">(required)</span>
{% endif %} {% endif %}
</label> </label>
<!-- Source Indicator Badge --> <!-- Source Badge -->
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium
{% if setting.source == 'database' %} {% if setting.source == 'database' %}bg-green-100 text-green-800
bg-green-100 text-green-800 {% elif setting.source == 'environment' %}bg-blue-100 text-blue-800
{% elif setting.source == 'environment' %} {% else %}bg-gray-100 text-gray-800{% endif %}"
bg-blue-100 text-blue-800 title="Value source: {{ setting.source }}">
{% else %}
bg-gray-100 text-gray-800
{% endif %}
" title="Value source: {{ setting.source }}">
{{ setting.source_label }} {{ setting.source_label }}
</span> </span>
<code class="text-xs text-gray-400 hidden sm:inline">{{ setting.key }}</code>
<span class="sr-only sm:hidden">Key: {{ setting.key }}</span>
</div> </div>
<p class="text-xs text-gray-500 mb-2"> <p class="text-xs text-gray-500 mb-2">{{ setting.metadata.description }}</p>
{{ setting.metadata.description }}
</p>
{% if setting.metadata.type == 'boolean' %} {% if setting.metadata.type == 'boolean' %}
<!-- Boolean/Checkbox Input --> <!-- Boolean/Checkbox Input -->
@@ -163,20 +276,14 @@
/> />
</div> </div>
{% elif setting.metadata.type == 'multiselect' and setting.metadata.options %} {% elif setting.metadata.type == 'multiselect' and setting.metadata.options %}
<!-- Multi-select checkboxes: stored as comma-separated string --> <!-- Multi-select checkboxes -->
<div class="flex flex-wrap gap-2 mt-1"> <div class="flex flex-wrap gap-2 mt-1">
{% for opt in setting.metadata.options %} {% for opt in setting.metadata.options %}
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border cursor-pointer select-none <label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border cursor-pointer select-none transition-colors bg-white border-gray-300 hover:border-blue-400"
transition-colors :class="isSelected('{{ setting.key }}', '{{ opt }}') ? 'bg-blue-100 border-blue-500 text-blue-800 font-medium' : 'text-gray-700'">
bg-white border-gray-300 hover:border-blue-400" <input type="checkbox" class="sr-only"
:class="isSelected('{{ setting.key }}', '{{ opt }}')
? 'bg-blue-100 border-blue-500 text-blue-800 font-medium'
: 'text-gray-700'">
<input type="checkbox"
class="sr-only"
:checked="isSelected('{{ setting.key }}', '{{ opt }}')" :checked="isSelected('{{ setting.key }}', '{{ opt }}')"
@change="toggleMultiselect('{{ setting.key }}', '{{ opt }}', $event.target.checked)" @change="toggleMultiselect('{{ setting.key }}', '{{ opt }}', $event.target.checked)" />
/>
<i :class="isSelected('{{ setting.key }}', '{{ opt }}') ? 'fas fa-check-circle text-blue-600' : 'far fa-circle text-gray-400'"></i> <i :class="isSelected('{{ setting.key }}', '{{ opt }}') ? 'fas fa-check-circle text-blue-600' : 'far fa-circle text-gray-400'"></i>
<span class="text-sm">{{ opt }}</span> <span class="text-sm">{{ opt }}</span>
</label> </label>
@@ -184,7 +291,7 @@
</div> </div>
<p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p> <p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
{% elif setting.metadata.type == 'model_picker' %} {% elif setting.metadata.type == 'model_picker' %}
<!-- Model Picker: free-text input with datalist of common models --> <!-- Model Picker -->
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
@@ -207,7 +314,7 @@
</p> </p>
</div> </div>
{% elif setting.metadata.options %} {% elif setting.metadata.options %}
<!-- Dropdown Select for fields with a fixed list of values --> <!-- Dropdown Select -->
<select <select
id="{{ setting.key }}" id="{{ setting.key }}"
name="{{ setting.key }}" name="{{ setting.key }}"
@@ -223,7 +330,6 @@
<!-- Text / Password Input --> <!-- Text / Password Input -->
<div class="relative"> <div class="relative">
{% if setting.metadata.sensitive %} {% if setting.metadata.sensitive %}
<!-- Sensitive Field with Show/Hide Toggle -->
<div class="relative"> <div class="relative">
<input <input
:type="showPassword['{{ setting.key }}'] ? 'text' : 'password'" :type="showPassword['{{ setting.key }}'] ? 'text' : 'password'"
@@ -235,11 +341,9 @@
autocomplete="off" autocomplete="off"
/> />
<div class="absolute inset-y-0 right-0 flex items-center pr-3 space-x-2"> <div class="absolute inset-y-0 right-0 flex items-center pr-3 space-x-2">
<!-- Encrypted indicator -->
<span class="text-xs text-gray-400" title="Value is encrypted at rest in database"> <span class="text-xs text-gray-400" title="Value is encrypted at rest in database">
<i class="fas fa-lock"></i> <i class="fas fa-lock"></i>
</span> </span>
<!-- Show/Hide Toggle -->
<button <button
type="button" type="button"
@click="togglePassword('{{ setting.key }}')" @click="togglePassword('{{ setting.key }}')"
@@ -252,7 +356,6 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<!-- Non-Sensitive Field -->
<input <input
type="text" type="text"
id="{{ setting.key }}" id="{{ setting.key }}"
@@ -266,7 +369,7 @@
{% endif %} {% endif %}
</div> </div>
<!-- Per-setting Save / Back-to-ENV buttons --> <!-- Per-setting actions -->
<div class="ml-4 flex-shrink-0 flex flex-row items-center gap-2 pt-1"> <div class="ml-4 flex-shrink-0 flex flex-row items-center gap-2 pt-1">
<button <button
type="button" type="button"
@@ -298,10 +401,11 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</section>
{% endfor %} {% endfor %}
<!-- Action Buttons --> <!-- Bulk Action Buttons -->
<div class="flex justify-end space-x-4 mt-6"> <div class="flex justify-end space-x-4 mt-6" x-show="!searchQuery">
<button <button
type="button" type="button"
@click="resetForm" @click="resetForm"
@@ -314,11 +418,13 @@
:disabled="saving" :disabled="saving"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span x-show="!saving">Save Settings</span> <span x-show="!saving">Save All Changes</span>
<span x-show="saving">Saving...</span> <span x-show="saving">Saving...</span>
</button> </button>
</div> </div>
</form> </form>
</div>
</div>
</div> </div>
<script> <script>
@@ -335,17 +441,115 @@ function settingsApp() {
alertType: 'success', alertType: 'success',
alertTitle: '', alertTitle: '',
alertMessage: '', alertMessage: '',
searchQuery: '',
matchCount: 0,
activeCategory: '',
openCategories: {},
visibleSettings: {},
visibleCount: {},
allCategories: [],
settingSearchIndex: {},
init() { init() {
// Initialize form data from current settings const categories = [];
{% for category, settings_list in settings_data.items() %} {% for category, settings_list in settings_data.items() %}
categories.push('{{ category|e }}');
this.openCategories['{{ category|e }}'] = true;
this.visibleCount['{{ category|e }}'] = {{ settings_list|length }};
{% for setting in settings_list %} {% for setting in settings_list %}
this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }}; this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
this.originalData['{{ setting.key }}'] = {{ setting.display_value|tojson }}; this.originalData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
this.isDbOverride['{{ setting.key }}'] = {{ (setting.source == 'database')|tojson }}; this.isDbOverride['{{ setting.key }}'] = {{ (setting.source == 'database')|tojson }};
this.showPassword['{{ setting.key }}'] = false; this.showPassword['{{ setting.key }}'] = false;
this.visibleSettings['{{ setting.key }}'] = true;
this.settingSearchIndex['{{ setting.key }}'] = {
category: '{{ category|e }}',
searchText: {{ (setting.key ~ ' ' ~ setting.key.replace('_', ' ') ~ ' ' ~ setting.metadata.description)|lower|tojson }}
};
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
this.allCategories = categories;
if (categories.length > 0) this.activeCategory = categories[0];
// Set up intersection observer for active category tracking
this.$nextTick(() => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const cat = entry.target.getAttribute('data-category');
if (cat) this.activeCategory = cat;
}
}
}, { rootMargin: '-20% 0px -70% 0px' });
document.querySelectorAll('[data-category]').forEach(el => observer.observe(el));
// Clean up observer when component is destroyed
if (this.$cleanup) {
this.$cleanup(() => observer.disconnect());
}
});
},
/** Filter settings based on search query. */
filterSettings() {
const query = this.searchQuery.toLowerCase().trim();
let totalMatches = 0;
for (const cat of this.allCategories) {
this.visibleCount[cat] = 0;
}
for (const [key, index] of Object.entries(this.settingSearchIndex)) {
if (!query) {
this.visibleSettings[key] = true;
this.visibleCount[index.category]++;
} else {
const matches = index.searchText.includes(query);
this.visibleSettings[key] = matches;
if (matches) {
totalMatches++;
this.visibleCount[index.category]++;
}
}
}
this.matchCount = totalMatches;
// When searching, auto-expand categories with matches and collapse empty ones
if (query) {
for (const cat of this.allCategories) {
this.openCategories[cat] = this.visibleCount[cat] > 0;
}
}
},
/** Check if a category has any visible settings. */
isCategoryVisible(category) {
if (!this.searchQuery) return true;
return (this.visibleCount[category] || 0) > 0;
},
/** Check if a single setting is visible (matches search). */
isSettingVisible(key) {
if (!this.searchQuery) return true;
return this.visibleSettings[key] === true;
},
/** Toggle a category open/closed. */
toggleCategory(category) {
this.openCategories[category] = !this.openCategories[category];
},
/** Scroll to a category section. */
scrollToCategory(category) {
this.activeCategory = category;
// Open the category if closed
this.openCategories[category] = true;
const slug = category.toLowerCase().replace(/ /g, '-').replace(/\//g, '-');
const el = document.getElementById('category-' + slug);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, },
/** Parse a comma-separated multiselect value into a trimmed array. */ /** Parse a comma-separated multiselect value into a trimmed array. */
@@ -446,7 +650,6 @@ function settingsApp() {
}); });
if (response.ok) { if (response.ok) {
// Reload the page so the ENV/default value and source badge refresh
window.location.reload(); window.location.reload();
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -465,10 +668,8 @@ function settingsApp() {
this.hideAlert(); this.hideAlert();
try { try {
// Prepare updates array
const updates = []; const updates = [];
for (const [key, value] of Object.entries(this.formData)) { for (const [key, value] of Object.entries(this.formData)) {
// Only include changed settings
if (value !== this.originalData[key]) { if (value !== this.originalData[key]) {
updates.push({ key, value }); updates.push({ key, value });
} }
@@ -480,12 +681,9 @@ function settingsApp() {
return; return;
} }
// Send bulk update request
const response = await fetch('/api/settings/bulk-update', { const response = await fetch('/api/settings/bulk-update', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify(updates), body: JSON.stringify(updates),
}); });