a08b103271
Merges origin/main (v0.156.0) into the classification feature branch, properly resolving all 23 merge conflicts: - Auto-generated files (BUILD_DATE, VERSION, etc.): accept main's version - Non-classification files (SharePoint, QR auth, session mgmt, mobile): accept main's version - Classification files (api/__init__.py, models.py, migrations/env.py, conftest.py): keep classification additions alongside main's content Previously the branch was incorrectly removing files from main (SharePoint integration, QR scanner, session management). This merge properly preserves all main branch content while maintaining the classification feature additions. Migration chain validated: 038_add_classification_rules chains from 037_add_user_sessions_and_qr_challenges. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1639 lines
69 KiB
HTML
1639 lines
69 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ _("files.page_title") }}{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<script src="/static/js/common.js"></script>
|
|
<!-- PDF.js library for in-browser PDF preview -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
|
<script>
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
</script>
|
|
<script>
|
|
window.uploadConfig = {
|
|
concurrency: {{ upload_concurrency }},
|
|
queueDelayMs: {{ upload_queue_delay_ms }}
|
|
};
|
|
</script>
|
|
<script src="/static/js/upload.js"></script>
|
|
<style>
|
|
/* Drop overlay styles - covers entire page for drag-and-drop anywhere */
|
|
.drop-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background-color: rgba(59, 130, 246, 0.15);
|
|
border: 6px dashed #3b82f6;
|
|
z-index: 999;
|
|
pointer-events: none;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.drop-overlay.active {
|
|
display: flex;
|
|
animation: fadeIn 0.2s ease-in;
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
.drop-message {
|
|
background-color: white;
|
|
padding: 2rem 3rem;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
.drop-message i {
|
|
font-size: 4rem;
|
|
color: #3b82f6;
|
|
margin-bottom: 1rem;
|
|
display: block;
|
|
}
|
|
.drop-message p {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.drop-message .drop-hint {
|
|
font-size: 1rem;
|
|
color: #6b7280;
|
|
font-weight: 400;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
/* Upload progress modal */
|
|
.upload-modal {
|
|
display: none;
|
|
position: fixed;
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
width: 400px;
|
|
max-width: 90vw;
|
|
background-color: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
z-index: 1000;
|
|
overflow: hidden;
|
|
}
|
|
.upload-modal.active {
|
|
display: block;
|
|
}
|
|
.upload-modal-header {
|
|
background-color: #3b82f6;
|
|
color: white;
|
|
padding: 0.75rem 1rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.upload-modal-body {
|
|
padding: 1rem;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
.close-modal-btn {
|
|
background: none;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-size: 1.25rem;
|
|
}
|
|
.close-modal-btn:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
<style>
|
|
.file-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.file-table th,
|
|
.file-table td {
|
|
padding: 0.75rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
.file-table th {
|
|
background-color: #f7fafc;
|
|
font-weight: 600;
|
|
position: relative;
|
|
}
|
|
.file-table th.sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.file-table th.sortable:hover {
|
|
background-color: #edf2f7;
|
|
}
|
|
.file-table tbody tr:hover {
|
|
background-color: #f7fafc;
|
|
cursor: pointer;
|
|
}
|
|
.sort-indicator {
|
|
display: inline-block;
|
|
margin-left: 0.5rem;
|
|
opacity: 0.5;
|
|
}
|
|
.sort-indicator.active {
|
|
opacity: 1;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* Status badges */
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
.status-pending {
|
|
background-color: #FEF3C7;
|
|
color: #92400E;
|
|
}
|
|
.status-processing {
|
|
background-color: #DBEAFE;
|
|
color: #1E3A8A;
|
|
}
|
|
.status-completed {
|
|
background-color: #D1FAE5;
|
|
color: #065F46;
|
|
}
|
|
.status-failed {
|
|
background-color: #FEE2E2;
|
|
color: #991B1B;
|
|
}
|
|
.status-duplicate {
|
|
background-color: #E5E7EB;
|
|
color: #374151;
|
|
}
|
|
|
|
/* Action buttons */
|
|
.action-btn {
|
|
color: #3182ce;
|
|
cursor: pointer;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
background: none;
|
|
border: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
margin-right: 0.5rem;
|
|
}
|
|
.action-btn:hover {
|
|
background-color: #bee3f8;
|
|
}
|
|
.action-btn.delete {
|
|
color: #e53e3e;
|
|
}
|
|
.action-btn.delete:hover {
|
|
background-color: #fed7d7;
|
|
}
|
|
|
|
/* Filters section */
|
|
.filters-section {
|
|
background-color: #f7fafc;
|
|
padding: 1.5rem;
|
|
border-radius: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.filter-group {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
align-items: flex-end;
|
|
}
|
|
.filter-item {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
.filter-item label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
color: #2d3748;
|
|
}
|
|
.filter-item input,
|
|
.filter-item select {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
border: 1px solid #cbd5e0;
|
|
border-radius: 0.25rem;
|
|
}
|
|
.filter-item button {
|
|
padding: 0.5rem 1rem;
|
|
background-color: #3182ce;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
.filter-item button:hover {
|
|
background-color: #2c5aa0;
|
|
}
|
|
.filter-item button.clear {
|
|
background-color: #718096;
|
|
}
|
|
.filter-item button.clear:hover {
|
|
background-color: #4a5568;
|
|
}
|
|
|
|
/* Pagination */
|
|
.pagination {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 1.5rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
.pagination-info {
|
|
color: #4a5568;
|
|
}
|
|
.pagination-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
.pagination-button {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #cbd5e0;
|
|
background-color: white;
|
|
border-radius: 0.25rem;
|
|
cursor: pointer;
|
|
color: #2d3748;
|
|
}
|
|
.pagination-button:hover:not(:disabled) {
|
|
background-color: #edf2f7;
|
|
}
|
|
.pagination-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.pagination-button.active {
|
|
background-color: #3182ce;
|
|
color: white;
|
|
border-color: #3182ce;
|
|
}
|
|
|
|
/* Modal styles */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.modal-content {
|
|
background-color: white;
|
|
border-radius: 0.5rem;
|
|
padding: 2rem;
|
|
max-width: 800px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.modal-title {
|
|
font-size: 1.25rem;
|
|
font-weight: bold;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.modal-buttons {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 1.5rem;
|
|
gap: 0.75rem;
|
|
}
|
|
.modal-btn {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.25rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
.modal-btn-cancel {
|
|
background-color: #e2e8f0;
|
|
}
|
|
.modal-btn-cancel:hover {
|
|
background-color: #cbd5e0;
|
|
}
|
|
.modal-btn-delete {
|
|
background-color: #e53e3e;
|
|
color: white;
|
|
}
|
|
.modal-btn-delete:hover {
|
|
background-color: #c53030;
|
|
}
|
|
|
|
.error-message {
|
|
background-color: #FEE2E2;
|
|
border: 1px solid #F87171;
|
|
color: #B91C1C;
|
|
padding: 1rem;
|
|
border-radius: 0.25rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
/* ── Preview modal (side panel) ── */
|
|
.preview-overlay {
|
|
display: none; position: fixed; inset: 0; z-index: 1000;
|
|
background: rgba(0,0,0,0.5); justify-content: flex-end;
|
|
}
|
|
.preview-overlay.active { display: flex; }
|
|
.preview-panel {
|
|
width: 55%; max-width: 720px; min-width: 340px; height: 100%;
|
|
background: white; box-shadow: -4px 0 20px rgba(0,0,0,0.15);
|
|
display: flex; flex-direction: column; animation: slideIn 0.2s ease;
|
|
}
|
|
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
@media (max-width: 768px) { .preview-panel { width: 100%; max-width: 100%; } }
|
|
.preview-panel-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; flex-shrink: 0;
|
|
}
|
|
.preview-panel-header h3 { font-size: 1rem; font-weight: 600; color: #1f2937; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.preview-panel-close {
|
|
background: none; border: none; cursor: pointer; padding: 0.5rem;
|
|
color: #6b7280; font-size: 1.25rem; min-height: 44px; min-width: 44px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.preview-panel-close:hover { color: #1f2937; }
|
|
.preview-panel-body { flex: 1; overflow: auto; padding: 0; background: #f9fafb; }
|
|
.preview-panel-footer {
|
|
display: flex; align-items: center; justify-content: center; gap: 0.75rem;
|
|
padding: 0.75rem 1rem; border-top: 1px solid #e5e7eb; flex-shrink: 0;
|
|
}
|
|
.preview-panel-footer a, .preview-panel-footer button {
|
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
|
padding: 0.4rem 0.9rem; border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
|
|
text-decoration: none; cursor: pointer; border: 1px solid #d1d5db; background: white; color: #374151;
|
|
min-height: 40px;
|
|
}
|
|
.preview-panel-footer a:hover, .preview-panel-footer button:hover { background: #f3f4f6; }
|
|
/* PDF controls inside preview modal */
|
|
.pm-pdf-controls {
|
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
padding: 0.5rem; background: #f1f5f9; border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
.pm-pdf-controls button {
|
|
padding: 0.3rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
|
background: white; cursor: pointer; font-size: 0.8rem; min-height: 36px; min-width: 36px;
|
|
}
|
|
.pm-pdf-controls button:disabled { opacity: 0.4; cursor: default; }
|
|
.pm-canvas-wrap { display: flex; justify-content: center; padding: 0.5rem; min-height: 300px; overflow: auto; }
|
|
.pm-canvas-wrap canvas { max-width: 100%; height: auto; }
|
|
/* Image in preview modal */
|
|
.pm-img-controls {
|
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
padding: 0.5rem; background: #f1f5f9; border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
.pm-img-controls button {
|
|
padding: 0.3rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
|
background: white; cursor: pointer; font-size: 0.85rem; min-height: 36px; min-width: 36px;
|
|
}
|
|
.pm-img-wrap {
|
|
display: flex; justify-content: center; align-items: center;
|
|
overflow: auto; cursor: grab; flex: 1; padding: 0.5rem; background: #525659;
|
|
}
|
|
.pm-img-wrap.dragging { cursor: grabbing; }
|
|
.pm-img-wrap img { transition: transform 0.15s ease; max-width: 100%; height: auto; }
|
|
/* Text in preview modal */
|
|
.pm-text-content {
|
|
background: #1e293b; color: #e2e8f0; padding: 1rem; font-family: 'SFMono-Regular', Consolas, monospace;
|
|
font-size: 0.82rem; line-height: 1.65; min-height: 300px; white-space: pre-wrap; word-break: break-word;
|
|
tab-size: 4;
|
|
}
|
|
.pm-line-number {
|
|
display: inline-block; width: 3.5em; color: #64748b; text-align: right;
|
|
margin-right: 1em; user-select: none; font-size: 0.75rem;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Drop overlay -->
|
|
<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>{{ _("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="{{ _('files.upload_modal_aria') }}">
|
|
<div class="upload-modal-header">
|
|
<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>
|
|
<div class="upload-modal-body">
|
|
<div id="uploadStatusMessage" class="text-sm text-gray-700 mb-2" aria-live="polite"></div>
|
|
<div id="uploadProgressContainer" class="space-y-2" role="status" aria-live="polite"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container mx-auto px-4 py-8">
|
|
<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">
|
|
<div class="flex items-center justify-between">
|
|
<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> {{ _("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>{{ _("files.queue_banner_link") }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{% if error %}
|
|
<div class="error-message">
|
|
<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="{{ _('files.filter_form_aria') }}">
|
|
<div class="filter-item">
|
|
<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">{{ _("files.filter_mime_type") }}</label>
|
|
<select id="mime_type" name="mime_type">
|
|
<option value="">{{ _("files.filter_all_types") }}</option>
|
|
{% for mt in mime_types %}
|
|
<option value="{{ mt }}" {% if mt == mime_type %}selected{% endif %}>{{ mt }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<label for="status">{{ _("common.status") }}</label>
|
|
<select id="status" name="status">
|
|
<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">{{ _("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">{{ _("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">{{ _("files.filter_storage_provider") }}</label>
|
|
<select id="storage_provider" name="storage_provider">
|
|
<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>
|
|
<option value="s3" {% if storage_provider == "s3" %}selected{% endif %}>S3</option>
|
|
<option value="nextcloud" {% if storage_provider == "nextcloud" %}selected{% endif %}>Nextcloud</option>
|
|
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
|
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
|
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
|
<option value="sharepoint" {% if storage_provider == "sharepoint" %}selected{% endif %}>SharePoint</option>
|
|
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<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">{{ _("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 < {{ 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 ≥ {{ 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> </label>
|
|
<button type="submit">{{ _("files.filter_apply") }}</button>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<label> </label>
|
|
<button type="button" class="clear" onclick="clearFilters()">{{ _("files.filter_clear") }}</button>
|
|
</div>
|
|
|
|
<!-- Hidden fields to preserve sort order -->
|
|
<input type="hidden" name="sort_by" value="{{ sort_by }}">
|
|
<input type="hidden" name="sort_order" value="{{ sort_order }}">
|
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Saved Searches Section -->
|
|
<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> {{ _("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;">{{ _("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="{{ _('files.saved_searches_save_aria') }}">
|
|
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("files.saved_searches_save") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full-Text Search Section -->
|
|
<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> {{ _("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="{{ _('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)"
|
|
>
|
|
<button
|
|
type="button"
|
|
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;"
|
|
>
|
|
{{ _("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;"
|
|
>
|
|
{{ _("files.filter_clear") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full-Text Search Results Panel -->
|
|
<div id="search-results-panel" style="display: none; margin-top: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; background: #f9fafb; border-radius: 0.5rem 0.5rem 0 0;">
|
|
<span id="search-results-summary" style="font-size: 0.875rem; color: #374151;"></span>
|
|
<div id="search-results-pagination" style="display: flex; gap: 0.5rem;"></div>
|
|
</div>
|
|
<div id="search-results-list" style="padding: 0.5rem 0;"></div>
|
|
</div>
|
|
|
|
<!-- Bulk Actions Section -->
|
|
<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.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> {{ _("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> {{ _("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> {{ _("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> {{ _("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;">
|
|
{{ _("files.bulk_clear_selection") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File table (desktop) -->
|
|
<div class="hidden md:block overflow-x-auto">
|
|
<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="{{ _('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 %}">
|
|
{{ _("files.table_id") }}
|
|
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
|
|
{% if sort_by == 'id' %}
|
|
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
|
{% else %}
|
|
↕
|
|
{% endif %}
|
|
</span>
|
|
</th>
|
|
<th scope="col" class="sortable" onclick="sortTable('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 %}
|
|
{% else %}
|
|
↕
|
|
{% endif %}
|
|
</span>
|
|
</th>
|
|
<th scope="col" class="sortable" onclick="sortTable('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 %}
|
|
{% else %}
|
|
↕
|
|
{% endif %}
|
|
</span>
|
|
</th>
|
|
<th scope="col" class="sortable" onclick="sortTable('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 %}
|
|
{% else %}
|
|
↕
|
|
{% endif %}
|
|
</span>
|
|
</th>
|
|
<th scope="col">{{ _("common.status") }}</th>
|
|
<th scope="col" class="sortable" onclick="sortTable('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 %}
|
|
{% else %}
|
|
↕
|
|
{% endif %}
|
|
</span>
|
|
</th>
|
|
<th scope="col">{{ _("files.table_actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for file in files %}
|
|
<tr onclick="viewFileDetail({{ file.id }}, event)">
|
|
<td onclick="event.stopPropagation();">
|
|
<input type="checkbox" class="file-checkbox" value="{{ file.id }}" onchange="updateBulkActionsBar()">
|
|
</td>
|
|
<td>{{ file.id }}</td>
|
|
<td>{{ file.original_filename }}</td>
|
|
<td>{{ (file.file_size / 1024) | round(2) }} KB</td>
|
|
<td>{{ file.mime_type }}</td>
|
|
<td>
|
|
<span class="status-badge status-{{ file.processing_status }}">
|
|
{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}
|
|
</span>
|
|
</td>
|
|
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else _('common.not_available') }}</td>
|
|
<td>
|
|
<div style="display: flex; align-items: center;">
|
|
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
|
</button>
|
|
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
|
</button>
|
|
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4">{{ _("files.table_empty") }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile card view (visible on small screens only) -->
|
|
<div class="md:hidden space-y-3" id="mobileCardView">
|
|
{% for file in files %}
|
|
<div
|
|
class="bg-white rounded-lg shadow p-4 cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick="viewFileDetail({{ file.id }}, event)"
|
|
onkeydown="if(event.key==='Enter'||event.key===' '){viewFileDetail({{ file.id }}, event);}"
|
|
aria-label="{{ _('files.action_details') }} {{ file.original_filename }}"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-semibold text-gray-800 truncate">{{ file.original_filename }}</p>
|
|
<p class="text-xs text-gray-500 mt-1">ID: {{ file.id }} · {{ (file.file_size / 1024) | round(2) }} KB</p>
|
|
<p class="text-xs text-gray-500 mt-0.5">{{ file.mime_type }}</p>
|
|
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else _('common.not_available') }}</p>
|
|
</div>
|
|
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
|
<span class="status-badge status-{{ file.processing_status }}">{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}</span>
|
|
<div class="flex gap-1" role="group" aria-label="{{ _('files.file_actions_aria') }}" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
|
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
|
</button>
|
|
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
|
</button>
|
|
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<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="{{ _('files.pagination_nav_aria') }}">
|
|
<div class="pagination-info" aria-live="polite">
|
|
{{ _("files.pagination_showing") }} {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
|
|
{{ min(pagination.page * pagination.per_page, pagination.total) }}
|
|
{{ _("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="{{ _('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="{{ _('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="{{ _('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>
|
|
{% endif %}
|
|
|
|
<!-- 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">{{ _("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;">{{ _("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>
|
|
|
|
<!-- Preview side-panel modal -->
|
|
<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">{{ _("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>
|
|
<div class="preview-panel-body" id="previewBody" aria-live="polite">
|
|
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#9ca3af;">
|
|
<i class="fas fa-spinner fa-spin" aria-hidden="true" style="font-size:2rem;"></i>
|
|
</div>
|
|
</div>
|
|
<div class="preview-panel-footer" id="previewFooter"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── Translations for JavaScript ──
|
|
const __i18n = {
|
|
previewFallback: {{ _("files.js_preview_fallback") | tojson }},
|
|
fullView: {{ _("files.js_full_view") | tojson }},
|
|
download: {{ _("files.js_download") | tojson }},
|
|
loading: {{ _("files.js_loading") | tojson }},
|
|
failedLoadPdf: {{ _("files.js_failed_load_pdf") | tojson }},
|
|
pdfPageInfo: {{ _("files.js_pdf_page_info") | tojson }},
|
|
failedLoadFile: {{ _("files.js_failed_load_file") | tojson }},
|
|
openFullViewAria: {{ _("files.js_open_full_view_aria") | tojson }},
|
|
downloadFileAria: {{ _("files.js_download_file_aria") | tojson }},
|
|
failedDeleteFile: {{ _("files.js_failed_delete_file") | tojson }},
|
|
errorDeletingFile: {{ _("files.js_error_deleting_file") | tojson }},
|
|
noSavedSearches: {{ _("files.js_no_saved_searches") | tojson }},
|
|
loadSavedSearchesError: {{ _("files.js_load_saved_searches_error") | tojson }},
|
|
noFiltersToSave: {{ _("files.js_no_filters_to_save") | tojson }},
|
|
enterSearchName: {{ _("files.js_enter_search_name") | tojson }},
|
|
deleteSavedSearchConfirm: {{ _("files.js_delete_saved_search_confirm") | tojson }},
|
|
deleteSavedSearchAria: {{ _("files.js_delete_saved_search_aria") | tojson }},
|
|
selectFilesDelete: {{ _("files.js_select_files_delete") | tojson }},
|
|
confirmDeleteFiles: {{ _("files.js_confirm_delete_files") | tojson }},
|
|
failedDeleteFiles: {{ _("files.js_failed_delete_files") | tojson }},
|
|
errorDeletingFiles: {{ _("files.js_error_deleting_files") | tojson }},
|
|
selectFilesReprocess: {{ _("files.js_select_files_reprocess") | tojson }},
|
|
confirmReprocessFiles: {{ _("files.js_confirm_reprocess_files") | tojson }},
|
|
failedReprocessFiles: {{ _("files.js_failed_reprocess_files") | tojson }},
|
|
errorReprocessingFiles: {{ _("files.js_error_reprocessing_files") | tojson }},
|
|
selectFilesCloudOcr: {{ _("files.js_select_files_cloud_ocr") | tojson }},
|
|
confirmCloudOcr: {{ _("files.js_confirm_cloud_ocr") | tojson }},
|
|
failedQueueCloudOcr: {{ _("files.js_failed_queue_cloud_ocr") | tojson }},
|
|
errorQueuingCloudOcr: {{ _("files.js_error_queuing_cloud_ocr") | tojson }},
|
|
selectFilesDownload: {{ _("files.js_select_files_download") | tojson }},
|
|
failedCreateZip: {{ _("files.js_failed_create_zip") | tojson }},
|
|
errorDownloadingFiles: {{ _("files.js_error_downloading_files") | tojson }},
|
|
searching: {{ _("files.js_searching") | tojson }},
|
|
searchUnavailable: {{ _("files.js_search_unavailable") | tojson }},
|
|
searchResultsCount: {{ _("files.js_search_results_count") | tojson }},
|
|
noResults: {{ _("files.js_no_results") | tojson }},
|
|
untitled: {{ _("files.js_untitled") | tojson }},
|
|
viewFile: {{ _("files.js_view_file") | tojson }},
|
|
prevPage: {{ _("files.js_prev_page") | tojson }},
|
|
pageInfo: {{ _("files.js_page_info") | tojson }},
|
|
nextPage: {{ _("files.js_next_page") | tojson }},
|
|
zoomIn: {{ _("files.js_zoom_in") | tojson }},
|
|
zoomOut: {{ _("files.js_zoom_out") | tojson }},
|
|
resetZoom: {{ _("files.js_reset_zoom") | tojson }},
|
|
};
|
|
|
|
// Modal functionality
|
|
const deleteModal = document.getElementById('deleteModal');
|
|
const cancelDelete = document.getElementById('cancelDelete');
|
|
const confirmDelete = document.getElementById('confirmDelete');
|
|
let currentFileId = null;
|
|
|
|
function showDeleteModal(fileId, event) {
|
|
if (event) event.stopPropagation();
|
|
currentFileId = fileId;
|
|
deleteModal.style.display = 'flex';
|
|
}
|
|
|
|
function viewFileDetail(fileId, event) {
|
|
if (event) event.stopPropagation();
|
|
window.location.href = `/files/${fileId}/detail`;
|
|
}
|
|
|
|
// ── Preview modal ──
|
|
let _pmPdf = { doc: null, page: 1, total: 0 };
|
|
let _pmImgScale = 1;
|
|
|
|
function openPreviewModal(fileId, mime, filename, event) {
|
|
if (event) event.stopPropagation();
|
|
const overlay = document.getElementById('previewOverlay');
|
|
const title = document.getElementById('previewTitle');
|
|
const body = document.getElementById('previewBody');
|
|
const footer = document.getElementById('previewFooter');
|
|
title.textContent = filename || __i18n.previewFallback;
|
|
body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#9ca3af;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div>';
|
|
footer.innerHTML = '';
|
|
_pmPdf = { doc: null, page: 1, total: 0 };
|
|
_pmImgScale = 1;
|
|
overlay.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
footer.innerHTML =
|
|
`<a href="/files/${fileId}" aria-label="${__i18n.openFullViewAria}"><i class="fas fa-expand" aria-hidden="true"></i> ${__i18n.fullView}</a>` +
|
|
`<a href="/api/files/${fileId}/download?version=processed" aria-label="${__i18n.downloadFileAria}"><i class="fas fa-download" aria-hidden="true"></i> ${__i18n.download}</a>`;
|
|
|
|
// Try "processed" first; fall back to "original" on 404
|
|
_pmLoadPreview(body, fileId, mime, filename, 'processed');
|
|
}
|
|
|
|
function _pmLoadPreview(body, fileId, mime, filename, version) {
|
|
const previewUrl = `/api/files/${fileId}/preview?version=${version}`;
|
|
if (mime && mime.startsWith('image/')) {
|
|
_pmRenderImage(body, previewUrl, filename);
|
|
} else if (mime && mime.startsWith('text/')) {
|
|
_pmRenderText(body, previewUrl, function() {
|
|
if (version === 'processed') _pmLoadPreview(body, fileId, mime, filename, 'original');
|
|
});
|
|
} else {
|
|
_pmRenderPdf(body, previewUrl, function() {
|
|
if (version === 'processed') _pmLoadPreview(body, fileId, mime, filename, 'original');
|
|
});
|
|
}
|
|
}
|
|
|
|
function closePreviewModal() {
|
|
const overlay = document.getElementById('previewOverlay');
|
|
overlay.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
_pmPdf = { doc: null, page: 1, total: 0 };
|
|
}
|
|
|
|
// PDF rendering inside preview modal
|
|
function _pmRenderPdf(body, url, onError) {
|
|
body.innerHTML =
|
|
'<div class="pm-pdf-controls">' +
|
|
'<button onclick="_pmPdfPage(-1)" id="pm-prev" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>' +
|
|
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">' + __i18n.loading + '</span>' +
|
|
'<button onclick="_pmPdfPage(1)" id="pm-next" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>' +
|
|
'</div>' +
|
|
'<div class="pm-canvas-wrap" id="pm-canvas-wrap"><div style="text-align:center;padding:3rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div></div>';
|
|
|
|
pdfjsLib.getDocument(url).promise.then(function(pdf) {
|
|
_pmPdf.doc = pdf;
|
|
_pmPdf.total = pdf.numPages;
|
|
_pmPdf.page = 1;
|
|
_pmPdfRender();
|
|
}).catch(function() {
|
|
if (onError) { onError(); return; }
|
|
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>' + __i18n.failedLoadPdf + '</p></div>';
|
|
});
|
|
}
|
|
|
|
function _pmPdfRender() {
|
|
var wrap = document.getElementById('pm-canvas-wrap');
|
|
if (!_pmPdf.doc || !wrap) return;
|
|
_pmPdf.doc.getPage(_pmPdf.page).then(function(page) {
|
|
var vp = page.getViewport({ scale: 1.0 });
|
|
var scale = Math.min((wrap.clientWidth - 16) / vp.width, 2.0);
|
|
var svp = page.getViewport({ scale: scale });
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = svp.width;
|
|
canvas.height = svp.height;
|
|
canvas.style.maxWidth = '100%';
|
|
canvas.style.height = 'auto';
|
|
wrap.innerHTML = '';
|
|
wrap.appendChild(canvas);
|
|
page.render({ canvasContext: canvas.getContext('2d'), viewport: svp });
|
|
_pmPdfUpdateInfo();
|
|
});
|
|
}
|
|
|
|
function _pmPdfPage(delta) {
|
|
var np = _pmPdf.page + delta;
|
|
if (np >= 1 && np <= _pmPdf.total) { _pmPdf.page = np; _pmPdfRender(); }
|
|
}
|
|
|
|
function _pmPdfUpdateInfo() {
|
|
var info = document.getElementById('pm-page-info');
|
|
if (info) info.textContent = __i18n.pdfPageInfo.replace('{page}', _pmPdf.page).replace('{total}', _pmPdf.total);
|
|
var prev = document.getElementById('pm-prev');
|
|
var next = document.getElementById('pm-next');
|
|
if (prev) prev.disabled = _pmPdf.page <= 1;
|
|
if (next) next.disabled = _pmPdf.page >= _pmPdf.total;
|
|
}
|
|
|
|
// Image rendering inside preview modal
|
|
function _pmRenderImage(body, url, alt) {
|
|
body.innerHTML =
|
|
'<div class="pm-img-controls">' +
|
|
'<button onclick="_pmImgZoom(1.25)" title="' + __i18n.zoomIn + '" aria-label="' + __i18n.zoomIn + '"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
|
'<button onclick="_pmImgZoom(0.8)" title="' + __i18n.zoomOut + '" aria-label="' + __i18n.zoomOut + '"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
|
'<button onclick="_pmImgReset()" title="' + __i18n.resetZoom + '" aria-label="' + __i18n.resetZoom + '"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
|
'<span id="pm-zoom-level" style="font-size:0.8rem;color:#6b7280;">100%</span>' +
|
|
'</div>' +
|
|
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || __i18n.previewFallback) + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
|
// Drag-pan
|
|
var wrap = document.getElementById('pm-img-wrap');
|
|
if (wrap) {
|
|
var dragging = false, sX, sY, sL, sT;
|
|
wrap.addEventListener('mousedown', function(e){ dragging=true; wrap.classList.add('dragging'); sX=e.pageX-wrap.offsetLeft; sY=e.pageY-wrap.offsetTop; sL=wrap.scrollLeft; sT=wrap.scrollTop; });
|
|
wrap.addEventListener('mousemove', function(e){ if(!dragging) return; e.preventDefault(); wrap.scrollLeft=sL-(e.pageX-wrap.offsetLeft-sX); wrap.scrollTop=sT-(e.pageY-wrap.offsetTop-sY); });
|
|
wrap.addEventListener('mouseup', function(){ dragging=false; wrap.classList.remove('dragging'); });
|
|
wrap.addEventListener('mouseleave', function(){ dragging=false; wrap.classList.remove('dragging'); });
|
|
wrap.addEventListener('wheel', function(e){ e.preventDefault(); _pmImgZoom(e.deltaY<0?1.1:0.9); }, {passive:false});
|
|
}
|
|
}
|
|
|
|
function _pmImgZoom(factor) {
|
|
_pmImgScale = Math.max(0.25, Math.min(5, _pmImgScale * factor));
|
|
var img = document.getElementById('pm-preview-img');
|
|
if (img) img.style.transform = 'scale(' + _pmImgScale + ')';
|
|
var lbl = document.getElementById('pm-zoom-level');
|
|
if (lbl) lbl.textContent = Math.round(_pmImgScale * 100) + '%';
|
|
}
|
|
|
|
function _pmImgReset() {
|
|
_pmImgScale = 1;
|
|
var img = document.getElementById('pm-preview-img');
|
|
if (img) img.style.transform = 'scale(1)';
|
|
var lbl = document.getElementById('pm-zoom-level');
|
|
if (lbl) lbl.textContent = '100%';
|
|
}
|
|
|
|
// Text rendering inside preview modal
|
|
function _pmRenderText(body, url, onError) {
|
|
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.loading + '</div></div>';
|
|
fetch(url)
|
|
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
|
.then(function(text) {
|
|
var container = document.getElementById('pm-text-content');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
var lines = text.split('\n');
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var div = document.createElement('div');
|
|
var num = document.createElement('span');
|
|
num.className = 'pm-line-number';
|
|
num.textContent = (i + 1) + '';
|
|
div.appendChild(num);
|
|
div.appendChild(document.createTextNode(lines[i]));
|
|
container.appendChild(div);
|
|
}
|
|
})
|
|
.catch(function() {
|
|
if (onError) { onError(); return; }
|
|
var c = document.getElementById('pm-text-content');
|
|
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">' + __i18n.failedLoadFile + '</div>';
|
|
});
|
|
}
|
|
|
|
// Close preview on overlay click or Escape
|
|
document.getElementById('previewOverlay').addEventListener('click', function(e) {
|
|
if (e.target === this) closePreviewModal();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
var overlay = document.getElementById('previewOverlay');
|
|
if (overlay && overlay.classList.contains('active')) closePreviewModal();
|
|
}
|
|
});
|
|
|
|
cancelDelete.addEventListener('click', () => {
|
|
deleteModal.style.display = 'none';
|
|
});
|
|
|
|
confirmDelete.addEventListener('click', () => {
|
|
deleteFile(currentFileId);
|
|
deleteModal.style.display = 'none';
|
|
});
|
|
|
|
// Close modal if clicking outside of it
|
|
window.addEventListener('click', (event) => {
|
|
if (event.target === deleteModal) {
|
|
deleteModal.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
function deleteFile(fileId) {
|
|
fetch(`/api/files/${fileId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
// Try to parse JSON error response, but handle non-JSON gracefully
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('application/json')) {
|
|
return response.json().then(err => {
|
|
throw new Error(err.detail || __i18n.failedDeleteFile);
|
|
});
|
|
} else {
|
|
// Non-JSON response (likely HTML error page)
|
|
return response.text().then(text => {
|
|
// Extract a readable error message
|
|
const statusText = response.statusText || 'Error';
|
|
throw new Error(`${statusText} (${response.status}): Server returned non-JSON response`);
|
|
});
|
|
}
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
// Reload the page to show updated file list
|
|
window.location.reload();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert(__i18n.errorDeletingFile.replace('{error}', error.message));
|
|
});
|
|
}
|
|
|
|
function sortTable(column) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const currentSortBy = urlParams.get('sort_by') || 'created_at';
|
|
const currentSortOrder = urlParams.get('sort_order') || 'desc';
|
|
|
|
// Toggle sort order if clicking the same column
|
|
let newSortOrder = 'asc';
|
|
if (column === currentSortBy) {
|
|
newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
|
|
}
|
|
|
|
urlParams.set('sort_by', column);
|
|
urlParams.set('sort_order', newSortOrder);
|
|
urlParams.set('page', '1'); // Reset to first page on sort
|
|
|
|
window.location.search = urlParams.toString();
|
|
}
|
|
|
|
function goToPage(page) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
urlParams.set('page', page);
|
|
window.location.search = urlParams.toString();
|
|
}
|
|
|
|
function clearFilters() {
|
|
window.location.href = '/files';
|
|
}
|
|
|
|
// Saved searches functionality
|
|
function loadSavedSearches() {
|
|
fetch('/api/saved-searches')
|
|
.then(response => response.json())
|
|
.then(searches => {
|
|
const container = document.getElementById('saved-searches-list');
|
|
if (!container) return;
|
|
if (!searches || searches.length === 0) {
|
|
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.noSavedSearches + '</span>';
|
|
return;
|
|
}
|
|
container.innerHTML = searches.map(s => {
|
|
const params = new URLSearchParams(s.filters);
|
|
return `<span class="saved-search-tag" style="display: inline-flex; align-items: center; gap: 0.25rem; background: var(--bg-tertiary, #e5e7eb); padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem;">
|
|
<a href="/files?${params.toString()}" style="text-decoration: none; color: inherit;">${s.name}</a>
|
|
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="${__i18n.deleteSavedSearchAria.replace('{name}', s.name)}">
|
|
<i class="fas fa-times" aria-hidden="true"></i>
|
|
</button>
|
|
</span>`;
|
|
}).join('');
|
|
})
|
|
.catch(() => {
|
|
const container = document.getElementById('saved-searches-list');
|
|
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.loadSavedSearchesError + '</span>';
|
|
});
|
|
}
|
|
|
|
function saveCurrentFilters() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const filters = {};
|
|
const filterKeys = ['search', 'mime_type', 'status', 'date_from', 'date_to', 'storage_provider', 'tags', 'sort_by', 'sort_order'];
|
|
filterKeys.forEach(key => {
|
|
const val = urlParams.get(key);
|
|
if (val) filters[key] = val;
|
|
});
|
|
if (Object.keys(filters).length === 0) {
|
|
alert(__i18n.noFiltersToSave);
|
|
return;
|
|
}
|
|
const name = prompt(__i18n.enterSearchName);
|
|
if (!name || !name.trim()) return;
|
|
fetch('/api/saved-searches', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name.trim(), filters: filters })
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) return response.json().then(d => { throw new Error(d.detail || 'Failed to save'); });
|
|
return response.json();
|
|
})
|
|
.then(() => loadSavedSearches())
|
|
.catch(error => alert(error.message));
|
|
}
|
|
|
|
function deleteSavedSearch(id) {
|
|
if (!confirm(__i18n.deleteSavedSearchConfirm)) return;
|
|
fetch(`/api/saved-searches/${id}`, { method: 'DELETE' })
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Failed to delete');
|
|
loadSavedSearches();
|
|
})
|
|
.catch(error => alert(error.message));
|
|
}
|
|
|
|
// Load saved searches on page load
|
|
document.addEventListener('DOMContentLoaded', loadSavedSearches);
|
|
|
|
// Bulk selection functionality
|
|
function toggleSelectAll() {
|
|
const selectAll = document.getElementById('selectAll');
|
|
const checkboxes = document.querySelectorAll('.file-checkbox');
|
|
checkboxes.forEach(checkbox => {
|
|
checkbox.checked = selectAll.checked;
|
|
});
|
|
updateBulkActionsBar();
|
|
}
|
|
|
|
function updateBulkActionsBar() {
|
|
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
|
|
const selectedCount = checkboxes.length;
|
|
const bulkActionsBar = document.getElementById('bulkActionsBar');
|
|
const selectedCountEl = document.getElementById('selectedCount');
|
|
|
|
if (selectedCount > 0) {
|
|
bulkActionsBar.style.display = 'block';
|
|
selectedCountEl.textContent = selectedCount;
|
|
} else {
|
|
bulkActionsBar.style.display = 'none';
|
|
}
|
|
|
|
// Update "select all" checkbox state
|
|
const allCheckboxes = document.querySelectorAll('.file-checkbox');
|
|
const selectAll = document.getElementById('selectAll');
|
|
selectAll.checked = allCheckboxes.length > 0 && selectedCount === allCheckboxes.length;
|
|
}
|
|
|
|
function clearSelection() {
|
|
document.querySelectorAll('.file-checkbox').forEach(cb => cb.checked = false);
|
|
document.getElementById('selectAll').checked = false;
|
|
updateBulkActionsBar();
|
|
}
|
|
|
|
function getSelectedFileIds() {
|
|
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
|
|
return Array.from(checkboxes).map(cb => parseInt(cb.value));
|
|
}
|
|
|
|
function bulkDelete() {
|
|
const fileIds = getSelectedFileIds();
|
|
if (fileIds.length === 0) {
|
|
alert(__i18n.selectFilesDelete);
|
|
return;
|
|
}
|
|
|
|
if (!confirm(__i18n.confirmDeleteFiles.replace('{count}', fileIds.length))) {
|
|
return;
|
|
}
|
|
|
|
fetch('/api/files/bulk-delete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(fileIds)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.json().then(err => {
|
|
throw new Error(err.detail || __i18n.failedDeleteFiles);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
alert(data.message);
|
|
window.location.reload();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert(__i18n.errorDeletingFiles.replace('{error}', error.message));
|
|
});
|
|
}
|
|
|
|
function bulkReprocess() {
|
|
const fileIds = getSelectedFileIds();
|
|
if (fileIds.length === 0) {
|
|
alert(__i18n.selectFilesReprocess);
|
|
return;
|
|
}
|
|
|
|
if (!confirm(__i18n.confirmReprocessFiles.replace('{count}', fileIds.length))) {
|
|
return;
|
|
}
|
|
|
|
fetch('/api/files/bulk-reprocess', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(fileIds)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.json().then(err => {
|
|
throw new Error(err.detail || __i18n.failedReprocessFiles);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.errors && data.errors.length > 0) {
|
|
const errorMsg = data.errors.map(e => `${e.filename}: ${e.error}`).join('\n');
|
|
alert(`${data.message}\n\nErrors:\n${errorMsg}`);
|
|
} else {
|
|
alert(data.message);
|
|
}
|
|
clearSelection();
|
|
window.location.reload();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert(__i18n.errorReprocessingFiles.replace('{error}', error.message));
|
|
});
|
|
}
|
|
|
|
function bulkReprocessCloudOcr() {
|
|
const fileIds = getSelectedFileIds();
|
|
if (fileIds.length === 0) {
|
|
alert(__i18n.selectFilesCloudOcr);
|
|
return;
|
|
}
|
|
|
|
if (!confirm(__i18n.confirmCloudOcr.replace('{count}', fileIds.length))) {
|
|
return;
|
|
}
|
|
|
|
fetch('/api/files/bulk-reprocess-cloud-ocr', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(fileIds)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.json().then(err => {
|
|
throw new Error(err.detail || __i18n.failedQueueCloudOcr);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.errors && data.errors.length > 0) {
|
|
const errorMsg = data.errors.map(e => `${e.filename}: ${e.error}`).join('\n');
|
|
alert(`${data.message}\n\nErrors:\n${errorMsg}`);
|
|
} else {
|
|
alert(data.message);
|
|
}
|
|
clearSelection();
|
|
window.location.reload();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert(__i18n.errorQueuingCloudOcr.replace('{error}', error.message));
|
|
});
|
|
}
|
|
|
|
function bulkDownload() {
|
|
const fileIds = getSelectedFileIds();
|
|
if (fileIds.length === 0) {
|
|
alert(__i18n.selectFilesDownload);
|
|
return;
|
|
}
|
|
|
|
fetch('/api/files/bulk-download', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(fileIds)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.json().then(err => {
|
|
throw new Error(err.detail || __i18n.failedCreateZip);
|
|
});
|
|
}
|
|
return response.blob();
|
|
})
|
|
.then(blob => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `docuelevate_bulk_${Date.now()}.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert(__i18n.errorDownloadingFiles.replace('{error}', error.message));
|
|
});
|
|
}
|
|
|
|
// ===== Drag-and-Drop Upload Functionality =====
|
|
const dropOverlay = document.getElementById('dropOverlay');
|
|
const uploadModal = document.getElementById('uploadModal');
|
|
const uploadStatusMessage = document.getElementById('uploadStatusMessage');
|
|
const uploadProgressContainer = document.getElementById('uploadProgressContainer');
|
|
|
|
let dragCounter = 0; // Track nested drag events
|
|
|
|
// Show overlay when dragging files over the window
|
|
window.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
|
|
// Only show overlay if dragging files
|
|
if (e.dataTransfer.types.includes('Files')) {
|
|
dropOverlay.classList.add('active');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('dragleave', (e) => {
|
|
e.preventDefault();
|
|
dragCounter--;
|
|
|
|
if (dragCounter === 0) {
|
|
dropOverlay.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
|
|
window.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
dragCounter = 0;
|
|
dropOverlay.classList.remove('active');
|
|
|
|
const files = await getFilesFromDataTransfer(e.dataTransfer);
|
|
if (files.length > 0) {
|
|
// Show upload modal
|
|
uploadModal.classList.add('active');
|
|
|
|
// Process the dropped files (queue-based, with adaptive throttling)
|
|
processFiles(files, uploadProgressContainer, uploadStatusMessage);
|
|
}
|
|
});
|
|
|
|
// Listen for upload completion event and reload the page to show new files
|
|
window.addEventListener('allUploadsComplete', (e) => {
|
|
// Wait 2 seconds to let users see the success message
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 2000);
|
|
});
|
|
|
|
function closeUploadModal() {
|
|
uploadModal.classList.remove('active');
|
|
}
|
|
|
|
// ---- Full-Text Search (Meilisearch) ----
|
|
let _searchDebounceTimer = null;
|
|
let _searchCurrentPage = 1;
|
|
|
|
function debounceSearch(value) {
|
|
clearTimeout(_searchDebounceTimer);
|
|
if (!value || value.trim().length < 2) {
|
|
// Only hide the results panel; do NOT clear the input value so the
|
|
// user can continue typing characters until the query is long enough.
|
|
var _p = document.getElementById('search-results-panel');
|
|
if (_p) _p.style.display = 'none';
|
|
return;
|
|
}
|
|
_searchDebounceTimer = setTimeout(() => {
|
|
_searchCurrentPage = 1;
|
|
runFullTextSearch();
|
|
}, 400);
|
|
}
|
|
|
|
function runFullTextSearch(page) {
|
|
const input = document.getElementById('fulltext-search');
|
|
const q = input ? input.value.trim() : '';
|
|
if (!q) { clearFullTextSearch(); return; }
|
|
if (page) _searchCurrentPage = page;
|
|
|
|
const panel = document.getElementById('search-results-panel');
|
|
const list = document.getElementById('search-results-list');
|
|
const summary = document.getElementById('search-results-summary');
|
|
const pagination = document.getElementById('search-results-pagination');
|
|
|
|
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.searching + '</div>';
|
|
summary.textContent = '';
|
|
pagination.innerHTML = '';
|
|
panel.style.display = 'block';
|
|
|
|
const params = new URLSearchParams({ q, page: _searchCurrentPage, per_page: 20 });
|
|
fetch(`/api/search?${params.toString()}`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(`Search returned ${r.status}`);
|
|
return r.json();
|
|
})
|
|
.then(data => renderSearchResults(data, q))
|
|
.catch(err => {
|
|
list.innerHTML = '<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> ' + __i18n.searchUnavailable.replace('{error}', err.message) + '</div>';
|
|
summary.textContent = '';
|
|
});
|
|
}
|
|
|
|
function renderSearchResults(data, q) {
|
|
const panel = document.getElementById('search-results-panel');
|
|
const list = document.getElementById('search-results-list');
|
|
const summary = document.getElementById('search-results-summary');
|
|
const pagination = document.getElementById('search-results-pagination');
|
|
|
|
const { results, total, page, pages } = data;
|
|
summary.textContent = __i18n.searchResultsCount.replace('{total}', total).replace('{query}', q);
|
|
|
|
if (!results || results.length === 0) {
|
|
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">' + __i18n.noResults + '</div>';
|
|
pagination.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = results.map(hit => {
|
|
const fmt = hit._formatted || {};
|
|
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
|
const filename = fmt.original_filename || hit.original_filename || '';
|
|
const snippet = fmt.ocr_text || '';
|
|
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
|
const docType = hit.document_type || '';
|
|
|
|
return `<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; gap: 0.75rem; align-items: flex-start;">
|
|
<div style="flex-shrink: 0; color: #3b82f6; font-size: 1.25rem; padding-top: 0.1rem;">
|
|
<i class="fas fa-file-pdf"></i>
|
|
</div>
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${title}</div>
|
|
${filename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${filename}</div>` : ''}
|
|
${docType ? `<span style="display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.5rem; background: #eff6ff; color: #1d4ed8; border-radius: 9999px; font-size: 0.75rem;">${docType}</span>` : ''}
|
|
${tags ? `<span style="display: inline-block; margin-top: 0.25rem; margin-left: 0.25rem; padding: 0.1rem 0.5rem; background: #f0fdf4; color: #15803d; border-radius: 9999px; font-size: 0.75rem;">${tags}</span>` : ''}
|
|
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
|
</div>
|
|
<div style="flex-shrink: 0;">
|
|
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
|
<i class="fas fa-external-link-alt"></i>
|
|
</a>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Pagination
|
|
if (pages > 1) {
|
|
const btns = [];
|
|
if (page > 1) {
|
|
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.prevPage}</button>`);
|
|
}
|
|
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">${__i18n.pageInfo.replace('{page}', page).replace('{pages}', pages)}</span>`);
|
|
if (page < pages) {
|
|
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.nextPage}</button>`);
|
|
}
|
|
pagination.innerHTML = btns.join('');
|
|
} else {
|
|
pagination.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function clearFullTextSearch() {
|
|
const panel = document.getElementById('search-results-panel');
|
|
const input = document.getElementById('fulltext-search');
|
|
if (panel) panel.style.display = 'none';
|
|
if (input) input.value = '';
|
|
_searchCurrentPage = 1;
|
|
}
|
|
</script>
|
|
|
|
<!-- Queue banner updater -->
|
|
<script>
|
|
(function () {
|
|
function updateQueueBanner() {
|
|
fetch('/api/queue/pending-count')
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
if (!data) return;
|
|
var banner = document.getElementById('queueBanner');
|
|
var count = data.total_pending || 0;
|
|
if (count > 0) {
|
|
document.getElementById('queueBannerCount').textContent = count;
|
|
banner.classList.remove('hidden');
|
|
// Show admin link if user is admin (adminMenuContainer visible)
|
|
var adminMenu = document.getElementById('adminMenuContainer');
|
|
if (adminMenu && !adminMenu.classList.contains('hidden')) {
|
|
var link = document.getElementById('queueBannerLink');
|
|
if (link) link.style.display = '';
|
|
}
|
|
} else {
|
|
banner.classList.add('hidden');
|
|
}
|
|
})
|
|
.catch(function () { /* silently ignore */ });
|
|
}
|
|
updateQueueBanner();
|
|
setInterval(updateQueueBanner, 15000);
|
|
})();
|
|
</script>
|
|
</div>
|
|
{% endblock %}
|