bdb67de5cb
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1021 lines
48 KiB
HTML
1021 lines
48 KiB
HTML
{% extends "base.html" %}
|
||
{% 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',
|
||
'Watch Folders': 'fas fa-folder-open',
|
||
'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 %}
|
||
<style>
|
||
.setting-input { 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>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container mx-auto px-4 py-6" x-data="settingsApp()">
|
||
<!-- Header -->
|
||
<div class="mb-6">
|
||
<div class="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
|
||
<div>
|
||
<h1 class="text-3xl font-bold mb-1">Application Settings</h1>
|
||
<p class="text-gray-500 text-sm">
|
||
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> >
|
||
<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> >
|
||
<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>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-shrink-0">
|
||
<a href="/setup?step=1"
|
||
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-1.5" aria-hidden="true"></i> Wizard
|
||
</a>
|
||
<a href="/database-wizard"
|
||
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-database mr-1.5" aria-hidden="true"></i> DB Wizard
|
||
</a>
|
||
<div class="relative" x-data="{ exportOpen: false }">
|
||
<button @click="exportOpen = !exportOpen"
|
||
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-1.5" aria-hidden="true"></i> Export
|
||
<i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i>
|
||
</button>
|
||
<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-20">
|
||
<a href="/api/settings/export-env?source=db"
|
||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||
@click="exportOpen = false">
|
||
<i class="fas fa-database mr-2 text-green-600" aria-hidden="true"></i>DB settings only
|
||
</a>
|
||
<a href="/api/settings/export-env?source=effective"
|
||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||
@click="exportOpen = false">
|
||
<i class="fas fa-layer-group mr-2 text-blue-600" aria-hidden="true"></i>Full effective config
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<a href="/admin/settings/audit-log"
|
||
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-1.5" aria-hidden="true"></i> Audit Log
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search Bar -->
|
||
<div class="relative">
|
||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" aria-hidden="true"></i>
|
||
<input
|
||
type="search"
|
||
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>
|
||
|
||
<!-- Alert Messages -->
|
||
<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'"
|
||
class="border-l-4 p-4 rounded-r-md" role="alert">
|
||
<p class="font-bold" x-text="alertTitle"></p>
|
||
<p x-text="alertMessage"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Two-column layout -->
|
||
<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() %}
|
||
<button
|
||
type="button"
|
||
@click="scrollToCategory('{{ category|e }}')"
|
||
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>
|
||
|
||
<!-- No results state -->
|
||
<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 %}
|
||
<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-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||
<label for="{{ setting.key }}" class="text-sm font-medium text-gray-700">
|
||
{{ setting.key.replace('_', ' ').title() }}
|
||
{% if setting.metadata.restart_required %}
|
||
<span class="text-red-600" title="Restart required">*</span>
|
||
{% endif %}
|
||
{% if setting.metadata.required %}
|
||
<span class="text-red-600 text-xs">(required)</span>
|
||
{% endif %}
|
||
</label>
|
||
<!-- Source Badge -->
|
||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium
|
||
{% if setting.source == 'database' %}bg-green-100 text-green-800
|
||
{% elif setting.source == 'environment' %}bg-blue-100 text-blue-800
|
||
{% else %}bg-gray-100 text-gray-800{% endif %}"
|
||
title="Value source: {{ setting.source }}">
|
||
{{ setting.source_label }}
|
||
</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>
|
||
|
||
<p class="text-xs text-gray-500 mb-2">{{ setting.metadata.description }}</p>
|
||
{% if setting.metadata.get('help_link') %}
|
||
<p class="text-xs mb-2">
|
||
<a href="{{ setting.metadata.help_link }}"
|
||
class="text-indigo-600 hover:underline focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded">
|
||
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>{{ setting.metadata.get('help_link_label', 'More info') }}
|
||
</a>
|
||
</p>
|
||
{% endif %}
|
||
|
||
{% if setting.metadata.type == 'boolean' %}
|
||
<!-- Boolean/Checkbox Input -->
|
||
<div class="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
:checked="formData['{{ setting.key }}'] === 'true' || formData['{{ setting.key }}'] === true"
|
||
@change="formData['{{ setting.key }}'] = $event.target.checked ? 'true' : 'false'"
|
||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label for="{{ setting.key }}" class="ml-2 text-sm text-gray-700">
|
||
Enable {{ setting.key.replace('_', ' ').title() }}
|
||
</label>
|
||
</div>
|
||
{% elif setting.metadata.type == 'slider' %}
|
||
<!-- Slider Input -->
|
||
<div class="flex items-center gap-4">
|
||
<input
|
||
type="range"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
min="{{ setting.metadata.get('min', 0) }}"
|
||
max="{{ setting.metadata.get('max', 100) }}"
|
||
step="{{ setting.metadata.get('step', 1) }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="{{ setting.metadata.get('min', 0) }}"
|
||
max="{{ setting.metadata.get('max', 100) }}"
|
||
step="{{ setting.metadata.get('step', 1) }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="setting-input w-24 px-2 py-1 border border-gray-300 rounded-md shadow-sm text-center focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
aria-label="{{ setting.key.replace('_', ' ').title() }} value"
|
||
/>
|
||
</div>
|
||
{% elif setting.metadata.type == 'multiselect' and setting.metadata.options %}
|
||
<!-- Multi-select checkboxes -->
|
||
<div class="flex flex-wrap gap-2 mt-1">
|
||
{% 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 transition-colors bg-white border-gray-300 hover:border-blue-400"
|
||
: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 }}')"
|
||
@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>
|
||
<span class="text-sm">{{ opt }}</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
|
||
{% elif setting.metadata.type == 'user_autocomplete' %}
|
||
<!-- User Autocomplete (WCAG 2.1 AA combobox pattern) -->
|
||
<div class="relative" x-data="userAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
|
||
<div class="relative">
|
||
<label for="{{ setting.key }}" class="sr-only">{{ setting.key.replace('_', ' ').title() }}</label>
|
||
<input
|
||
type="text"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
@input.debounce.250ms="fetchSuggestions($event.target.value)"
|
||
@focus="if (formData['{{ setting.key }}']) fetchSuggestions(formData['{{ setting.key }}']); else fetchSuggestions('')"
|
||
@keydown.arrow-down.prevent="highlightNext()"
|
||
@keydown.arrow-up.prevent="highlightPrev()"
|
||
@keydown.enter.prevent="selectHighlighted()"
|
||
@keydown.escape="showSuggestions = false"
|
||
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Start typing to search users…"
|
||
autocomplete="off"
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-label="{{ setting.key.replace('_', ' ').title() }} — search existing users"
|
||
:aria-expanded="showSuggestions && suggestions.length > 0"
|
||
aria-haspopup="listbox"
|
||
:aria-controls="'{{ setting.key }}_listbox'"
|
||
:aria-activedescendant="highlightedIdx >= 0 ? '{{ setting.key }}_opt_' + highlightedIdx : ''"
|
||
/>
|
||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||
<i class="fas fa-user text-gray-400 text-sm" aria-hidden="true"></i>
|
||
</div>
|
||
<div x-show="loading" class="absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
||
<i class="fas fa-spinner fa-spin text-gray-400 text-sm"></i>
|
||
</div>
|
||
</div>
|
||
<!-- Live region for screen reader announcements -->
|
||
<div class="sr-only" aria-live="polite" aria-atomic="true" x-text="statusText"></div>
|
||
<!-- Suggestion dropdown -->
|
||
<ul
|
||
x-show="showSuggestions && suggestions.length > 0"
|
||
x-transition:enter="transition ease-out duration-100"
|
||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||
x-transition:enter-end="opacity-100 translate-y-0"
|
||
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||
role="listbox"
|
||
:id="'{{ setting.key }}_listbox'"
|
||
:aria-label="'{{ setting.key.replace('_', ' ').title() }} suggestions'"
|
||
>
|
||
<template x-for="(user, idx) in suggestions" :key="user">
|
||
<li
|
||
:id="'{{ setting.key }}_opt_' + idx"
|
||
role="option"
|
||
:aria-selected="highlightedIdx === idx"
|
||
@mouseenter="highlightedIdx = idx"
|
||
@click="selectUser(user)"
|
||
class="px-3 py-2.5 text-sm cursor-pointer flex items-center gap-2 min-h-[44px]"
|
||
:class="highlightedIdx === idx ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'"
|
||
>
|
||
<i class="fas fa-user-circle text-gray-400" aria-hidden="true"></i>
|
||
<span x-text="user"></span>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
<!-- Empty state -->
|
||
<div
|
||
x-show="showSuggestions && suggestions.length === 0 && !loading && searchDone"
|
||
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
|
||
role="status"
|
||
>
|
||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matching users found
|
||
</div>
|
||
<p class="text-xs text-gray-400 mt-1" id="{{ setting.key }}_help">
|
||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||
Type to search existing users by name, or enter any identifier manually.
|
||
</p>
|
||
</div>
|
||
{% elif setting.metadata.type == 'autocomplete' %}
|
||
<!-- Dynamic Autocomplete (WCAG 2.1 AA combobox pattern) -->
|
||
<div class="relative" x-data="dynamicAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
|
||
<div class="relative">
|
||
<label for="{{ setting.key }}" class="sr-only">{{ setting.key.replace('_', ' ').title() }}</label>
|
||
<input
|
||
type="text"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
@input.debounce.200ms="fetchSuggestions($event.target.value)"
|
||
@focus="fetchSuggestions(formData['{{ setting.key }}'] || '')"
|
||
@keydown.arrow-down.prevent="highlightNext()"
|
||
@keydown.arrow-up.prevent="highlightPrev()"
|
||
@keydown.enter.prevent="selectHighlighted()"
|
||
@keydown.escape="showSuggestions = false"
|
||
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Type to search or enter a value…"
|
||
autocomplete="off"
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-label="{{ setting.key.replace('_', ' ').title() }} — type to search suggestions"
|
||
:aria-expanded="showSuggestions && filtered.length > 0"
|
||
aria-haspopup="listbox"
|
||
:aria-controls="'{{ setting.key }}_listbox'"
|
||
:aria-activedescendant="highlightedIdx >= 0 ? '{{ setting.key }}_opt_' + highlightedIdx : ''"
|
||
/>
|
||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||
<i class="fas fa-search text-gray-400 text-sm" aria-hidden="true"></i>
|
||
</div>
|
||
<div x-show="loading" class="absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
||
<i class="fas fa-spinner fa-spin text-gray-400 text-sm"></i>
|
||
</div>
|
||
</div>
|
||
<!-- Live region for screen reader announcements -->
|
||
<div class="sr-only" aria-live="polite" aria-atomic="true" x-text="statusText"></div>
|
||
<!-- Suggestion dropdown -->
|
||
<ul
|
||
x-show="showSuggestions && filtered.length > 0"
|
||
x-transition:enter="transition ease-out duration-100"
|
||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||
x-transition:enter-end="opacity-100 translate-y-0"
|
||
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||
role="listbox"
|
||
:id="'{{ setting.key }}_listbox'"
|
||
:aria-label="'{{ setting.key.replace('_', ' ').title() }} suggestions'"
|
||
>
|
||
<template x-for="(item, idx) in filtered" :key="item">
|
||
<li
|
||
:id="'{{ setting.key }}_opt_' + idx"
|
||
role="option"
|
||
:aria-selected="highlightedIdx === idx"
|
||
@mouseenter="highlightedIdx = idx"
|
||
@click="selectItem(item)"
|
||
class="px-3 py-2.5 text-sm cursor-pointer min-h-[44px] flex items-center"
|
||
:class="highlightedIdx === idx ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'"
|
||
>
|
||
<span x-text="item"></span>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
<!-- Empty state -->
|
||
<div
|
||
x-show="showSuggestions && filtered.length === 0 && !loading && searchDone"
|
||
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
|
||
role="status"
|
||
>
|
||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matches — you can still type a custom value
|
||
</div>
|
||
<p class="text-xs text-gray-400 mt-1">
|
||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||
Type to search known values, or enter any custom value.
|
||
</p>
|
||
</div>
|
||
{% elif setting.metadata.type == 'model_picker' %}
|
||
<!-- Model Picker -->
|
||
<div class="relative">
|
||
<input
|
||
type="text"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
list="{{ setting.key }}_models"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Select a common model or type a custom name…"
|
||
autocomplete="off"
|
||
/>
|
||
<datalist id="{{ setting.key }}_models">
|
||
{% for m in setting.metadata.suggested_models %}
|
||
<option value="{{ m }}">{{ m }}</option>
|
||
{% endfor %}
|
||
</datalist>
|
||
<p class="text-xs text-gray-400 mt-1">
|
||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||
Pick from the list or type any model name supported by your provider.
|
||
</p>
|
||
</div>
|
||
{% elif setting.metadata.options %}
|
||
<!-- Dropdown Select -->
|
||
<select
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
<option value="">— select —</option>
|
||
{% for opt in setting.metadata.options %}
|
||
<option value="{{ opt }}">{{ opt }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
{% else %}
|
||
<!-- Text / Password Input -->
|
||
<div class="relative">
|
||
{% if setting.metadata.sensitive %}
|
||
<div class="relative">
|
||
<input
|
||
:type="showPassword['{{ setting.key }}'] ? 'text' : 'password'"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="setting-input w-full px-3 py-2 pr-24 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||
placeholder="{{ setting.metadata.description }}"
|
||
autocomplete="off"
|
||
/>
|
||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 space-x-2">
|
||
<span class="text-xs text-gray-400" title="Value is encrypted at rest in database">
|
||
<i class="fas fa-lock"></i>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
@click="togglePassword('{{ setting.key }}')"
|
||
class="text-gray-400 hover:text-gray-600 focus:outline-none"
|
||
title="Show/hide value"
|
||
aria-label="Toggle password visibility"
|
||
>
|
||
<i :class="showPassword['{{ setting.key }}'] ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<input
|
||
type="text"
|
||
id="{{ setting.key }}"
|
||
name="{{ setting.key }}"
|
||
x-model="formData['{{ setting.key }}']"
|
||
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="{{ setting.metadata.description }}"
|
||
/>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Per-setting actions -->
|
||
<div class="ml-4 flex-shrink-0 flex flex-row items-center gap-2 pt-1">
|
||
<button
|
||
type="button"
|
||
x-show="isDbOverride['{{ setting.key }}']"
|
||
x-transition
|
||
@click="revertSetting('{{ setting.key }}')"
|
||
:disabled="revertingKey === '{{ setting.key }}'"
|
||
class="px-3 py-1 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||
title="Remove DB override and revert to environment variable or default"
|
||
>
|
||
<span x-show="revertingKey !== '{{ setting.key }}'"><i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Remove from DB</span>
|
||
<span x-show="revertingKey === '{{ setting.key }}'">Reverting…</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
x-show="formData['{{ setting.key }}'] !== originalData['{{ setting.key }}']"
|
||
x-transition
|
||
@click="saveSetting('{{ setting.key }}')"
|
||
:disabled="savingKey === '{{ setting.key }}'"
|
||
class="px-3 py-1 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||
title="Save this setting"
|
||
>
|
||
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1" aria-hidden="true"></i>Save</span>
|
||
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endfor %}
|
||
|
||
<!-- Bulk Action Buttons -->
|
||
<div class="flex justify-end space-x-4 mt-6" x-show="!searchQuery">
|
||
<button
|
||
type="button"
|
||
@click="resetForm"
|
||
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
>
|
||
Reset
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
: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"
|
||
>
|
||
<span x-show="!saving">Save All Changes</span>
|
||
<span x-show="saving">Saving...</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function settingsApp() {
|
||
return {
|
||
formData: {},
|
||
originalData: {},
|
||
isDbOverride: {},
|
||
showPassword: {},
|
||
saving: false,
|
||
savingKey: null,
|
||
revertingKey: null,
|
||
showAlert: false,
|
||
alertType: 'success',
|
||
alertTitle: '',
|
||
alertMessage: '',
|
||
searchQuery: '',
|
||
matchCount: 0,
|
||
activeCategory: '',
|
||
openCategories: {},
|
||
visibleSettings: {},
|
||
visibleCount: {},
|
||
allCategories: [],
|
||
settingSearchIndex: {},
|
||
|
||
init() {
|
||
const categories = [];
|
||
{% 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 %}
|
||
this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
|
||
this.originalData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
|
||
this.isDbOverride['{{ setting.key }}'] = {{ (setting.source == 'database')|tojson }};
|
||
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 %}
|
||
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. */
|
||
parseMultiselect(key) {
|
||
return (this.formData[key] || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
},
|
||
|
||
/** Return true when *option* is present in the comma-separated *key* value. */
|
||
isSelected(key, option) {
|
||
return this.parseMultiselect(key).includes(option);
|
||
},
|
||
|
||
/** Add or remove *option* from the comma-separated *key* value. */
|
||
toggleMultiselect(key, option, checked) {
|
||
const current = this.parseMultiselect(key);
|
||
if (checked) {
|
||
if (!current.includes(option)) current.push(option);
|
||
} else {
|
||
const idx = current.indexOf(option);
|
||
if (idx !== -1) current.splice(idx, 1);
|
||
}
|
||
this.formData[key] = current.join(',');
|
||
},
|
||
|
||
togglePassword(key) {
|
||
this.showPassword[key] = !this.showPassword[key];
|
||
},
|
||
|
||
resetForm() {
|
||
this.formData = { ...this.originalData };
|
||
this.hideAlert();
|
||
},
|
||
|
||
showSuccessAlert(title, message) {
|
||
this.alertType = 'success';
|
||
this.alertTitle = title;
|
||
this.alertMessage = message;
|
||
this.showAlert = true;
|
||
setTimeout(() => this.hideAlert(), 5000);
|
||
},
|
||
|
||
showErrorAlert(title, message) {
|
||
this.alertType = 'error';
|
||
this.alertTitle = title;
|
||
this.alertMessage = message;
|
||
this.showAlert = true;
|
||
setTimeout(() => this.hideAlert(), 10000);
|
||
},
|
||
|
||
hideAlert() {
|
||
this.showAlert = false;
|
||
},
|
||
|
||
async saveSetting(key) {
|
||
this.savingKey = key;
|
||
this.hideAlert();
|
||
|
||
try {
|
||
const value = this.formData[key];
|
||
const response = await fetch(`/api/settings/${key}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key, value }),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
this.originalData[key] = value;
|
||
this.isDbOverride[key] = true;
|
||
let message = `Setting '${key}' saved successfully.`;
|
||
if (result.restart_required) {
|
||
message += ' Please restart the application for this change to take effect.';
|
||
}
|
||
this.showSuccessAlert('Setting Saved', message);
|
||
} else {
|
||
this.showErrorAlert('Save Failed', result.detail || 'Unknown error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving setting:', error);
|
||
this.showErrorAlert('Error', 'Failed to save setting. Please try again.');
|
||
} finally {
|
||
this.savingKey = null;
|
||
}
|
||
},
|
||
|
||
async revertSetting(key) {
|
||
if (!confirm(`Remove '${key}' from the database?\n\nThe setting will revert to its environment variable or default value.`)) {
|
||
return;
|
||
}
|
||
|
||
this.revertingKey = key;
|
||
this.hideAlert();
|
||
|
||
try {
|
||
const response = await fetch(`/api/settings/${key}`, {
|
||
method: 'DELETE',
|
||
});
|
||
|
||
if (response.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const result = await response.json();
|
||
this.showErrorAlert('Revert Failed', result.detail || 'Unknown error');
|
||
this.revertingKey = null;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error reverting setting:', error);
|
||
this.showErrorAlert('Error', 'Failed to revert setting. Please try again.');
|
||
this.revertingKey = null;
|
||
}
|
||
},
|
||
|
||
async saveSettings() {
|
||
this.saving = true;
|
||
this.hideAlert();
|
||
|
||
try {
|
||
const updates = [];
|
||
for (const [key, value] of Object.entries(this.formData)) {
|
||
if (value !== this.originalData[key]) {
|
||
updates.push({ key, value });
|
||
}
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
this.showSuccessAlert('No Changes', 'No settings were modified.');
|
||
this.saving = false;
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('/api/settings/bulk-update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updates),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
for (const updated of result.updated) {
|
||
this.originalData[updated.key] = updated.value;
|
||
this.isDbOverride[updated.key] = true;
|
||
}
|
||
let message = `${result.updated.length} setting(s) updated successfully.`;
|
||
if (result.restart_required) {
|
||
message += ' Please restart the application for changes to take effect.';
|
||
}
|
||
this.showSuccessAlert('Settings Saved', message);
|
||
} else {
|
||
const errorMessages = result.errors ? result.errors.map(e => `${e.key}: ${e.error}`).join(', ') : 'Unknown error';
|
||
this.showErrorAlert('Save Failed', errorMessages);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving settings:', error);
|
||
this.showErrorAlert('Error', 'Failed to save settings. Please try again.');
|
||
} finally {
|
||
this.saving = false;
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Alpine.js component for the user-autocomplete setting widget.
|
||
* Implements the ARIA 1.1 combobox pattern (WCAG 2.1 AA).
|
||
* Fetches matching owner_id values from GET /api/users/search?q=<term>&limit=5
|
||
* and displays them in a dropdown list.
|
||
*/
|
||
function userAutocomplete(settingKey) {
|
||
return {
|
||
suggestions: [],
|
||
showSuggestions: false,
|
||
loading: false,
|
||
searchDone: false,
|
||
highlightedIdx: -1,
|
||
statusText: '',
|
||
|
||
async fetchSuggestions(query) {
|
||
this.loading = true;
|
||
this.searchDone = false;
|
||
this.highlightedIdx = -1;
|
||
this.statusText = 'Searching…';
|
||
try {
|
||
const resp = await fetch('/api/users/search?q=' + encodeURIComponent(query || '') + '&limit=5');
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
this.suggestions = data.users || [];
|
||
} else {
|
||
this.suggestions = [];
|
||
}
|
||
} catch {
|
||
this.suggestions = [];
|
||
}
|
||
this.loading = false;
|
||
this.searchDone = true;
|
||
this.showSuggestions = true;
|
||
// Announce result count to screen readers via live region
|
||
if (this.suggestions.length === 0) {
|
||
this.statusText = 'No matching users found';
|
||
} else if (this.suggestions.length === 1) {
|
||
this.statusText = '1 user found';
|
||
} else {
|
||
this.statusText = this.suggestions.length + ' users found';
|
||
}
|
||
},
|
||
|
||
selectUser(user) {
|
||
this.formData[settingKey] = user;
|
||
this.showSuggestions = false;
|
||
this.statusText = user + ' selected';
|
||
},
|
||
|
||
highlightNext() {
|
||
if (this.suggestions.length === 0) return;
|
||
this.highlightedIdx = (this.highlightedIdx + 1) % this.suggestions.length;
|
||
this.statusText = this.suggestions[this.highlightedIdx];
|
||
},
|
||
|
||
highlightPrev() {
|
||
if (this.suggestions.length === 0) return;
|
||
this.highlightedIdx = this.highlightedIdx <= 0 ? this.suggestions.length - 1 : this.highlightedIdx - 1;
|
||
this.statusText = this.suggestions[this.highlightedIdx];
|
||
},
|
||
|
||
selectHighlighted() {
|
||
if (this.highlightedIdx >= 0 && this.highlightedIdx < this.suggestions.length) {
|
||
this.selectUser(this.suggestions[this.highlightedIdx]);
|
||
} else {
|
||
this.showSuggestions = false;
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Alpine.js component for the generic dynamic autocomplete setting widget.
|
||
* Fetches suggestions from GET /api/settings/{key}/suggestions?q=<term>&limit=10
|
||
* using a debounced API call. Implements the ARIA 1.1 combobox pattern.
|
||
*/
|
||
function dynamicAutocomplete(settingKey) {
|
||
return {
|
||
filtered: [],
|
||
showSuggestions: false,
|
||
loading: false,
|
||
searchDone: false,
|
||
highlightedIdx: -1,
|
||
statusText: '',
|
||
|
||
async fetchSuggestions(query) {
|
||
this.loading = true;
|
||
this.searchDone = false;
|
||
this.highlightedIdx = -1;
|
||
this.statusText = 'Searching…';
|
||
try {
|
||
const resp = await fetch('/api/settings/' + encodeURIComponent(settingKey) + '/suggestions?q=' + encodeURIComponent(query || '') + '&limit=10');
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
this.filtered = data.suggestions || [];
|
||
} else {
|
||
this.filtered = [];
|
||
}
|
||
} catch {
|
||
this.filtered = [];
|
||
}
|
||
this.loading = false;
|
||
this.searchDone = true;
|
||
this.showSuggestions = true;
|
||
if (this.filtered.length === 0) {
|
||
this.statusText = 'No matches found';
|
||
} else if (this.filtered.length === 1) {
|
||
this.statusText = '1 suggestion found';
|
||
} else {
|
||
this.statusText = this.filtered.length + ' suggestions found';
|
||
}
|
||
},
|
||
|
||
selectItem(item) {
|
||
this.formData[settingKey] = item;
|
||
this.showSuggestions = false;
|
||
this.statusText = item + ' selected';
|
||
},
|
||
|
||
highlightNext() {
|
||
if (this.filtered.length === 0) return;
|
||
this.highlightedIdx = (this.highlightedIdx + 1) % this.filtered.length;
|
||
this.statusText = this.filtered[this.highlightedIdx];
|
||
},
|
||
|
||
highlightPrev() {
|
||
if (this.filtered.length === 0) return;
|
||
this.highlightedIdx = this.highlightedIdx <= 0 ? this.filtered.length - 1 : this.highlightedIdx - 1;
|
||
this.statusText = this.filtered[this.highlightedIdx];
|
||
},
|
||
|
||
selectHighlighted() {
|
||
if (this.highlightedIdx >= 0 && this.highlightedIdx < this.filtered.length) {
|
||
this.selectItem(this.filtered[this.highlightedIdx]);
|
||
} else {
|
||
this.showSuggestions = false;
|
||
}
|
||
}
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|