i18n: translate 6 HTML Jinja2 templates with _() calls

Replace all hardcoded English visible text with {{ _("key") }} calls in:
- queue_dashboard.html: headings, stat cards, table headers, empty states
- api_tokens.html: form labels, table headers, status badges, aria-labels
- similarity_dashboard.html: headings, stat labels, controls, empty states
- backup.html: buttons, config labels, table, retention section, confirm dialog
- shared_links.html: form labels/options, table headers, status badges
- duplicates.html: tabs, role tags, near-dup form, explanation bullets

Follows i18n rules: placeholder/aria-label/title attrs translated,
JS strings in <script> left as-is, Alpine.js bindings use Jinja2
server-side evaluation pattern for dynamic translated strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-11 12:22:46 +00:00
parent fdff970e59
commit 64577f2d92
8 changed files with 260 additions and 280 deletions
+31 -33
View File
@@ -1,32 +1,30 @@
{% extends "base.html" %}
{% block title %}API Tokens DocuElevate{% endblock %}
{% block title %}{{ _("api_tokens.page_title") }}{% endblock %}
{% block content %}
<div x-data="apiTokens()" x-init="loadTokens()" class="container mx-auto px-4 py-8 max-w-4xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-key text-yellow-500" aria-hidden="true"></i>
API Tokens
{{ _("api_tokens.heading") }}
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
Create personal API tokens to interact with the DocuElevate API programmatically.
Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload
or retrieve documents.
{{ _("api_tokens.intro") }}
</p>
</header>
<!-- Create token section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-token-heading">
<h2 id="create-token-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Token</h2>
<h2 id="create-token-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("api_tokens.create_heading") }}</h2>
<form @submit.prevent="createToken()" class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<label for="token-name" class="sr-only">Token name</label>
<label for="token-name" class="sr-only">{{ _("api_tokens.token_name_label") }}</label>
<input
id="token-name"
type="text"
x-model="newTokenName"
placeholder="e.g. CI Pipeline, Webhook Upload, My Script"
placeholder="{{ _('api_tokens.token_name_placeholder') }}"
required
minlength="1"
maxlength="255"
@@ -44,7 +42,7 @@
style="min-height:40px; min-width:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i>
<span x-text="creating ? 'Creating…' : 'Create Token'"></span>
<span x-text="creating ? '{{ _('api_tokens.creating') }}' : '{{ _('api_tokens.create_token') }}'"></span>
</button>
</form>
@@ -54,9 +52,9 @@
<div class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Token created successfully!</p>
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">{{ _("api_tokens.token_created") }}</p>
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
Copy this token now — it will <strong>not be shown again</strong>.
{{ _("api_tokens.copy_warning_1") }} <strong>{{ _("api_tokens.copy_warning_2") }}</strong>.
</p>
<div class="mt-3 flex items-center gap-2">
<code
@@ -72,10 +70,10 @@
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors"
style="min-height:40px; min-width:44px;"
:aria-label="copied ? 'Copied!' : 'Copy token to clipboard'"
:aria-label="copied ? '{{ _('common.copied') }}' : '{{ _('api_tokens.copy_to_clipboard') }}'"
>
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
<span class="ml-1 hidden sm:inline" x-text="copied ? '{{ _('common.copied') }}' : '{{ _('common.copy') }}'"></span>
</button>
</div>
</div>
@@ -88,11 +86,11 @@
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="usage-heading">
<h2 id="usage-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
<i class="fas fa-code text-blue-500 mr-2" aria-hidden="true"></i>
Usage Example
{{ _("api_tokens.usage_heading") }}
</h2>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">
Use your API token in the <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">Authorization</code>
header with any API request:
{{ _("api_tokens.usage_intro_pre") }} <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">Authorization</code>
{{ _("api_tokens.usage_intro_post") }}
</p>
<div class="relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-sm overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="baseUrl"></span>/api/files/ui-upload" \
@@ -104,7 +102,7 @@
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600
focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors"
style="min-height:30px; min-width:30px;"
aria-label="Copy upload example to clipboard"
aria-label="{{ _('api_tokens.copy_upload_example') }}"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
@@ -114,14 +112,14 @@
<!-- Tokens list -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden" aria-labelledby="tokens-heading">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">Your Tokens</h2>
<h2 id="tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("api_tokens.your_tokens") }}</h2>
</div>
<!-- Loading state -->
<template x-if="loading">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
<p class="text-sm">Loading tokens</p>
<p class="text-sm">{{ _("api_tokens.loading_tokens") }}</p>
</div>
</template>
@@ -129,24 +127,24 @@
<template x-if="!loading && tokens.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-key text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
<p class="font-medium">No API tokens yet</p>
<p class="text-sm mt-1">Create your first token above to get started.</p>
<p class="font-medium">{{ _("api_tokens.no_tokens_heading") }}</p>
<p class="text-sm mt-1">{{ _("api_tokens.no_tokens_help") }}</p>
</div>
</template>
<!-- Tokens table -->
<template x-if="!loading && tokens.length > 0">
<div class="overflow-x-auto">
<table class="w-full text-sm" aria-label="API Tokens">
<table class="w-full text-sm" aria-label="{{ _('api_tokens.table_aria') }}">
<thead>
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Token Prefix</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Created</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last Used</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Last IP</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">Actions</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_name") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_prefix") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_created") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_used") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_ip") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@@ -159,7 +157,7 @@
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="token.last_used_at ? formatDate(token.last_used_at) : 'Never'"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="token.last_used_at ? formatDate(token.last_used_at) : '{{ _('api_tokens.never') }}'"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
<span x-show="!token.last_used_ip" class="text-gray-400"></span>
@@ -168,7 +166,7 @@
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? 'Active' : 'Revoked'"
x-text="token.is_active ? '{{ _('api_tokens.status_active') }}' : '{{ _('api_tokens.status_revoked') }}'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
@@ -181,10 +179,10 @@
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'Revoke token ' + token.name"
:aria-label="'{{ _('api_tokens.revoke_prefix') }} ' + token.name"
>
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
Revoke
{{ _("api_tokens.revoke") }}
</button>
</td>
</tr>
+56 -57
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Backup Management{% endblock %}
{% block title %}{{ _("backup.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="backupDashboard()">
@@ -9,10 +9,10 @@
<div>
<h1 class="text-3xl font-bold text-gray-900">
<i class="fas fa-database mr-2 text-blue-600" aria-hidden="true"></i>
Backup Management
{{ _("backup.heading") }}
</h1>
<p class="mt-1 text-sm text-gray-500">
Database backups are created automatically: hourly (4 days), daily (3 weeks), weekly (13 weeks).
{{ _("backup.subtitle") }}
</p>
</div>
<div class="flex gap-2 flex-wrap">
@@ -21,26 +21,26 @@
:disabled="triggering"
type="button"
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 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-clock mr-1 text-blue-500" aria-hidden="true"></i> Backup Now (Hourly)
<i class="fas fa-clock mr-1 text-blue-500" aria-hidden="true"></i> {{ _("backup.trigger_hourly") }}
</button>
<button @click="triggerBackup('daily')"
:disabled="triggering"
type="button"
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 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-day mr-1 text-green-500" aria-hidden="true"></i> Backup Now (Daily)
<i class="fas fa-calendar-day mr-1 text-green-500" aria-hidden="true"></i> {{ _("backup.trigger_daily") }}
</button>
<button @click="triggerBackup('weekly')"
:disabled="triggering"
type="button"
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 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-week mr-1 text-purple-500" aria-hidden="true"></i> Backup Now (Weekly)
<i class="fas fa-calendar-week mr-1 text-purple-500" aria-hidden="true"></i> {{ _("backup.trigger_weekly") }}
</button>
<button @click="runCleanup()"
:disabled="triggering"
type="button"
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 disabled:opacity-50 min-h-[44px]"
title="Run retention cleanup now">
<i class="fas fa-broom mr-1 text-orange-400" aria-hidden="true"></i> Clean Up
title="{{ _('backup.cleanup_title') }}">
<i class="fas fa-broom mr-1 text-orange-400" aria-hidden="true"></i> {{ _("backup.clean_up") }}
</button>
</div>
</div>
@@ -60,18 +60,18 @@
role="dialog" aria-modal="true" :aria-labelledby="'confirmTitle'">
<div class="bg-white rounded-lg shadow-xl max-w-sm w-full p-6"
@keydown.escape.window="confirmOpen = false">
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 mb-2">Confirm action</h2>
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 mb-2">{{ _("backup.confirm_title") }}</h2>
<p class="text-sm text-gray-600 mb-4" x-text="confirmMsg"></p>
<div class="flex justify-end gap-3">
<button @click="confirmOpen = false; confirmResolve(false)"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 min-h-[44px]">
Cancel
{{ _("common.cancel") }}
</button>
<button @click="confirmOpen = false; confirmResolve(true)"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 min-h-[44px]">
Confirm
{{ _("backup.confirm_btn") }}
</button>
</div>
</div>
@@ -81,36 +81,36 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- Backup enabled -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Auto-backup</p>
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_auto_backup") }}</p>
<p class="mt-1 text-lg font-semibold {% if backup_enabled %}text-green-600{% else %}text-red-600{% endif %}">
{% if backup_enabled %}<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> Enabled
{% else %}<i class="fas fa-times-circle mr-1" aria-hidden="true"></i> Disabled{% endif %}
{% if backup_enabled %}<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("common.enabled") }}
{% else %}<i class="fas fa-times-circle mr-1" aria-hidden="true"></i> {{ _("common.disabled") }}{% endif %}
</p>
</div>
<!-- Remote destination -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Remote destination</p>
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_remote_dest") }}</p>
<p class="mt-1 text-lg font-semibold text-gray-800">
{% if backup_remote_destination %}
<i class="fas fa-cloud-upload-alt mr-1 text-blue-500" aria-hidden="true"></i>
{{ backup_remote_destination }}
{% else %}
<span class="text-gray-400"><i class="fas fa-hdd mr-1" aria-hidden="true"></i> Local only</span>
<span class="text-gray-400"><i class="fas fa-hdd mr-1" aria-hidden="true"></i> {{ _("backup.local_only") }}</span>
{% endif %}
</p>
</div>
<!-- Retention hourly -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Retention</p>
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_retention") }}</p>
<p class="mt-1 text-sm text-gray-700">
<span class="font-semibold">{{ backup_retain_hourly }}</span> hourly &middot;
<span class="font-semibold">{{ backup_retain_daily }}</span> daily &middot;
<span class="font-semibold">{{ backup_retain_weekly }}</span> weekly
<span class="font-semibold">{{ backup_retain_hourly }}</span> {{ _("backup.type_hourly") }} &middot;
<span class="font-semibold">{{ backup_retain_daily }}</span> {{ _("backup.type_daily") }} &middot;
<span class="font-semibold">{{ backup_retain_weekly }}</span> {{ _("backup.type_weekly") }}
</p>
</div>
<!-- Total backup size -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">Total local size</p>
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_total_size") }}</p>
<p class="mt-1 text-lg font-semibold text-gray-800" id="totalSize">
{{ (total_size / 1048576) | round(2) }} MB
</p>
@@ -120,17 +120,17 @@
<!-- Upload restore section -->
<div class="bg-white shadow rounded-lg p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-1">
<i class="fas fa-upload mr-2 text-yellow-500" aria-hidden="true"></i>Restore from File
<i class="fas fa-upload mr-2 text-yellow-500" aria-hidden="true"></i>{{ _("backup.restore_heading") }}
</h2>
<p class="text-sm text-gray-500 mb-4">
Upload a <code>.db.gz</code> backup archive to restore the database.
<strong class="text-red-600">This will overwrite all current data.</strong>
{{ _("backup.restore_desc_pre") }} <code>.db.gz</code> {{ _("backup.restore_desc_mid") }}
<strong class="text-red-600">{{ _("backup.restore_desc_warning") }}</strong>
</p>
<form id="restoreForm" @submit.prevent="submitRestore" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div>
<label for="restoreFile" class="block text-sm font-medium text-gray-700 mb-1">Backup archive (.db.gz)</label>
<label for="restoreFile" class="block text-sm font-medium text-gray-700 mb-1">{{ _("backup.restore_file_label") }}</label>
<input type="file" id="restoreFile" name="file" accept=".gz"
required
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
@@ -139,7 +139,7 @@
:disabled="restoring"
class="inline-flex items-center px-4 py-2 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-md disabled:opacity-50 min-h-[44px]">
<i class="fas fa-undo mr-2" aria-hidden="true"></i>
<span x-text="restoring ? 'Restoring…' : 'Restore'"></span>
<span x-text="restoring ? '{{ _('backup.restoring') }}' : '{{ _('backup.restore_btn') }}'"></span>
</button>
</div>
</form>
@@ -149,30 +149,30 @@
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-list mr-2 text-gray-500" aria-hidden="true"></i>Backup Archives
<span class="ml-2 text-sm font-normal text-gray-400">({{ records | length }} records)</span>
<i class="fas fa-list mr-2 text-gray-500" aria-hidden="true"></i>{{ _("backup.archives_heading") }}
<span class="ml-2 text-sm font-normal text-gray-400">({{ records | length }} {{ _("backup.records_label") }})</span>
</h2>
<!-- Tier filter -->
<div class="flex gap-2 text-sm">
<button @click="filterType = ''" :class="filterType === '' ? 'bg-gray-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">All</button>
<button @click="filterType = 'hourly'" :class="filterType === 'hourly' ? 'bg-blue-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Hourly</button>
<button @click="filterType = 'daily'" :class="filterType === 'daily' ? 'bg-green-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Daily</button>
<button @click="filterType = 'weekly'" :class="filterType === 'weekly' ? 'bg-purple-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">Weekly</button>
<button @click="filterType = ''" :class="filterType === '' ? 'bg-gray-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("common.all") }}</button>
<button @click="filterType = 'hourly'" :class="filterType === 'hourly' ? 'bg-blue-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_hourly") }}</button>
<button @click="filterType = 'daily'" :class="filterType === 'daily' ? 'bg-green-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_daily") }}</button>
<button @click="filterType = 'weekly'" :class="filterType === 'weekly' ? 'bg-purple-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_weekly") }}</button>
</div>
</div>
{% if records %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Backup archives">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('backup.table_aria') }}">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Filename</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Storage</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_filename") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_type") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_created") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_size") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_storage") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@@ -186,15 +186,15 @@
<td class="px-4 py-3 text-sm">
{% if r.backup_type == 'hourly' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-clock mr-1" aria-hidden="true"></i> hourly
<i class="fas fa-clock mr-1" aria-hidden="true"></i> {{ _("backup.type_hourly") }}
</span>
{% elif r.backup_type == 'daily' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-calendar-day mr-1" aria-hidden="true"></i> daily
<i class="fas fa-calendar-day mr-1" aria-hidden="true"></i> {{ _("backup.type_daily") }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-calendar-week mr-1" aria-hidden="true"></i> weekly
<i class="fas fa-calendar-week mr-1" aria-hidden="true"></i> {{ _("backup.type_weekly") }}
</span>
{% endif %}
</td>
@@ -209,7 +209,7 @@
<td class="px-4 py-3 text-sm">
{% if r.status == 'ok' %}
<span class="inline-flex items-center text-green-700">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> ok
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("backup.status_ok") }}
</span>
{% else %}
<span class="inline-flex items-center text-red-600">
@@ -220,7 +220,7 @@
<td class="px-4 py-3 text-sm text-gray-600">
{% if r.local_path %}
<span class="inline-flex items-center" title="{{ r.local_path }}">
<i class="fas fa-hdd mr-1 text-gray-400" aria-hidden="true"></i> local
<i class="fas fa-hdd mr-1 text-gray-400" aria-hidden="true"></i> {{ _("backup.storage_local") }}
</span>
{% endif %}
{% if r.remote_destination %}
@@ -236,15 +236,15 @@
{% if r.local_path %}
<a href="/api/admin/backup/{{ r.id }}/download"
class="inline-flex items-center px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700 min-h-[32px]"
aria-label="Download backup {{ r.filename }}">
<i class="fas fa-download mr-1" aria-hidden="true"></i> Download
aria-label="{{ _('common.download') }} {{ r.filename }}">
<i class="fas fa-download mr-1" aria-hidden="true"></i> {{ _("common.download") }}
</a>
{% endif %}
<button @click="deleteBackup({{ r.id }}, '{{ r.filename }}')"
type="button"
class="inline-flex items-center px-2 py-1 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600 ml-1 min-h-[32px]"
aria-label="Delete backup {{ r.filename }}">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i> Delete
aria-label="{{ _('common.delete') }} {{ r.filename }}">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
</button>
</td>
</tr>
@@ -255,8 +255,8 @@
{% else %}
<div class="px-6 py-12 text-center text-gray-400">
<i class="fas fa-database text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg font-medium">No backups yet.</p>
<p class="text-sm">Click <em>Backup Now</em> to create your first backup.</p>
<p class="text-lg font-medium">{{ _("backup.no_backups") }}</p>
<p class="text-sm">{{ _("backup.no_backups_hint_pre") }}<em>{{ _("backup.backup_now") }}</em>{{ _("backup.no_backups_hint_post") }}</p>
</div>
{% endif %}
</div>
@@ -264,16 +264,15 @@
<!-- Retention explanation -->
<div class="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-info-circle mr-1 text-blue-400" aria-hidden="true"></i> Retention policy
<i class="fas fa-info-circle mr-1 text-blue-400" aria-hidden="true"></i> {{ _("backup.retention_heading") }}
</h3>
<ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li><strong>Hourly</strong> kept for 4 days ({{ backup_retain_hourly }} snapshots)</li>
<li><strong>Daily</strong> kept for 3 weeks ({{ backup_retain_daily }} snapshots)</li>
<li><strong>Weekly</strong> kept for ~3 months ({{ backup_retain_weekly }} snapshots)</li>
<li><strong>{{ _("backup.type_hourly") }}</strong> {{ _("backup.retention_hourly_detail") }} ({{ backup_retain_hourly }} {{ _("backup.snapshots") }})</li>
<li><strong>{{ _("backup.type_daily") }}</strong> {{ _("backup.retention_daily_detail") }} ({{ backup_retain_daily }} {{ _("backup.snapshots") }})</li>
<li><strong>{{ _("backup.type_weekly") }}</strong> {{ _("backup.retention_weekly_detail") }} ({{ backup_retain_weekly }} {{ _("backup.snapshots") }})</li>
</ul>
<p class="text-xs text-gray-400 mt-2">
Backups beyond these limits are automatically pruned after each new backup is created.
Use the <em>Clean Up</em> button to apply retention manually.
{{ _("backup.retention_note_pre") }}<em>{{ _("backup.clean_up") }}</em>{{ _("backup.retention_note_post") }}
</p>
</div>
+37 -50
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Duplicate Documents - DocuElevate{% endblock %}
{% block title %}{{ _("duplicates.page_title") }}{% endblock %}
{% block head_extra %}
<style>
@@ -88,22 +88,20 @@
{% block content %}
<main id="main-content" class="dup-container px-4 py-8">
<h1 class="text-2xl font-bold mb-2">Duplicate Documents</h1>
<h1 class="text-2xl font-bold mb-2">{{ _("duplicates.heading") }}</h1>
<p class="text-gray-500 mb-6 text-sm">
Documents with identical content (same SHA-256 hash) are shown as
<span class="dup-badge dup-badge-exact">exact duplicates</span>.
Use the <strong>Near-Duplicate Finder</strong> tab to detect documents with the
same scanned content but different hashes — for example, a document scanned
twice.
{{ _("duplicates.subtitle_1") }}
<span class="dup-badge dup-badge-exact">{{ _("duplicates.exact_duplicates_badge") }}</span>.
{{ _("duplicates.subtitle_2_pre") }} <strong>{{ _("duplicates.tab_near") }}</strong> {{ _("duplicates.subtitle_2_post") }}
</p>
<!-- Tabs -->
<div class="tabs" role="tablist" aria-label="Duplicate detection tabs">
<div class="tabs" role="tablist" aria-label="{{ _('duplicates.tabs_aria') }}">
<button class="tab-btn active" role="tab" aria-selected="true"
aria-controls="tab-exact" id="btn-exact"
onclick="switchTab('exact')">
<i class="fas fa-clone mr-1" aria-hidden="true"></i>
Exact Duplicates
{{ _("duplicates.tab_exact") }}
{% if total_groups is defined %}
<span class="ml-1 text-xs bg-red-100 text-red-700 font-bold px-1.5 rounded-full">
{{ total_groups }}
@@ -114,7 +112,7 @@
aria-controls="tab-near" id="btn-near"
onclick="switchTab('near')">
<i class="fas fa-layer-group mr-1" aria-hidden="true"></i>
Near-Duplicate Finder
{{ _("duplicates.tab_near") }}
</button>
</div>
@@ -123,12 +121,12 @@
{% if groups %}
<p class="text-sm text-gray-500 mb-4">
Found <strong>{{ total_groups }}</strong> duplicate group(s) with
<strong>{{ total_duplicate_files }}</strong> duplicate file(s) total.
{{ _("duplicates.found_prefix") }} <strong>{{ total_groups }}</strong> {{ _("duplicates.found_groups") }}
<strong>{{ total_duplicate_files }}</strong> {{ _("duplicates.found_files") }}
</p>
{% for group in groups %}
<section class="dup-group" aria-label="Duplicate group {{ loop.index }}">
<section class="dup-group" aria-label="{{ _('duplicates.group_aria') }} {{ loop.index }}">
<div class="dup-group-header">
<i class="fas fa-fingerprint text-gray-400" aria-hidden="true"></i>
<span class="text-xs font-mono text-gray-500" title="SHA-256 hash">
@@ -136,14 +134,14 @@
</span>
<span class="dup-badge dup-badge-exact">
<i class="fas fa-copy" aria-hidden="true"></i>
{{ group.duplicate_count }} duplicate{{ 's' if group.duplicate_count != 1 else '' }}
{{ group.duplicate_count }} {{ _("duplicates.dup_singular") if group.duplicate_count == 1 else _("duplicates.dup_plural") }}
</span>
</div>
<!-- Original file -->
{% if group.original %}
<div class="dup-file-row original">
<span class="dup-role-tag tag-original">Original</span>
<span class="dup-role-tag tag-original">{{ _("duplicates.role_original") }}</span>
<div class="flex-1 min-w-0">
<div class="dup-filename">
<a href="/files/{{ group.original.id }}"
@@ -164,8 +162,8 @@
<div class="dup-actions">
<a href="/files/{{ group.original.id }}"
class="text-sm text-blue-600 hover:underline"
aria-label="View original file {{ group.original.original_filename }}">
<i class="fas fa-external-link-alt" aria-hidden="true"></i> View
aria-label="{{ _('duplicates.view_original_aria') }} {{ group.original.original_filename }}">
<i class="fas fa-external-link-alt" aria-hidden="true"></i> {{ _("common.view") }}
</a>
</div>
</div>
@@ -174,7 +172,7 @@
<!-- Duplicate files -->
{% for dup in group.duplicates %}
<div class="dup-file-row duplicate">
<span class="dup-role-tag tag-duplicate">Duplicate</span>
<span class="dup-role-tag tag-duplicate">{{ _("duplicates.role_duplicate") }}</span>
<div class="flex-1 min-w-0">
<div class="dup-filename">
<a href="/files/{{ dup.id }}" class="hover:text-blue-600">
@@ -194,8 +192,8 @@
<div class="dup-actions">
<a href="/files/{{ dup.id }}"
class="text-sm text-blue-600 hover:underline"
aria-label="View duplicate file {{ dup.original_filename }}">
<i class="fas fa-external-link-alt" aria-hidden="true"></i> View
aria-label="{{ _('duplicates.view_dup_aria') }} {{ dup.original_filename }}">
<i class="fas fa-external-link-alt" aria-hidden="true"></i> {{ _("common.view") }}
</a>
</div>
</div>
@@ -205,23 +203,23 @@
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav class="pagination" aria-label="Pagination">
<nav class="pagination" aria-label="{{ _('duplicates.pagination_aria') }}">
<button class="page-btn" onclick="changePage({{ pagination.page - 1 }})"
{% if pagination.page <= 1 %}disabled{% endif %}
aria-label="Previous page">
aria-label="{{ _('common.prev_page') }}">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</button>
{% for p in range(1, pagination.pages + 1) %}
<button class="page-btn {% if p == pagination.page %}current{% endif %}"
onclick="changePage({{ p }})"
aria-label="Page {{ p }}"
aria-label="{{ _('common.page') }} {{ p }}"
{% if p == pagination.page %}aria-current="page"{% endif %}>
{{ p }}
</button>
{% endfor %}
<button class="page-btn" onclick="changePage({{ pagination.page + 1 }})"
{% if pagination.page >= pagination.pages %}disabled{% endif %}
aria-label="Next page">
aria-label="{{ _('common.next_page') }}">
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</nav>
@@ -230,11 +228,9 @@
{% else %}
<div class="empty-state">
<i class="fas fa-check-circle text-green-400" aria-hidden="true"></i>
<p class="font-semibold text-lg text-gray-700">No exact duplicates found</p>
<p class="font-semibold text-lg text-gray-700">{{ _("duplicates.no_exact_heading") }}</p>
<p class="text-sm mt-1">
Every file in the system has a unique SHA-256 hash.
Use the <strong>Near-Duplicate Finder</strong> tab to check for
re-scanned documents.
{{ _("duplicates.no_exact_detail_pre") }} <strong>{{ _("duplicates.tab_near") }}</strong> {{ _("duplicates.no_exact_detail_post") }}
</p>
</div>
{% endif %}
@@ -246,32 +242,29 @@
<div class="near-dup-search">
<h3>
<i class="fas fa-search mr-1 text-yellow-500" aria-hidden="true"></i>
Find Near-Duplicates for a Document
{{ _("duplicates.near_dup_heading") }}
</h3>
<p class="text-sm text-gray-500 mb-4">
Select a document to check whether any other files contain the same (or very
similar) content — even if they were scanned at different times and have
different SHA-256 hashes. Similarity is computed from OCR text embeddings;
documents without extracted text cannot be compared.
{{ _("duplicates.near_dup_desc") }}
</p>
<form id="nearDupForm" onsubmit="findNearDups(event)">
<div class="nd-form">
<div class="flex flex-col gap-1">
<label for="ndFileId">File ID</label>
<label for="ndFileId">{{ _("duplicates.file_id_label") }}</label>
<input type="number" id="ndFileId" name="file_id" min="1"
placeholder="e.g. 42" required
placeholder="{{ _('duplicates.file_id_placeholder') }}" required
style="min-height:44px;">
</div>
<div class="flex flex-col gap-1">
<label for="ndThreshold">Similarity threshold</label>
<label for="ndThreshold">{{ _("duplicates.threshold_label") }}</label>
<input type="number" id="ndThreshold" name="threshold"
min="0" max="1" step="0.05"
value="{{ near_duplicate_threshold }}"
style="min-height:44px;">
</div>
<div class="flex flex-col gap-1">
<label for="ndLimit">Max results</label>
<label for="ndLimit">{{ _("duplicates.max_results_label") }}</label>
<select id="ndLimit" name="limit" style="min-height:44px;">
<option value="5" selected>5</option>
<option value="10">10</option>
@@ -282,7 +275,7 @@
class="bg-yellow-500 hover:bg-yellow-600 text-white font-bold
px-5 rounded-lg transition-colors"
style="min-height:44px;">
<i class="fas fa-search mr-1" aria-hidden="true"></i> Find
<i class="fas fa-search mr-1" aria-hidden="true"></i> {{ _("duplicates.find_btn") }}
</button>
</div>
</form>
@@ -296,20 +289,14 @@
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<p class="font-semibold mb-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
How near-duplicate detection works
{{ _("duplicates.how_it_works_heading") }}
</p>
<ul class="list-disc ml-5 space-y-1">
<li>After OCR processes a document, its text is converted to a numeric
<em>embedding vector</em> using an AI language model.</li>
<li>Near-duplicate detection compares these vectors using <em>cosine
similarity</em>: a score of 1.0 means identical content, 0.0 means
completely different.</li>
<li>A threshold of <strong>{{ near_duplicate_threshold }}</strong> means
documents must share at least
{{ (near_duplicate_threshold * 100)|round|int }}% semantic similarity
to be flagged.</li>
<li>Documents scanned twice (even with slight differences) will typically
score <strong>&gt; 0.90</strong>.</li>
<li>{{ _("duplicates.how_step1_pre") }} <em>{{ _("duplicates.embedding_vector") }}</em> {{ _("duplicates.how_step1_post") }}</li>
<li>{{ _("duplicates.how_step2_pre") }} <em>{{ _("duplicates.cosine_similarity") }}</em>{{ _("duplicates.how_step2_post") }}</li>
<li>{{ _("duplicates.how_step3_pre") }} <strong>{{ near_duplicate_threshold }}</strong> {{ _("duplicates.how_step3_mid") }}
{{ (near_duplicate_threshold * 100)|round|int }}% {{ _("duplicates.how_step3_post") }}</li>
<li>{{ _("duplicates.how_step4") }} <strong>&gt; 0.90</strong>.</li>
</ul>
</div>
</div><!-- /tab-near -->
+16 -16
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Login</title>
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
@@ -12,10 +12,10 @@
<body class="bg-gray-100 h-screen flex items-center justify-center">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
<img src="/static/images/logo_writing.svg" alt="{{ _('auth.logo_alt') }}" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-6">Welcome to DocuElevate</h1>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-6">{{ _("dashboard.welcome") }}</h1>
{% if error %}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
@@ -31,33 +31,33 @@
<!-- Local authentication form -->
<div class="mb-8" id="local-auth">
<h2 class="text-lg font-semibold mb-4 text-gray-700">Sign in with username</h2>
<h2 class="text-lg font-semibold mb-4 text-gray-700">{{ _("auth.sign_in_with_username") }}</h2>
<form method="POST" action="/auth" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default(\'\', true) }}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username or Email</label>
<label for="username" class="block text-sm font-medium text-gray-700">{{ _("auth.username_or_email") }}</label>
<input type="text" id="username" name="username" required
autocomplete="username"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<label for="password" class="block text-sm font-medium text-gray-700">{{ _("auth.password_label") }}</label>
<input type="password" id="password" name="password" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign in
{{ _("auth.sign_in") }}
</button>
</form>
<div class="mt-3 text-center space-x-3">
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
Forgot password?
{{ _("auth.forgot_password") }}
</a>
<span class="text-gray-300" aria-hidden="true">|</span>
<a href="/forgot-username" class="text-sm text-blue-600 hover:text-blue-500">
Forgot username?
{{ _("auth.forgot_username") }}
</a>
</div>
</div>
@@ -69,14 +69,14 @@
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
<span class="px-2 bg-white text-gray-500">{{ _("auth.or_continue_with") }}</span>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-3">
<a href="/oauth-login"
class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<span class="sr-only">Sign in with SSO</span>
<span class="sr-only">{{ _("auth.sign_in_sso") }}</span>
<i class="fas fa-lock mr-2" aria-hidden="true"></i>
{{ oauth_provider_name }}
</a>
@@ -85,21 +85,21 @@
<div class="mt-8 text-center">
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Return to Home
{{ _("auth.return_home") }}
</a>
</div>
{% if allow_signup %}
<div class="mt-4 text-center">
<span class="text-sm text-gray-600">Don't have an account?</span>
<span class="text-sm text-gray-600">{{ _("auth.no_account") }}</span>
<a href="/signup" class="ml-1 text-sm font-medium text-indigo-600 hover:text-indigo-500">
Create account
{{ _("auth.create_account") }}
</a>
</div>
{% endif %}
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
{{ _("app.name") }} {{ app_version|default(\'\', true) }}
</div>
</body>
</html>
+29 -29
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Queue Monitor — DocuElevate{% endblock %}
{% block title %}{{ _("queue.page_title") }}{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
@@ -11,20 +11,20 @@
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold mb-2">
<i class="fas fa-stream mr-2 text-blue-500" aria-hidden="true"></i>Queue Monitor
<i class="fas fa-stream mr-2 text-blue-500" aria-hidden="true"></i>{{ _("queue.heading") }}
</h1>
<p class="text-gray-600">Real-time view of the document processing pipeline and Celery task queues.</p>
<p class="text-gray-600">{{ _("queue.subtitle") }}</p>
</div>
<!-- Loading state -->
<div id="queueLoading" class="text-center py-12">
<i class="fas fa-spinner fa-spin fa-2x text-blue-400" aria-hidden="true"></i>
<p class="mt-2 text-gray-500">Loading queue statistics&hellip;</p>
<p class="mt-2 text-gray-500">{{ _("queue.loading_stats") }}</p>
</div>
<!-- Error state -->
<div id="queueError" class="hidden bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
<p><strong>Error:</strong> <span id="queueErrorMsg"></span></p>
<p><strong>{{ _("common.error") }}:</strong> <span id="queueErrorMsg"></span></p>
</div>
<!-- Dashboard content (hidden until loaded) -->
@@ -39,7 +39,7 @@
<i class="fas fa-inbox text-yellow-600 text-xl" aria-hidden="true"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Queued Tasks</p>
<p class="text-sm font-medium text-gray-500">{{ _("queue.queued_tasks") }}</p>
<p id="totalQueued" class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
@@ -51,7 +51,7 @@
<i class="fas fa-cogs text-blue-600 text-xl" aria-hidden="true"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Active Tasks</p>
<p class="text-sm font-medium text-gray-500">{{ _("queue.active_tasks") }}</p>
<p id="activeTasks" class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
@@ -63,7 +63,7 @@
<i class="fas fa-file-alt text-indigo-600 text-xl" aria-hidden="true"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Files Processing</p>
<p class="text-sm font-medium text-gray-500">{{ _("queue.files_processing") }}</p>
<p id="filesProcessing" class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
@@ -75,7 +75,7 @@
<i class="fas fa-server text-green-600 text-xl" aria-hidden="true"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Workers Online</p>
<p class="text-sm font-medium text-gray-500">{{ _("queue.workers_online") }}</p>
<p id="workersOnline" class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
@@ -89,19 +89,19 @@
<div class="bg-white rounded-lg shadow">
<div class="px-5 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-list-ol mr-2 text-gray-500" aria-hidden="true"></i>Redis Queues
<i class="fas fa-list-ol mr-2 text-gray-500" aria-hidden="true"></i>{{ _("queue.redis_queues") }}
</h2>
</div>
<div class="p-5">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2">Queue</th>
<th class="pb-2 text-right">Pending</th>
<th class="pb-2">{{ _("queue.col_queue") }}</th>
<th class="pb-2 text-right">{{ _("common.pending") }}</th>
</tr>
</thead>
<tbody id="queueRows">
<tr><td colspan="2" class="py-3 text-gray-400 text-center">No data</td></tr>
<tr><td colspan="2" class="py-3 text-gray-400 text-center">{{ _("queue.no_data") }}</td></tr>
</tbody>
</table>
</div>
@@ -111,19 +111,19 @@
<div class="bg-white rounded-lg shadow">
<div class="px-5 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-database mr-2 text-gray-500" aria-hidden="true"></i>Processing Pipeline
<i class="fas fa-database mr-2 text-gray-500" aria-hidden="true"></i>{{ _("queue.processing_pipeline") }}
</h2>
</div>
<div class="p-5">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2">State</th>
<th class="pb-2 text-right">Files</th>
<th class="pb-2">{{ _("queue.col_state") }}</th>
<th class="pb-2 text-right">{{ _("queue.col_files") }}</th>
</tr>
</thead>
<tbody id="dbSummaryRows">
<tr><td colspan="2" class="py-3 text-gray-400 text-center">No data</td></tr>
<tr><td colspan="2" class="py-3 text-gray-400 text-center">{{ _("queue.no_data") }}</td></tr>
</tbody>
</table>
</div>
@@ -134,7 +134,7 @@
<div class="bg-white rounded-lg shadow mb-8">
<div class="px-5 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-play-circle mr-2 text-blue-500" aria-hidden="true"></i>Active Tasks
<i class="fas fa-play-circle mr-2 text-blue-500" aria-hidden="true"></i>{{ _("queue.active_tasks") }}
<span id="activeCount" class="ml-1 text-sm font-normal text-gray-500">(0)</span>
</h2>
</div>
@@ -142,13 +142,13 @@
<table class="w-full text-sm" id="activeTable">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2">Task</th>
<th class="pb-2">Arguments</th>
<th class="pb-2">Task ID</th>
<th class="pb-2">{{ _("queue.col_task") }}</th>
<th class="pb-2">{{ _("queue.col_arguments") }}</th>
<th class="pb-2">{{ _("queue.col_task_id") }}</th>
</tr>
</thead>
<tbody id="activeRows">
<tr><td colspan="3" class="py-3 text-gray-400 text-center">No active tasks</td></tr>
<tr><td colspan="3" class="py-3 text-gray-400 text-center">{{ _("queue.no_active_tasks") }}</td></tr>
</tbody>
</table>
</div>
@@ -158,20 +158,20 @@
<div class="bg-white rounded-lg shadow mb-8">
<div class="px-5 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-file-medical-alt mr-2 text-indigo-500" aria-hidden="true"></i>Recently Processing Files
<i class="fas fa-file-medical-alt mr-2 text-indigo-500" aria-hidden="true"></i>{{ _("queue.recently_processing") }}
</h2>
</div>
<div class="p-5 overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2">File</th>
<th class="pb-2">Current Step</th>
<th class="pb-2 text-right">Actions</th>
<th class="pb-2">{{ _("queue.col_file") }}</th>
<th class="pb-2">{{ _("queue.col_current_step") }}</th>
<th class="pb-2 text-right">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody id="recentRows">
<tr><td colspan="3" class="py-3 text-gray-400 text-center">No files currently processing</td></tr>
<tr><td colspan="3" class="py-3 text-gray-400 text-center">{{ _("queue.no_files_processing") }}</td></tr>
</tbody>
</table>
</div>
@@ -180,8 +180,8 @@
<!-- Auto-refresh info -->
<div class="text-center text-gray-400 text-xs mb-4">
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i>
Auto-refreshes every <span id="refreshInterval">10</span> seconds &mdash;
Last updated: <span id="lastUpdated"></span>
{{ _("queue.auto_refresh_1") }} <span id="refreshInterval">10</span> {{ _("queue.auto_refresh_2") }} &mdash;
{{ _("queue.last_updated") }} <span id="lastUpdated"></span>
</div>
</div>
</div>
+52 -54
View File
@@ -1,37 +1,35 @@
{% extends "base.html" %}
{% block title %}Shared Links DocuElevate{% endblock %}
{% block title %}{{ _("shared.page_title") }}{% endblock %}
{% block content %}
<div x-data="sharedLinks()" x-init="init(); loadLinks()" class="container mx-auto px-4 py-8 max-w-5xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-share-alt text-blue-500" aria-hidden="true"></i>
Shared Links
{{ _("shared.heading") }}
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
Share documents with anyone via a time-limited or view-limited link.
Recipients do not need a DocuElevate account. Links can be password-protected
and revoked at any time.
{{ _("shared.subtitle") }}
</p>
</header>
<!-- Create link section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-link-heading">
<h2 id="create-link-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Shared Link</h2>
<h2 id="create-link-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("shared.create_heading") }}</h2>
<form @submit.prevent="createLink()" class="space-y-4">
<!-- File ID -->
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div class="flex-1">
<label for="file-id-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
File ID <span class="text-red-500" aria-hidden="true">*</span>
{{ _("shared.file_id_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="file-id-input"
type="number"
x-model.number="newLink.file_id"
placeholder="e.g. 42"
placeholder="{{ _('shared.file_id_placeholder') }}"
required
min="1"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -39,20 +37,20 @@
aria-required="true"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Find the file ID on the <a href="/files" class="underline hover:text-blue-600">Files</a> page or in the document detail URL.
{{ _("shared.file_id_help_pre") }} <a href="/files" class="underline hover:text-blue-600">{{ _("shared.files_link") }}</a> {{ _("shared.file_id_help_post") }}
</p>
</div>
<!-- Label (optional) -->
<div class="flex-1">
<label for="link-label-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Label <span class="text-gray-400 font-normal">(optional)</span>
{{ _("shared.label_label") }} <span class="text-gray-400 font-normal">({{ _("shared.optional") }})</span>
</label>
<input
id="link-label-input"
type="text"
x-model="newLink.label"
placeholder="e.g. Shared with Bob"
placeholder="{{ _('shared.label_placeholder') }}"
maxlength="255"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
@@ -65,7 +63,7 @@
<!-- Expiry -->
<div class="flex-1">
<label for="expires-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expiry
{{ _("shared.expiry_label") }}
</label>
<select
id="expires-select"
@@ -73,28 +71,28 @@
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
>
<option :value="null">Never</option>
<option value="1">1 hour</option>
<option value="6">6 hours</option>
<option value="12">12 hours</option>
<option value="24">24 hours (1 day)</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="336">14 days</option>
<option value="720">30 days</option>
<option :value="null">{{ _("shared.expiry_never") }}</option>
<option value="1">{{ _("shared.expiry_1h") }}</option>
<option value="6">{{ _("shared.expiry_6h") }}</option>
<option value="12">{{ _("shared.expiry_12h") }}</option>
<option value="24">{{ _("shared.expiry_24h") }}</option>
<option value="72">{{ _("shared.expiry_3d") }}</option>
<option value="168">{{ _("shared.expiry_7d") }}</option>
<option value="336">{{ _("shared.expiry_14d") }}</option>
<option value="720">{{ _("shared.expiry_30d") }}</option>
</select>
</div>
<!-- Max views -->
<div class="flex-1">
<label for="max-views-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max downloads <span class="text-gray-400 font-normal">(optional)</span>
{{ _("shared.max_downloads_label") }} <span class="text-gray-400 font-normal">({{ _("shared.optional") }})</span>
</label>
<input
id="max-views-input"
type="number"
x-model.number="newLink.max_views"
placeholder="Unlimited"
placeholder="{{ _('shared.unlimited_placeholder') }}"
min="1"
max="10000"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -105,13 +103,13 @@
<!-- Password -->
<div class="flex-1">
<label for="link-password-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-gray-400 font-normal">(optional)</span>
{{ _("shared.password_label") }} <span class="text-gray-400 font-normal">({{ _("shared.optional") }})</span>
</label>
<input
id="link-password-input"
type="password"
x-model="newLink.password"
placeholder="Leave blank for no password"
placeholder="{{ _('shared.password_placeholder') }}"
maxlength="128"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -135,7 +133,7 @@
style="min-height:40px; min-width:44px;"
>
<i class="fas fa-share-alt mr-2" aria-hidden="true"></i>
<span x-text="creating ? 'Creating…' : 'Create Link'"></span>
<span x-text="creating ? '{{ _('shared.creating') }}' : '{{ _('shared.create_btn') }}'"></span>
</button>
</div>
</form>
@@ -146,9 +144,9 @@
<div class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Shared link created!</p>
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">{{ _("shared.link_created") }}</p>
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
Copy and send this link to the recipient.
{{ _("shared.link_created_help") }}
</p>
<div class="mt-3 flex items-center gap-2">
<code
@@ -164,10 +162,10 @@
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500
transition-colors"
style="min-height:40px; min-width:44px;"
:aria-label="copied ? 'Copied!' : 'Copy link to clipboard'"
:aria-label="copied ? '{{ _('common.copied') }}' : '{{ _('shared.copy_link_aria') }}'"
>
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
<span class="ml-1 hidden sm:inline" x-text="copied ? '{{ _('common.copied') }}' : '{{ _('common.copy') }}'"></span>
</button>
</div>
</div>
@@ -180,28 +178,28 @@
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6" aria-labelledby="links-heading">
<div class="flex items-center justify-between mb-4">
<h2 id="links-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
Your Shared Links
{{ _("shared.your_links") }}
<span
x-show="links.length > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
x-text="links.length"
aria-label="number of links"
aria-label="{{ _('shared.links_count_aria') }}"
></span>
</h2>
<button
@click="loadLinks()"
type="button"
class="text-sm text-blue-600 hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Refresh shared links list"
aria-label="{{ _('shared.refresh_aria') }}"
>
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i>Refresh
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i>{{ _("common.refresh") }}
</button>
</div>
<!-- Loading state -->
<template x-if="loading">
<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Loading
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>{{ _("common.loading") }}
</p>
</template>
@@ -209,22 +207,22 @@
<template x-if="!loading && links.length === 0">
<div class="text-center py-10 text-gray-400 dark:text-gray-500">
<i class="fas fa-share-alt text-3xl mb-3" aria-hidden="true"></i>
<p class="text-sm">No shared links yet. Create one above to get started.</p>
<p class="text-sm">{{ _("shared.no_links") }}</p>
</div>
</template>
<!-- Links list -->
<template x-if="!loading && links.length > 0">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm" aria-label="Shared links">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm" aria-label="{{ _('shared.table_aria') }}">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th scope="col" class="pb-3 pr-4">File / Label</th>
<th scope="col" class="pb-3 pr-4">Link</th>
<th scope="col" class="pb-3 pr-4">Expiry</th>
<th scope="col" class="pb-3 pr-4">Views</th>
<th scope="col" class="pb-3 pr-4">Status</th>
<th scope="col" class="pb-3">Actions</th>
<th scope="col" class="pb-3 pr-4">{{ _("shared.col_file_label") }}</th>
<th scope="col" class="pb-3 pr-4">{{ _("shared.col_link") }}</th>
<th scope="col" class="pb-3 pr-4">{{ _("shared.col_expiry") }}</th>
<th scope="col" class="pb-3 pr-4">{{ _("shared.col_views") }}</th>
<th scope="col" class="pb-3 pr-4">{{ _("common.status") }}</th>
<th scope="col" class="pb-3">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@@ -235,7 +233,7 @@
<p class="font-medium text-gray-800 dark:text-gray-200 truncate" x-text="link.original_filename || ('File #' + link.file_id)"></p>
<p x-show="link.label" class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="link.label"></p>
<p x-show="link.has_password" class="text-xs text-yellow-600 dark:text-yellow-400 mt-0.5">
<i class="fas fa-lock text-xs" aria-hidden="true"></i> Password protected
<i class="fas fa-lock text-xs" aria-hidden="true"></i> {{ _("shared.password_protected") }}
</p>
</td>
<!-- Link -->
@@ -246,8 +244,8 @@
type="button"
@click="copyUrl(link.share_url)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Copy link"
title="Copy link"
aria-label="{{ _('shared.copy_link_aria') }}"
title="{{ _('shared.copy_link_title') }}"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-copy text-xs" aria-hidden="true"></i>
@@ -257,8 +255,8 @@
target="_blank"
rel="noopener noreferrer"
class="p-1 text-gray-400 hover:text-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Open shared link"
title="Open in new tab"
aria-label="{{ _('shared.open_link_aria') }}"
title="{{ _('shared.open_in_new_tab') }}"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i>
@@ -278,22 +276,22 @@
<td class="py-3 pr-4 whitespace-nowrap">
<template x-if="!link.is_active">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
Revoked
{{ _("shared.status_revoked") }}
</span>
</template>
<template x-if="link.is_active && isExpired(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300">
Expired
{{ _("shared.status_expired") }}
</span>
</template>
<template x-if="link.is_active && isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Limit reached
{{ _("shared.status_limit_reached") }}
</span>
</template>
<template x-if="link.is_active && !isExpired(link) && !isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
Active
{{ _("shared.status_active") }}
</span>
</template>
</td>
@@ -307,9 +305,9 @@
border border-red-300 dark:border-red-700 rounded hover:bg-red-50 dark:hover:bg-red-900/20
focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
style="min-height:32px;min-width:44px;"
:aria-label="'Revoke shared link for ' + (link.original_filename || 'file')"
:aria-label="'{{ _('shared.revoke_aria_prefix') }} ' + (link.original_filename || '{{ _('shared.revoke_aria_file') }}')"
>
<i class="fas fa-ban mr-1" aria-hidden="true"></i> Revoke
<i class="fas fa-ban mr-1" aria-hidden="true"></i> {{ _("shared.revoke_btn") }}
</button>
<span x-show="!link.is_active" class="text-xs text-gray-400 dark:text-gray-500"></span>
</td>
+19 -21
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Document Similarity - DocuElevate{% endblock %}
{% block title %}{{ _("similarity.page_title") }}{% endblock %}
{% block head_extra %}
<style>
@@ -97,45 +97,43 @@
<main id="main-content" class="sim-container px-4 py-8">
<h1 class="text-2xl font-bold mb-2">
<i class="fas fa-project-diagram text-blue-500" aria-hidden="true"></i>
Document Similarity
{{ _("similarity.heading") }}
</h1>
<p class="text-gray-500 mb-6 text-sm">
Pairs of documents with high semantic similarity, ranked by score.
Embeddings are computed during document ingestion; a background task
also backfills any files that were processed before this feature was enabled.
{{ _("similarity.subtitle") }}
</p>
<!-- Embedding coverage stats -->
<div class="stats-bar">
<div class="stat-card">
<div class="stat-value">{{ total_files }}</div>
<div class="stat-label">Total Files</div>
<div class="stat-label">{{ _("similarity.stat_total_files") }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #2563eb;">{{ files_with_embedding }}</div>
<div class="stat-label">With Embedding</div>
<div class="stat-label">{{ _("similarity.stat_with_embedding") }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: {% if files_missing_embedding > 0 %}#d97706{% else %}#059669{% endif %};">
{{ files_missing_embedding }}
</div>
<div class="stat-label">Missing Embedding</div>
<div class="stat-label">{{ _("similarity.stat_missing_embedding") }}</div>
</div>
<div class="stat-card">
<div class="stat-value text-sm" style="word-break: break-all;">{{ embedding_model }}</div>
<div class="stat-label">Embedding Model</div>
<div class="stat-label">{{ _("similarity.stat_embedding_model") }}</div>
</div>
</div>
{% if files_missing_embedding > 0 %}
<div style="background: #fffff0; border: 1px solid #fefcbf; border-radius: 0.5rem; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: #975a16;">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i>
<strong>{{ files_missing_embedding }}</strong> file(s) have OCR text but no embedding yet.
The background task will compute them automatically every 5 minutes, or you can
<strong>{{ files_missing_embedding }}</strong> {{ _("similarity.files_missing_text") }}
{{ _("similarity.backfill_auto") }}
<button onclick="triggerBackfill()" id="backfill-btn"
style="background: #d97706; color: white; border: none; padding: 0.2rem 0.6rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.8rem; min-height: 44px; min-width: 44px;"
aria-label="Trigger embedding computation for all files missing embeddings">
trigger it now
aria-label="{{ _('similarity.trigger_aria') }}">
{{ _("similarity.trigger_now") }}
</button>.
</div>
{% endif %}
@@ -144,12 +142,12 @@
<div class="controls">
<form id="pairsForm" onsubmit="loadPairs(event)" class="controls-form">
<div class="flex flex-col gap-1">
<label for="pairThreshold">Min. similarity</label>
<label for="pairThreshold">{{ _("similarity.min_similarity_label") }}</label>
<input type="number" id="pairThreshold" min="0" max="1" step="0.05"
value="{{ default_threshold }}" style="min-height:44px;">
</div>
<div class="flex flex-col gap-1">
<label for="pairLimit">Per page</label>
<label for="pairLimit">{{ _("similarity.per_page_label") }}</label>
<select id="pairLimit" style="min-height:44px;">
<option value="25">25</option>
<option value="50" selected>50</option>
@@ -159,7 +157,7 @@
<button type="submit"
style="min-height:44px; background-color: #2563eb; color: white; font-weight: 700;
padding: 0 1.25rem; border: none; border-radius: 0.5rem; cursor: pointer;">
<i class="fas fa-search" aria-hidden="true"></i> Find Pairs
<i class="fas fa-search" aria-hidden="true"></i> {{ _("similarity.find_pairs_btn") }}
</button>
</form>
</div>
@@ -167,23 +165,23 @@
<!-- Results area -->
<div id="pairs-loading" style="text-align: center; padding: 2rem; color: #718096;">
<i class="fas fa-spinner fa-spin" aria-hidden="true" style="font-size: 1.5rem; margin-bottom: 0.5rem;"></i>
<p>Scanning for similar document pairs…</p>
<p>{{ _("similarity.scanning") }}</p>
</div>
<div id="pairs-content" style="display: none;" aria-live="polite"></div>
<div id="pairs-empty" style="display: none;" class="empty-state">
<i class="fas fa-check-circle text-green-400" aria-hidden="true"></i>
<p class="font-semibold text-lg text-gray-700">No similar pairs found</p>
<p class="font-semibold text-lg text-gray-700">{{ _("similarity.no_pairs_heading") }}</p>
<p class="text-sm mt-1" id="pairs-empty-detail">
No document pairs exceed the similarity threshold.
{{ _("similarity.no_pairs_detail") }}
</p>
</div>
<div id="pairs-error" style="display: none;" class="empty-state">
<i class="fas fa-exclamation-triangle text-red-400" aria-hidden="true"></i>
<p class="font-semibold text-lg text-gray-700" id="pairs-error-msg">Failed to load pairs.</p>
<p class="font-semibold text-lg text-gray-700" id="pairs-error-msg">{{ _("similarity.load_error") }}</p>
</div>
<!-- Pagination -->
<nav id="pairs-pagination" class="pagination" style="display: none;" aria-label="Similarity pairs pagination"></nav>
<nav id="pairs-pagination" class="pagination" style="display: none;" aria-label="{{ _('similarity.pagination_aria') }}"></nav>
</main>
{% endblock %}
+20 -20
View File
@@ -1,28 +1,28 @@
{% extends "base.html" %}
{% block title %}Upload Document{% endblock %}
{% block title %}{{ _("upload.page_title") }}{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-center p-4 sm:p-8">
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8">Upload Files</h1>
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8">{{ _("upload.page_title") }}</h1>
<!-- File Upload Section -->
<div class="w-full max-w-2xl mb-6 sm:mb-8">
<h2 class="text-xl font-semibold mb-4">Upload from Device</h2>
<h2 class="text-xl font-semibold mb-4">{{ _("upload.section_device") }}</h2>
<form action="/api/ui-upload" method="POST" enctype="multipart/form-data">
<div
id="dropZone"
class="border-4 border-dashed border-gray-300 rounded-lg p-6 sm:p-8 bg-white text-center w-full"
role="button"
tabindex="0"
aria-label="File upload area. Drag and drop files here, or press Enter to browse files."
aria-label="{{ _('upload.drop_zone_aria') }}"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)"
>
<p class="text-gray-500 mb-4 hidden sm:block">
Drag &amp; drop files or folders here, or click to select files.
{{ _("upload.drop_hint_desktop") }}
</p>
<p class="text-gray-500 mb-4 sm:hidden">
Tap to select files or use the camera button below.
{{ _("upload.drop_hint_mobile") }}
</p>
<input
id="fileInput"
@@ -31,7 +31,7 @@
onchange="handleFileSelect(event)"
name="files"
multiple
aria-label="Select files to upload"
aria-label="{{ _('upload.select_file') }}"
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*"
/>
<!-- Browse Files button (all devices) -->
@@ -41,11 +41,11 @@
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200 mb-4"
style="min-height:44px;"
>
<i class="fas fa-folder-open mr-2" aria-hidden="true"></i> Browse Files
<i class="fas fa-folder-open mr-2" aria-hidden="true"></i> {{ _("upload.browse_button") }}
</button>
<div class="text-sm text-gray-500 mt-2">
<p>Allowed types: PDF, Office documents (Word, Excel, PowerPoint, etc.), Images</p>
<p>Maximum size: 500MB per file &bull; Directories are uploaded recursively</p>
<p>{{ _("upload.file_types") }}</p>
<p>{{ _("upload.file_size_hint") }}</p>
</div>
</div>
@@ -55,7 +55,7 @@
class="w-full flex items-center justify-center gap-2 bg-green-500 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200 cursor-pointer"
style="min-height:44px;"
>
<i class="fas fa-camera" aria-hidden="true"></i> Take Photo / Scan Document
<i class="fas fa-camera" aria-hidden="true"></i> {{ _("upload.camera_button") }}
<input
id="cameraInput"
type="file"
@@ -63,7 +63,7 @@
onchange="handleFileSelect(event)"
accept="image/*"
capture="environment"
aria-label="Take photo or scan document"
aria-label="{{ _('upload.camera_button') }}"
/>
</label>
</div>
@@ -72,40 +72,40 @@
<!-- URL Upload Section -->
<div class="w-full max-w-2xl mb-6 sm:mb-8">
<h2 class="text-xl font-semibold mb-4">Upload from URL</h2>
<h2 class="text-xl font-semibold mb-4">{{ _("upload.section_url") }}</h2>
<div class="bg-white border border-gray-300 rounded-lg p-4 sm:p-6">
<form id="urlUploadForm" class="space-y-4">
<div>
<label for="urlInput" class="block text-sm font-medium text-gray-700 mb-2">
File URL
{{ _("upload.url_label") }}
</label>
<input
type="url"
id="urlInput"
name="url"
placeholder="https://example.com/document.pdf"
placeholder="{{ _('upload.url_placeholder') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:44px;"
required
/>
<p class="text-sm text-gray-500 mt-1">
Enter a direct link to a file (PDF, Office documents, or images)
{{ _("upload.url_description") }}
</p>
</div>
<div>
<label for="urlFilename" class="block text-sm font-medium text-gray-700 mb-2">
Filename (optional)
{{ _("upload.filename_label") }}
</label>
<input
type="text"
id="urlFilename"
name="filename"
placeholder="my-document.pdf"
placeholder="{{ _('upload.filename_placeholder') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:44px;"
/>
<p class="text-sm text-gray-500 mt-1">
Leave empty to use filename from URL
{{ _("upload.filename_description") }}
</p>
</div>
<button
@@ -113,7 +113,7 @@
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition duration-200"
style="min-height:44px;"
>
Download and Process
{{ _("upload.download_button") }}
</button>
</form>
<div id="urlStatusMessage" class="mt-4" aria-live="polite"></div>