Files
gh-christianlouis-docuelevate/frontend/templates/settings.html
T
copilot-swe-agent[bot] 8c77982109 feat(i18n): replace hardcoded English text with translation keys in files, settings, and pipelines templates
Replace all hardcoded English strings in three HTML Jinja2 templates with
`{{ _("key") }}` translation function calls, adding 87 new keys to en.json.

files.html:
- Page title, drop overlay, upload modal, error messages
- All filter labels, placeholders, options (status, OCR quality, etc.)
- Saved searches and full-text search UI
- Bulk action buttons, table headers, pagination
- Delete and preview modal text

settings.html:
- Page heading, configuration priority legend
- Header buttons (Wizard, DB Wizard, Export, Audit Log)
- Search bar placeholder and aria-labels
- Sidebar categories, no-results state
- Per-setting labels: required, enable prefix, effective value
- Autocomplete and model picker hints/placeholders
- Revert/save button labels and states

pipelines.html:
- Page title and subtitle
- New Pipeline button, loading state, empty state
- Pipeline card badges (System, Default, Inactive, Disabled)
- Add/Edit step modal fields and labels
- OCR language options and hints
- Cancel/Delete buttons in all modals

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 00:17:23 +00:00

1021 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ _("settings.title") }} - 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">{{ _("settings.page_heading") }}</h1>
<p class="text-gray-500 text-sm">
{{ _("settings.manage_config_prefix") }} <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ _("settings.source_db_badge") }}</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">{{ _("settings.source_env_badge") }}</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">{{ _("settings.source_default_badge") }}</span>
<span class="mx-1.5">·</span> <span class="text-red-600">*</span> {{ _("settings.restart_required_suffix") }} <span class="mx-1.5">·</span> <i class="fas fa-lock text-xs" aria-hidden="true"></i> {{ _("settings.encrypted_suffix") }}
</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> {{ _("settings.wizard_btn") }}
</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> {{ _("settings.db_wizard_btn") }}
</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> {{ _("settings.export_btn") }}
<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>{{ _("settings.export_db_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>{{ _("settings.export_full_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> {{ _("settings.audit_log_btn") }}
</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="{{ _('settings.search_placeholder') }}"
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="{{ _('settings.search_aria_label') }}"
/>
<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="{{ _('settings.search_clear_aria') }}">
<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_aria') }}">
<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">{{ _("settings.categories_heading") }}</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">{{ _("settings.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="">{{ _("settings.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">{{ _("settings.no_results_heading") }}</p>
<p class="text-gray-400 text-sm mt-1">{{ _("settings.no_results_hint") }} <button type="button" @click="searchQuery = ''; filterSettings()" class="text-blue-500 hover:underline">{{ _("settings.no_results_clear") }}</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.settings_count_suffix") }}
</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">{{ _("settings.required_label") }}</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">
{{ _("settings.boolean_enable_prefix") }} {{ 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">{{ _("settings.effective_value_label") }} <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="{{ _('settings.user_autocomplete_placeholder') }}"
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> {{ _("settings.user_autocomplete_empty") }}
</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>
{{ _("settings.user_autocomplete_hint") }}
</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="{{ _('settings.autocomplete_placeholder') }}"
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> {{ _("settings.autocomplete_no_matches") }}
</div>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("settings.autocomplete_hint") }}
</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="{{ _('settings.model_picker_placeholder') }}"
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>
{{ _("settings.model_picker_hint") }}
</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="">{{ _("common.select_placeholder") }}</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="{{ _('settings.encrypted_title') }}">
<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="{{ _('settings.toggle_visibility_title') }}"
aria-label="{{ _('settings.toggle_visibility_aria') }}"
>
<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="{{ _('settings.revert_title') }}"
>
<span x-show="revertingKey !== '{{ setting.key }}'"><i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("settings.revert_btn") }}</span>
<span x-show="revertingKey === '{{ setting.key }}'">{{ _("settings.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="{{ _('settings.save_setting_title') }}"
>
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1" aria-hidden="true"></i>{{ _("common.save") }}</span>
<span x-show="savingKey === '{{ setting.key }}'">{{ _("settings.saving_state") }}</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"
>
{{ _("common.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">{{ _("settings.save_all_btn") }}</span>
<span x-show="saving">{{ _("settings.saving_state") }}</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 %}