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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 00:17:23 +00:00
parent bea9dae957
commit 8c77982109
4 changed files with 247 additions and 168 deletions
+78 -79
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}File Records{% endblock %}
{% block title %}{{ _("files.page_title") }}{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
@@ -427,16 +427,16 @@
<div id="dropOverlay" class="drop-overlay" aria-hidden="true">
<div class="drop-message">
<i class="fas fa-cloud-upload-alt" aria-hidden="true"></i>
<p>Drop files or folders anywhere to upload</p>
<div class="drop-hint">Supports PDF, Office docs, images, HTML, Markdown, and more folders are processed recursively</div>
<p>{{ _("files.drop_overlay_title") }}</p>
<div class="drop-hint">{{ _("files.drop_overlay_hint") }}</div>
</div>
</div>
<!-- Upload progress modal -->
<div id="uploadModal" class="upload-modal" role="dialog" aria-modal="true" aria-label="Upload progress">
<div id="uploadModal" class="upload-modal" role="dialog" aria-modal="true" aria-label="{{ _('files.upload_modal_aria') }}">
<div class="upload-modal-header">
<span><i class="fas fa-upload" aria-hidden="true"></i> Uploading Files</span>
<button class="close-modal-btn" onclick="closeUploadModal()" aria-label="Close upload progress">
<span><i class="fas fa-upload" aria-hidden="true"></i> {{ _("files.upload_modal_header") }}</span>
<button class="close-modal-btn" onclick="closeUploadModal()" aria-label="{{ _('files.upload_modal_close') }}">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
@@ -447,7 +447,7 @@
</div>
<div class="container mx-auto px-4 py-8">
<h2 class="text-3xl font-bold mb-6">File Records</h2>
<h2 class="text-3xl font-bold mb-6">{{ _("files.page_title") }}</h2>
<!-- Queue pending banner (populated via JS) -->
<div id="queueBanner" class="hidden bg-blue-50 border-l-4 border-blue-400 text-blue-800 p-4 mb-6 rounded-r-lg" role="status">
@@ -455,36 +455,35 @@
<div class="flex items-center">
<i class="fas fa-spinner fa-spin mr-3 text-blue-500" aria-hidden="true"></i>
<span>
<strong id="queueBannerCount">0</strong> item(s) are currently queued or being processed.
Files will appear here once processing completes.
<strong id="queueBannerCount">0</strong> {{ _("files.queue_banner_items") }}
</span>
</div>
<a href="/admin/queue" class="text-blue-600 hover:text-blue-800 text-sm font-medium whitespace-nowrap ml-4"
id="queueBannerLink" style="display:none;">
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>View Queue
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>{{ _("files.queue_banner_link") }}
</a>
</div>
</div>
{% if error %}
<div class="error-message">
<p><strong>Error:</strong> {{ error }}</p>
<p class="mt-2">This might be due to a configuration or import issue. Please check the server logs.</p>
<p><strong>{{ _("common.error") }}:</strong> {{ error }}</p>
<p class="mt-2">{{ _("files.error_hint") }}</p>
</div>
{% endif %}
<!-- Filters Section -->
<div class="filters-section">
<form method="get" action="/files" class="filter-group" role="search" aria-label="Filter files">
<form method="get" action="/files" class="filter-group" role="search" aria-label="{{ _('files.filter_form_aria') }}">
<div class="filter-item">
<label for="search">Search Filename</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
<label for="search">{{ _("files.filter_search_label") }}</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="{{ _('files.filter_search_placeholder') }}">
</div>
<div class="filter-item">
<label for="mime_type">MIME Type</label>
<label for="mime_type">{{ _("files.filter_mime_type") }}</label>
<select id="mime_type" name="mime_type">
<option value="">All Types</option>
<option value="">{{ _("files.filter_all_types") }}</option>
{% for mt in mime_types %}
<option value="{{ mt }}" {% if mt == mime_type %}selected{% endif %}>{{ mt }}</option>
{% endfor %}
@@ -492,31 +491,31 @@
</div>
<div class="filter-item">
<label for="status">Status</label>
<label for="status">{{ _("common.status") }}</label>
<select id="status" name="status">
<option value="">All Statuses</option>
<option value="pending" {% if status == "pending" %}selected{% endif %}>Pending</option>
<option value="processing" {% if status == "processing" %}selected{% endif %}>Processing</option>
<option value="completed" {% if status == "completed" %}selected{% endif %}>Completed</option>
<option value="failed" {% if status == "failed" %}selected{% endif %}>Failed</option>
<option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>Duplicate</option>
<option value="">{{ _("files.filter_all_statuses") }}</option>
<option value="pending" {% if status == "pending" %}selected{% endif %}>{{ _("common.pending") }}</option>
<option value="processing" {% if status == "processing" %}selected{% endif %}>{{ _("common.processing") }}</option>
<option value="completed" {% if status == "completed" %}selected{% endif %}>{{ _("common.completed") }}</option>
<option value="failed" {% if status == "failed" %}selected{% endif %}>{{ _("common.failed") }}</option>
<option value="duplicate" {% if status == "duplicate" %}selected{% endif %}>{{ _("files.status_duplicate") }}</option>
</select>
</div>
<div class="filter-item">
<label for="date_from">Date From</label>
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" aria-label="Filter from date">
<label for="date_from">{{ _("files.filter_date_from") }}</label>
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" aria-label="{{ _('files.filter_date_from_aria') }}">
</div>
<div class="filter-item">
<label for="date_to">Date To</label>
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" aria-label="Filter to date">
<label for="date_to">{{ _("files.filter_date_to") }}</label>
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" aria-label="{{ _('files.filter_date_to_aria') }}">
</div>
<div class="filter-item">
<label for="storage_provider">Storage Provider</label>
<label for="storage_provider">{{ _("files.filter_storage_provider") }}</label>
<select id="storage_provider" name="storage_provider">
<option value="">All Providers</option>
<option value="">{{ _("files.filter_all_providers") }}</option>
<option value="dropbox" {% if storage_provider == "dropbox" %}selected{% endif %}>Dropbox</option>
<option value="google_drive" {% if storage_provider == "google_drive" %}selected{% endif %}>Google Drive</option>
<option value="onedrive" {% if storage_provider == "onedrive" %}selected{% endif %}>OneDrive</option>
@@ -529,28 +528,28 @@
</div>
<div class="filter-item">
<label for="tags">Tags</label>
<input type="text" id="tags" name="tags" value="{{ tags }}" placeholder="e.g. invoice,amazon" aria-label="Filter by tags (comma-separated)">
<label for="tags">{{ _("files.tags") }}</label>
<input type="text" id="tags" name="tags" value="{{ tags }}" placeholder="{{ _('files.filter_tags_placeholder') }}" aria-label="{{ _('files.filter_tags_aria') }}">
</div>
<div class="filter-item">
<label for="ocr_quality">OCR Quality</label>
<select id="ocr_quality" name="ocr_quality" aria-label="Filter by OCR quality score">
<option value="">All Files</option>
<option value="poor" {% if ocr_quality == "poor" %}selected{% endif %} aria-label="Poor quality, score less than {{ ocr_quality_threshold }}">Poor (score &lt; {{ ocr_quality_threshold }})</option>
<option value="good" {% if ocr_quality == "good" %}selected{% endif %} aria-label="Good quality, score at least {{ ocr_quality_threshold }}">Good (score &ge; {{ ocr_quality_threshold }})</option>
<option value="unchecked" {% if ocr_quality == "unchecked" %}selected{% endif %} aria-label="Not yet assessed">Not yet assessed</option>
<label for="ocr_quality">{{ _("files.filter_ocr_quality") }}</label>
<select id="ocr_quality" name="ocr_quality" aria-label="{{ _('files.filter_ocr_quality_aria') }}">
<option value="">{{ _("files.filter_ocr_all") }}</option>
<option value="poor" {% if ocr_quality == "poor" %}selected{% endif %} aria-label="Poor quality, score less than {{ ocr_quality_threshold }}">{{ _("files.filter_ocr_poor") }} (score &lt; {{ ocr_quality_threshold }})</option>
<option value="good" {% if ocr_quality == "good" %}selected{% endif %} aria-label="Good quality, score at least {{ ocr_quality_threshold }}">{{ _("files.filter_ocr_good") }} (score &ge; {{ ocr_quality_threshold }})</option>
<option value="unchecked" {% if ocr_quality == "unchecked" %}selected{% endif %} aria-label="Not yet assessed">{{ _("files.filter_ocr_unchecked") }}</option>
</select>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="submit">Apply Filters</button>
<button type="submit">{{ _("files.filter_apply") }}</button>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="button" class="clear" onclick="clearFilters()">Clear</button>
<button type="button" class="clear" onclick="clearFilters()">{{ _("files.filter_clear") }}</button>
</div>
<!-- Hidden fields to preserve sort order -->
@@ -564,13 +563,13 @@
<div class="filters-section" style="margin-top: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; width: 100%;">
<label style="font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
<i class="fas fa-bookmark" aria-hidden="true"></i> Saved Searches:
<i class="fas fa-bookmark" aria-hidden="true"></i> {{ _("files.saved_searches_label") }}
</label>
<div id="saved-searches-list" style="display: flex; gap: 0.25rem; flex-wrap: wrap;" aria-live="polite">
<span style="color: var(--text-muted); font-size: 0.85rem;">Loading...</span>
<span style="color: var(--text-muted); font-size: 0.85rem;">{{ _("common.loading") }}</span>
</div>
<button type="button" onclick="saveCurrentFilters()" class="btn-save-search" style="margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer;" aria-label="Save current filters as a saved search">
<i class="fas fa-plus" aria-hidden="true"></i> Save Current
<button type="button" onclick="saveCurrentFilters()" class="btn-save-search" style="margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer;" aria-label="{{ _('files.saved_searches_save_aria') }}">
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("files.saved_searches_save") }}
</button>
</div>
</div>
@@ -579,13 +578,13 @@
<div class="filters-section" style="margin-top: 0.75rem;">
<div style="width: 100%;">
<label for="fulltext-search" style="font-weight: 600; display: block; margin-bottom: 0.4rem;">
<i class="fas fa-search" aria-hidden="true"></i> Full-Text Search (OCR text, metadata, tags)
<i class="fas fa-search" aria-hidden="true"></i> {{ _("files.fulltext_search_label") }}
</label>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input
type="text"
id="fulltext-search"
placeholder="Search document content, sender, tags, type..."
placeholder="{{ _('files.fulltext_search_placeholder') }}"
style="flex: 1; min-width: 220px; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 0.875rem;"
oninput="debounceSearch(this.value)"
>
@@ -594,14 +593,14 @@
onclick="runFullTextSearch()"
style="padding: 0.5rem 1rem; background-color: #3182ce; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; white-space: nowrap;"
>
Search
{{ _("common.search") }}
</button>
<button
type="button"
onclick="clearFullTextSearch()"
style="padding: 0.5rem 1rem; background-color: #6b7280; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; white-space: nowrap;"
>
Clear
{{ _("files.filter_clear") }}
</button>
</div>
</div>
@@ -620,23 +619,23 @@
<div id="bulkActionsBar" class="filters-section" style="display: none; background-color: #e6f3ff;">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3">
<div>
<strong id="selectedCount">0</strong> files selected
<strong id="selectedCount">0</strong> {{ _("files.files_selected") }}
</div>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="bulkReprocess()" style="padding: 0.5rem 1rem; background-color: #3182ce; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
<i class="fas fa-sync" aria-hidden="true"></i> Reprocess Selected
<i class="fas fa-sync" aria-hidden="true"></i> {{ _("files.bulk_reprocess") }}
</button>
<button type="button" onclick="bulkReprocessCloudOcr()" style="padding: 0.5rem 1rem; background-color: #805ad5; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
<i class="fas fa-cloud" aria-hidden="true"></i> Re-run Cloud OCR
<i class="fas fa-cloud" aria-hidden="true"></i> {{ _("files.bulk_cloud_ocr") }}
</button>
<button type="button" onclick="bulkDownload()" style="padding: 0.5rem 1rem; background-color: #38a169; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
<i class="fas fa-file-archive" aria-hidden="true"></i> Download as ZIP
<i class="fas fa-file-archive" aria-hidden="true"></i> {{ _("files.bulk_download") }}
</button>
<button type="button" onclick="bulkDelete()" style="padding: 0.5rem 1rem; background-color: #e53e3e; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
<i class="fas fa-trash" aria-hidden="true"></i> Delete Selected
<i class="fas fa-trash" aria-hidden="true"></i> {{ _("files.bulk_delete") }}
</button>
<button type="button" onclick="clearSelection()" style="padding: 0.5rem 1rem; background-color: #718096; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; min-height: 44px;">
Clear Selection
{{ _("files.bulk_clear_selection") }}
</button>
</div>
</div>
@@ -644,14 +643,14 @@
<!-- File table (desktop) -->
<div class="hidden md:block overflow-x-auto">
<table class="file-table" id="fileTable" aria-label="File records">
<table class="file-table" id="fileTable" aria-label="{{ _('files.table_aria') }}">
<thead>
<tr>
<th scope="col" style="width: 40px;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()" title="Select all files on this page" aria-label="Select all files on this page">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()" title="{{ _('files.table_select_all') }}" aria-label="{{ _('files.table_select_all') }}">
</th>
<th scope="col" class="sortable" onclick="sortTable('id')" role="columnheader" aria-sort="{% if sort_by == 'id' %}{% if sort_order == 'asc' %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}">
ID
{{ _("files.table_id") }}
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
{% if sort_by == 'id' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
@@ -661,7 +660,7 @@
</span>
</th>
<th scope="col" class="sortable" onclick="sortTable('original_filename')">
Original Filename
{{ _("files.table_original_filename") }}
<span class="sort-indicator {% if sort_by == 'original_filename' %}active{% endif %}">
{% if sort_by == 'original_filename' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
@@ -671,7 +670,7 @@
</span>
</th>
<th scope="col" class="sortable" onclick="sortTable('file_size')">
File Size
{{ _("files.file_size") }}
<span class="sort-indicator {% if sort_by == 'file_size' %}active{% endif %}">
{% if sort_by == 'file_size' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
@@ -681,7 +680,7 @@
</span>
</th>
<th scope="col" class="sortable" onclick="sortTable('mime_type')">
MIME Type
{{ _("files.table_mime_type") }}
<span class="sort-indicator {% if sort_by == 'mime_type' %}active{% endif %}">
{% if sort_by == 'mime_type' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
@@ -690,9 +689,9 @@
{% endif %}
</span>
</th>
<th scope="col">Status</th>
<th scope="col">{{ _("common.status") }}</th>
<th scope="col" class="sortable" onclick="sortTable('created_at')">
Created At
{{ _("files.table_created_at") }}
<span class="sort-indicator {% if sort_by == 'created_at' %}active{% endif %}">
{% if sort_by == 'created_at' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
@@ -701,7 +700,7 @@
{% endif %}
</span>
</th>
<th scope="col">Actions</th>
<th scope="col">{{ _("files.table_actions") }}</th>
</tr>
</thead>
<tbody>
@@ -736,7 +735,7 @@
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-4">No files found</td>
<td colspan="8" class="text-center py-4">{{ _("files.table_empty") }}</td>
</tr>
{% endfor %}
</tbody>
@@ -778,33 +777,33 @@
</div>
</div>
{% else %}
<p class="text-center py-4 text-gray-500">No files found</p>
<p class="text-center py-4 text-gray-500">{{ _("files.table_empty") }}</p>
{% endfor %}
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav class="pagination flex-wrap gap-2" aria-label="File list pagination">
<nav class="pagination flex-wrap gap-2" aria-label="{{ _('files.pagination_nav_aria') }}">
<div class="pagination-info" aria-live="polite">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
{{ _("files.pagination_showing") }} {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
{{ min(pagination.page * pagination.per_page, pagination.total) }}
of {{ pagination.total }} files
{{ _("files.pagination_of") }} {{ pagination.total }} {{ _("files.pagination_files") }}
</div>
<div class="pagination-buttons flex-wrap">
{% if pagination.page > 1 %}
<button class="pagination-button" onclick="goToPage(1)" aria-label="Go to first page" style="min-height:44px;">First</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})" aria-label="Go to previous page" style="min-height:44px;">Previous</button>
<button class="pagination-button" onclick="goToPage(1)" aria-label="{{ _('files.pagination_first') }}" style="min-height:44px;">{{ _("files.pagination_first") }}</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})" aria-label="{{ _('files.pagination_previous') }}" style="min-height:44px;">{{ _("files.pagination_previous") }}</button>
{% endif %}
{% for p in range(max(1, pagination.page - 2), min(pagination.pages + 1, pagination.page + 3)) %}
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})" aria-label="Page {{ p }}" {% if p == pagination.page %}aria-current="page"{% endif %} style="min-height:44px;">
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})" aria-label="{{ _('common.page') }} {{ p }}" {% if p == pagination.page %}aria-current="page"{% endif %} style="min-height:44px;">
{{ p }}
</button>
{% endfor %}
{% if pagination.page < pagination.pages %}
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})" aria-label="Go to next page" style="min-height:44px;">Next</button>
<button class="pagination-button" onclick="goToPage({{ pagination.pages }})" aria-label="Go to last page" style="min-height:44px;">Last</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})" aria-label="{{ _('files.pagination_next') }}" style="min-height:44px;">{{ _("files.pagination_next") }}</button>
<button class="pagination-button" onclick="goToPage({{ pagination.pages }})" aria-label="{{ _('files.pagination_last') }}" style="min-height:44px;">{{ _("files.pagination_last") }}</button>
{% endif %}
</div>
</nav>
@@ -813,11 +812,11 @@
<!-- Delete confirmation modal -->
<div id="deleteModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
<div class="modal-content">
<div class="modal-title" id="deleteModalTitle">Confirm Deletion</div>
<p>Are you sure you want to delete this file?</p>
<div class="modal-title" id="deleteModalTitle">{{ _("files.delete_modal_title") }}</div>
<p>{{ _("files.delete_modal_message") }}</p>
<div class="modal-buttons">
<button id="cancelDelete" class="modal-btn modal-btn-cancel" style="min-height:44px;min-width:80px;">Cancel</button>
<button id="confirmDelete" class="modal-btn modal-btn-delete" style="min-height:44px;min-width:80px;">Delete</button>
<button id="cancelDelete" class="modal-btn modal-btn-cancel" style="min-height:44px;min-width:80px;">{{ _("files.delete_modal_cancel") }}</button>
<button id="confirmDelete" class="modal-btn modal-btn-delete" style="min-height:44px;min-width:80px;">{{ _("files.delete_modal_confirm") }}</button>
</div>
</div>
</div>
@@ -826,8 +825,8 @@
<div id="previewOverlay" class="preview-overlay" role="dialog" aria-modal="true" aria-labelledby="previewTitle">
<div class="preview-panel">
<div class="preview-panel-header">
<h3 id="previewTitle">Preview</h3>
<button class="preview-panel-close" onclick="closePreviewModal()" aria-label="Close preview">
<h3 id="previewTitle">{{ _("files.preview_modal_title") }}</h3>
<button class="preview-panel-close" onclick="closePreviewModal()" aria-label="{{ _('files.preview_modal_close') }}">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
+39 -40
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Processing Pipelines DocuElevate{% endblock %}
{% block title %}{{ _("pipelines.title") }} DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="pipelinesApp()" x-init="init()">
@@ -9,13 +9,13 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-project-diagram text-blue-500" aria-hidden="true"></i>
Processing Pipelines
{{ _("pipelines.page_title") }}
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Define and manage custom document processing workflows.
System pipelines (created by admins) are shown with a
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1">System</span>
badge and are visible to all users.
{{ _("pipelines.subtitle_intro") }}
{{ _("pipelines.subtitle_system_pre") }}
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1">{{ _("pipelines.system_label") }}</span>
{{ _("pipelines.subtitle_system_post") }}
</p>
</div>
<button
@@ -24,7 +24,7 @@
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
style="min-height:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> New Pipeline
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("pipelines.new_pipeline_btn") }}
</button>
</div>
@@ -51,7 +51,7 @@
<template x-if="loading">
<div class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
<p>Loading pipelines</p>
<p>{{ _("pipelines.loading") }}</p>
</div>
</template>
@@ -59,15 +59,15 @@
<template x-if="!loading && pipelines.length === 0">
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
<i class="fas fa-project-diagram text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No pipelines yet</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">Create your first pipeline to define custom document processing workflows.</p>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">{{ _("pipelines.empty_state") }}</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">{{ _("pipelines.empty_state_hint") }}</p>
<button
type="button"
@click="openCreatePipelineModal()"
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md"
style="min-height:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Create Pipeline
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("pipelines.create") }}
</button>
</div>
</template>
@@ -84,13 +84,13 @@
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="pipeline.name"></h2>
<!-- Badges -->
<template x-if="pipeline.owner_id === null">
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5">System</span>
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5">{{ _("pipelines.system_label") }}</span>
</template>
<template x-if="pipeline.is_default">
<span class="text-xs font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1.5 py-0.5">Default</span>
<span class="text-xs font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1.5 py-0.5">{{ _("pipelines.default_label") }}</span>
</template>
<template x-if="!pipeline.is_active">
<span class="text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded px-1.5 py-0.5">Inactive</span>
<span class="text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded px-1.5 py-0.5">{{ _("pipelines.inactive_label") }}</span>
</template>
</div>
@@ -115,7 +115,7 @@
style="min-height:36px;"
:aria-label="`Edit pipeline ${pipeline.name}`"
>
<i class="fas fa-pencil-alt mr-1" aria-hidden="true"></i> Edit
<i class="fas fa-pencil-alt mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
</button>
<button
type="button"
@@ -124,7 +124,7 @@
style="min-height:36px;"
:aria-label="`Delete pipeline ${pipeline.name}`"
>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
</button>
</div>
</div>
@@ -139,7 +139,7 @@
<!-- Step list -->
<template x-if="(pipeline.steps || []).length === 0">
<p class="text-sm text-gray-400 italic mb-3">No steps defined. Add a step to start building your pipeline.</p>
<p class="text-sm text-gray-400 italic mb-3">{{ _("pipelines.no_steps") }}</p>
</template>
<template x-if="(pipeline.steps || []).length > 0">
@@ -153,7 +153,7 @@
<p class="text-xs text-gray-400 truncate" x-text="step.step_type"></p>
</div>
<template x-if="!step.enabled">
<span class="text-xs text-gray-400 bg-gray-200 rounded px-1.5 py-0.5 flex-shrink-0">Disabled</span>
<span class="text-xs text-gray-400 bg-gray-200 rounded px-1.5 py-0.5 flex-shrink-0">{{ _("pipelines.disabled_label") }}</span>
</template>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
@@ -201,7 +201,7 @@
style="min-height:36px;"
:aria-label="`Add step to ${pipeline.name}`"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Step
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("pipelines.add_step_btn") }}
</button>
</div>
@@ -241,7 +241,7 @@
<!-- Name -->
<div>
<label for="pipelineName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name <span aria-hidden="true" class="text-red-500">*</span>
{{ _("common.name") }} <span aria-hidden="true" class="text-red-500">*</span>
</label>
<input
id="pipelineName"
@@ -250,21 +250,21 @@
maxlength="255"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
placeholder="My pipeline"
placeholder="{{ _('pipelines.name_placeholder') }}"
autocomplete="off"
/>
</div>
<!-- Description -->
<div>
<label for="pipelineDesc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<label for="pipelineDesc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _("common.description") }}</label>
<textarea
id="pipelineDesc"
x-model="pipelineModal.form.description"
rows="2"
maxlength="4096"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white resize-none"
placeholder="Optional description"
placeholder="{{ _('pipelines.description_placeholder') }}"
></textarea>
</div>
@@ -276,7 +276,7 @@
x-model="pipelineModal.form.is_default"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Set as my default pipeline</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _("pipelines.set_default") }}</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
@@ -284,7 +284,7 @@
x-model="pipelineModal.form.is_active"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _("pipelines.active_label") }}</span>
</label>
</div>
@@ -296,7 +296,7 @@
x-model="pipelineModal.form.system"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">System pipeline (visible to all users)</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _("pipelines.system_pipeline_label") }}</span>
</label>
</template>
@@ -310,14 +310,13 @@
@click="closePipelineModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style="min-height:44px;"
>Cancel</button>
>{{ _("common.cancel") }}</button>
<button
type="submit"
:disabled="pipelineModal.saving"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60 rounded-md"
style="min-height:44px;"
>
<template x-if="pipelineModal.saving">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<span x-text="pipelineModal.editMode ? 'Save Changes' : 'Create'"></span>
@@ -358,7 +357,7 @@
<!-- Step type -->
<div>
<label for="stepType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Step Type <span aria-hidden="true" class="text-red-500">*</span>
{{ _("pipelines.step_type_label") }} <span aria-hidden="true" class="text-red-500">*</span>
</label>
<select
id="stepType"
@@ -366,7 +365,7 @@
:disabled="stepModal.editMode"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
>
<option value="">— select —</option>
<option value="">{{ _("common.select_placeholder") }}</option>
<template x-for="[key, meta] in Object.entries(stepTypes)" :key="key">
<option :value="key" x-text="meta.label"></option>
</template>
@@ -379,7 +378,7 @@
<!-- Custom label -->
<div>
<label for="stepLabel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Label <span class="text-gray-400 font-normal">(optional)</span>
{{ _("pipelines.custom_label_label") }} <span class="text-gray-400 font-normal">{{ _("pipelines.optional_suffix") }}</span>
</label>
<input
id="stepLabel"
@@ -387,7 +386,7 @@
x-model="stepModal.form.label"
maxlength="255"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
placeholder="Override the default step name"
placeholder="{{ _('pipelines.step_label_placeholder') }}"
autocomplete="off"
/>
</div>
@@ -400,7 +399,7 @@
x-model="stepModal.form.config.force_cloud_ocr"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Force cloud OCR (skip local text extraction)</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _("pipelines.force_cloud_ocr_label") }}</span>
</label>
</template>
@@ -408,14 +407,14 @@
<template x-if="stepModal.form.step_type === 'ocr'">
<div>
<label for="ocrLanguage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
OCR Language
{{ _("pipelines.ocr_language_label") }}
</label>
<select
id="ocrLanguage"
x-model="stepModal.form.config.ocr_language"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
>
<option value="auto">Auto (use system default)</option>
<option value="auto">{{ _("pipelines.ocr_auto") }}</option>
<option value="ara">Arabic</option>
<option value="chi_sim">Chinese (Simplified)</option>
<option value="chi_tra">Chinese (Traditional)</option>
@@ -446,7 +445,7 @@
<option value="vie">Vietnamese</option>
</select>
<p class="mt-1 text-xs text-gray-400">
Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.
{{ _("pipelines.ocr_language_hint") }}
</p>
</div>
</template>
@@ -458,7 +457,7 @@
x-model="stepModal.form.enabled"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _("pipelines.enabled_label") }}</span>
</label>
<!-- Error -->
@@ -471,7 +470,7 @@
@click="closeStepModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style="min-height:44px;"
>Cancel</button>
>{{ _("common.cancel") }}</button>
<button
type="submit"
:disabled="stepModal.saving"
@@ -515,13 +514,13 @@
@click="confirmModal.open = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style="min-height:44px;"
>Cancel</button>
>{{ _("common.cancel") }}</button>
<button
type="button"
@click="confirmModal.action(); confirmModal.open = false"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md"
style="min-height:44px;"
>Delete</button>
>{{ _("common.delete") }}</button>
</div>
</div>
</div>
+46 -46
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Settings - DocuElevate{% endblock %}
{% block title %}{{ _("settings.title") }} - DocuElevate{% endblock %}
{# Category icon mapping #}
{% set category_icons = {
@@ -48,27 +48,27 @@
<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>
<h1 class="text-3xl font-bold mb-1">{{ _("settings.page_heading") }}</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> &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
{{ _("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> Wizard
<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> DB Wizard
<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> Export
<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
@@ -76,18 +76,18 @@
<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
<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>Full effective config
<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> Audit Log
<i class="fas fa-history mr-1.5" aria-hidden="true"></i> {{ _("settings.audit_log_btn") }}
</a>
</div>
</div>
@@ -99,14 +99,14 @@
type="search"
x-model="searchQuery"
@input="filterSettings()"
placeholder="Search settings by name, key, or description…"
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="Search settings"
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="Clear search">
class="text-gray-400 hover:text-gray-600" aria-label="{{ _('settings.search_clear_aria') }}">
<i class="fas fa-times"></i>
</button>
</div>
@@ -125,9 +125,9 @@
<!-- 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">
<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">Categories</p>
<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"
@@ -149,11 +149,11 @@
<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>
<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="">Jump to category</option>
<option value="">{{ _("settings.jump_to_category") }}</option>
{% for category in settings_data.keys() %}
<option value="{{ category }}">{{ category }} ({{ settings_data[category]|length }})</option>
{% endfor %}
@@ -163,8 +163,8 @@
<!-- 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>
<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">
@@ -191,7 +191,7 @@
<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_list|length }} {{ _("settings.settings_count_suffix") }}
</span>
</div>
<i class="fas fa-chevron-down text-gray-400 category-chevron"
@@ -225,7 +225,7 @@
<span class="text-red-600" title="Restart required">*</span>
{% endif %}
{% if setting.metadata.required %}
<span class="text-red-600 text-xs">(required)</span>
<span class="text-red-600 text-xs">{{ _("settings.required_label") }}</span>
{% endif %}
</label>
<!-- Source Badge -->
@@ -262,7 +262,7 @@
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() }}
{{ _("settings.boolean_enable_prefix") }} {{ setting.key.replace('_', ' ').title() }}
</label>
</div>
{% elif setting.metadata.type == 'slider' %}
@@ -302,7 +302,7 @@
</label>
{% endfor %}
</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">{{ _("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">
@@ -320,7 +320,7 @@
@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…"
placeholder="{{ _('settings.user_autocomplete_placeholder') }}"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
@@ -371,11 +371,11 @@
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
<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>
Type to search existing users by name, or enter any identifier manually.
{{ _("settings.user_autocomplete_hint") }}
</p>
</div>
{% elif setting.metadata.type == 'autocomplete' %}
@@ -395,7 +395,7 @@
@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…"
placeholder="{{ _('settings.autocomplete_placeholder') }}"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
@@ -445,11 +445,11 @@
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
<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>
Type to search known values, or enter any custom value.
{{ _("settings.autocomplete_hint") }}
</p>
</div>
{% elif setting.metadata.type == 'model_picker' %}
@@ -462,7 +462,7 @@
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…"
placeholder="{{ _('settings.model_picker_placeholder') }}"
autocomplete="off"
/>
<datalist id="{{ setting.key }}_models">
@@ -472,7 +472,7 @@
</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.
{{ _("settings.model_picker_hint") }}
</p>
</div>
{% elif setting.metadata.options %}
@@ -483,7 +483,7 @@
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>
<option value="">{{ _("common.select_placeholder") }}</option>
{% for opt in setting.metadata.options %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
@@ -503,15 +503,15 @@
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">
<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="Show/hide value"
aria-label="Toggle password visibility"
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>
@@ -540,10 +540,10 @@
@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"
title="{{ _('settings.revert_title') }}"
>
<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>
<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"
@@ -552,10 +552,10 @@
@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"
title="{{ _('settings.save_setting_title') }}"
>
<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>
<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>
@@ -573,15 +573,15 @@
@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
{{ _("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">Save All Changes</span>
<span x-show="saving">Saving...</span>
<span x-show="!saving">{{ _("settings.save_all_btn") }}</span>
<span x-show="saving">{{ _("settings.saving_state") }}</span>
</button>
</div>
</form>
+84 -3
View File
@@ -193,7 +193,7 @@
"upload.button_processing": "Processing...",
"files.page_title": "File Records",
"files.drop_overlay_title": "Drop files or folders anywhere to upload",
"files.drop_overlay_hint": "Supports PDF, Office docs, images, HTML, Markdown, and more",
"files.drop_overlay_hint": "Supports PDF, Office docs, images, HTML, Markdown, and more folders are processed recursively",
"files.upload_modal_header": "Uploading Files",
"files.queue_banner_link": "View Queue",
"files.filter_search_placeholder": "Enter filename...",
@@ -216,7 +216,7 @@
"files.saved_searches_empty": "No saved searches yet",
"files.saved_searches_save": "Save Current",
"files.saved_searches_error": "Could not load saved searches",
"files.fulltext_search_label": "Full-Text Search",
"files.fulltext_search_label": "Full-Text Search (OCR text, metadata, tags)",
"files.fulltext_search_placeholder": "Search document content, sender, tags, type...",
"files.search_results_title": "Search Results",
"files.search_results_empty": "No results found.",
@@ -754,5 +754,86 @@
"common.prev_page": "Previous page",
"common.next_page": "Next page",
"common.page": "Page",
"common.col_pending": "Pending"
"common.col_pending": "Pending",
"files.upload_modal_close": "Close upload progress",
"files.upload_modal_aria": "Upload progress",
"files.filter_form_aria": "Filter files",
"files.filter_search_label": "Search Filename",
"files.filter_date_from_aria": "Filter from date",
"files.filter_date_to_aria": "Filter to date",
"files.filter_tags_aria": "Filter by tags (comma-separated)",
"files.filter_ocr_quality_aria": "Filter by OCR quality score",
"files.saved_searches_save_aria": "Save current filters as a saved search",
"files.status_duplicate": "Duplicate",
"files.files_selected": "files selected",
"files.error_hint": "This might be due to a configuration or import issue. Please check the server logs.",
"files.queue_banner_items": "item(s) are currently queued or being processed. Files will appear here once processing completes.",
"files.pagination_showing": "Showing",
"files.pagination_of": "of",
"files.pagination_files": "files",
"files.table_aria": "File records",
"files.pagination_nav_aria": "File list pagination",
"settings.page_heading": "Application Settings",
"settings.manage_config_prefix": "Manage configuration. Priority:",
"settings.source_db_badge": "DB",
"settings.source_env_badge": "ENV",
"settings.source_default_badge": "DEFAULT",
"settings.restart_required_suffix": "= restart required",
"settings.encrypted_suffix": "= encrypted at rest",
"settings.wizard_btn": "Wizard",
"settings.db_wizard_btn": "DB Wizard",
"settings.export_btn": "Export",
"settings.export_db_only": "DB settings only",
"settings.export_full_config": "Full effective config",
"settings.audit_log_btn": "Audit Log",
"settings.search_placeholder": "Search settings by name, key, or description…",
"settings.search_aria_label": "Search settings",
"settings.search_clear_aria": "Clear search",
"settings.categories_heading": "Categories",
"settings.categories_aria": "Settings categories",
"settings.jump_to_category": "Jump to category…",
"settings.no_results_heading": "No settings found",
"settings.no_results_hint": "Try a different search term or",
"settings.no_results_clear": "clear the search",
"settings.settings_count_suffix": "settings",
"settings.required_label": "(required)",
"settings.boolean_enable_prefix": "Enable",
"settings.effective_value_label": "Effective value:",
"settings.user_autocomplete_empty": "No matching users found",
"settings.user_autocomplete_hint": "Type to search existing users by name, or enter any identifier manually.",
"settings.user_autocomplete_placeholder": "Start typing to search users…",
"settings.autocomplete_no_matches": "No matches — you can still type a custom value",
"settings.autocomplete_hint": "Type to search known values, or enter any custom value.",
"settings.autocomplete_placeholder": "Type to search or enter a value…",
"settings.model_picker_placeholder": "Select a common model or type a custom name…",
"settings.model_picker_hint": "Pick from the list or type any model name supported by your provider.",
"settings.encrypted_title": "Value is encrypted at rest in database",
"settings.toggle_visibility_title": "Show/hide value",
"settings.toggle_visibility_aria": "Toggle password visibility",
"settings.revert_title": "Remove DB override and revert to environment variable or default",
"settings.revert_btn": "Remove from DB",
"settings.reverting": "Reverting…",
"settings.save_setting_title": "Save this setting",
"settings.saving_state": "Saving…",
"settings.save_all_btn": "Save All Changes",
"pipelines.new_pipeline_btn": "New Pipeline",
"pipelines.subtitle_intro": "Define and manage custom document processing workflows.",
"pipelines.subtitle_system_pre": "System pipelines (created by admins) are shown with a",
"pipelines.subtitle_system_post": "badge and are visible to all users.",
"pipelines.loading": "Loading pipelines…",
"pipelines.empty_state_hint": "Create your first pipeline to define custom document processing workflows.",
"pipelines.no_steps": "No steps defined. Add a step to start building your pipeline.",
"pipelines.add_step_btn": "Add Step",
"pipelines.name_placeholder": "My pipeline",
"pipelines.description_placeholder": "Optional description",
"pipelines.system_pipeline_label": "System pipeline (visible to all users)",
"pipelines.step_type_label": "Step Type",
"pipelines.custom_label_label": "Custom Label",
"pipelines.optional_suffix": "(optional)",
"pipelines.step_label_placeholder": "Override the default step name",
"pipelines.force_cloud_ocr_label": "Force cloud OCR (skip local text extraction)",
"pipelines.ocr_language_label": "OCR Language",
"pipelines.ocr_auto": "Auto (use system default)",
"pipelines.ocr_language_hint": "Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.",
"common.select_placeholder": "— select —"
}