c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
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}`;
|
|
}
|
|
|
|
// ── 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 %}
|