refactor(views): split file views into summary, detail, process, and annotations pages

- /files/<id> → new summary page with navigation cards
- /files/<id>/detail → document detail with metadata, preview, text
- /files/<id>/process → processing pipeline status and history
- /files/<id>/annotations → comments & annotations with EmbedPDF viewer
- /files/<id>/comments → redirects to /annotations
- Added embed-pdf-viewer as git submodule for PDF annotation viewer
- Updated all navigation links across templates
- Updated all tests to use new URL structure

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/12276514-bd3d-4e3e-84d9-5977d1f82b19
This commit is contained in:
copilot-swe-agent[bot]
2026-03-22 11:48:57 +00:00
parent 5a3ddcc1f0
commit f852ba9783
14 changed files with 1213 additions and 725 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/embed-pdf-viewer"]
path = vendor/embed-pdf-viewer
url = https://github.com/embedpdf/embed-pdf-viewer.git
+152 -4
View File
@@ -206,10 +206,91 @@ def files_page(
@router.get("/files/{file_id}") @router.get("/files/{file_id}")
@require_login @require_login
def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the file summary page — a concise overview with links to detail, processing, and annotations views.
"""
try:
import json
import os
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse(
"file_summary.html",
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
)
from app.config import settings
workdir = os.path.realpath(settings.workdir)
def _safe_exists(path: str | None) -> bool:
"""Return True only when *path* exists and resides within workdir."""
if not path:
return False
resolved = os.path.realpath(path)
try:
common = os.path.commonpath([resolved, workdir])
except ValueError:
return False
return common == workdir and os.path.exists(resolved)
original_file_exists = _safe_exists(file_record.original_file_path)
processed_file_exists = _safe_exists(file_record.processed_file_path)
# Load AI metadata — JSON sidecar file first, then DB column
gpt_metadata = None
if file_record.processed_file_path:
metadata_path = os.path.splitext(os.path.realpath(file_record.processed_file_path))[0] + ".json"
if _safe_exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as f:
gpt_metadata = json.load(f)
except Exception as e:
logger.warning(f"Failed to load metadata sidecar for file {file_id}: {e}")
if gpt_metadata is None and file_record.ai_metadata:
try:
gpt_metadata = json.loads(file_record.ai_metadata)
except Exception as e:
logger.warning(f"Failed to parse ai_metadata for file {file_id}: {e}")
# Quick processing status
try:
from app.utils.step_manager import get_step_summary as _get_step_summary
step_summary = _get_step_summary(db, file_id)
except Exception:
step_summary = None
pipeline_info = _resolve_pipeline(db, file_record)
return templates.TemplateResponse(
"file_summary.html",
{
"request": request,
"file": file_record,
"gpt_metadata": gpt_metadata,
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
},
)
except Exception as e:
logger.error(f"Error retrieving file summary {file_id}: {str(e)}")
return templates.TemplateResponse("file_summary.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/detail")
@require_login
def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)): def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)):
""" """
Return the document view page — document-centric view with metadata, preview, and extracted text. Return the document detail page — document-centric view with metadata, preview, and extracted text.
Process-oriented details are available via /files/{file_id}/detail.
""" """
try: try:
import json import json
@@ -290,11 +371,11 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)}) return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/detail") @router.get("/files/{file_id}/process")
@require_login @require_login
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)): def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
""" """
Return the file detail page showing processing history and file information Return the file processing page showing processing history and pipeline information.
""" """
try: try:
import json import json
@@ -375,6 +456,73 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)}) return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/annotations")
@require_login
def file_annotations_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the comments & annotations page for a file.
"""
try:
import os
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse(
"file_annotations.html",
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
)
from app.config import settings
workdir = os.path.realpath(settings.workdir)
def _safe_exists(path: str | None) -> bool:
"""Return True only when *path* exists and resides within workdir."""
if not path:
return False
resolved = os.path.realpath(path)
try:
common = os.path.commonpath([resolved, workdir])
except ValueError:
return False
return common == workdir and os.path.exists(resolved)
original_file_exists = _safe_exists(file_record.original_file_path)
processed_file_exists = _safe_exists(file_record.processed_file_path)
# Determine whether the file is a PDF (for EmbedPDF viewer)
mime = file_record.mime_type or ""
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
return templates.TemplateResponse(
"file_annotations.html",
{
"request": request,
"file": file_record,
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"is_pdf": is_pdf,
},
)
except Exception as e:
logger.error(f"Error retrieving annotations for file {file_id}: {str(e)}")
return templates.TemplateResponse("file_annotations.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/comments")
@require_login
def file_comments_redirect(request: Request, file_id: int):
"""
Redirect /files/{file_id}/comments to /files/{file_id}/annotations.
"""
from starlette.responses import RedirectResponse
return RedirectResponse(url=f"/files/{file_id}/annotations", status_code=302)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pipeline ↔ Celery-log stage mapping # Pipeline ↔ Celery-log stage mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+746
View File
@@ -0,0 +1,746 @@
{% extends "base.html" %}
{% block title %}Comments & Annotations - {{ file.original_filename or 'Document' }} - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.annotations-container { max-width: 1200px; margin: 0 auto; }
/* ── PDF viewer ── */
.pdf-viewer-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
margin-bottom: 1.25rem; overflow: hidden;
}
.dark .pdf-viewer-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.pdf-viewer-card-header {
display: flex; align-items: center; gap: 0.5rem;
padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
font-weight: 700; font-size: 0.95rem; color: #374151;
}
.dark .pdf-viewer-card-header { border-bottom-color: #374151; color: #d1d5db; }
#embedpdf-viewer { width: 100%; height: 600px; }
/* ── header ── */
.annotations-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
}
.annotations-title { font-size: 1.5rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
.annotations-subtitle { font-size: 0.85rem; color: #6b7280; margin-top: 0.25rem; }
.dark .annotations-title { color: #f3f4f6; }
.dark .annotations-subtitle { color: #9ca3af; }
/* ── error ── */
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
/* ── card ── */
.collab-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; margin-bottom: 1.25rem;
}
.dark .collab-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
/* ── Comments panel ───────────────────────────────────────────────────── */
.comments-panel, .annotations-panel {
margin-top: 0;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.panel-header h3 {
margin-bottom: 0;
}
.comments-empty, .annotations-empty {
text-align: center;
padding: 2rem;
color: #718096;
}
.comments-loading, .annotations-loading {
text-align: center;
padding: 1.5rem;
color: #718096;
}
.comments-error {
text-align: center;
padding: 1rem;
color: #991B1B;
}
/* Individual comment */
.comment-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #4299e1;
}
.dark .comment-item {
background-color: #2d3748;
border-left-color: #63b3ed;
}
.comment-item.comment-reply {
margin-left: 1.5rem;
border-left-color: #a0aec0;
background-color: #edf2f7;
}
.dark .comment-item.comment-reply {
background-color: #1a202c;
border-left-color: #4a5568;
}
.comment-item.comment-resolved {
opacity: 0.75;
border-left-color: #48bb78;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-author {
font-weight: 600;
color: #2d3748;
font-size: 0.875rem;
}
.dark .comment-author {
color: #e2e8f0;
}
.comment-time {
font-size: 0.75rem;
color: #718096;
}
.comment-resolved-badge {
font-size: 0.75rem;
color: #065F46;
background-color: #D1FAE5;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
}
.dark .comment-resolved-badge {
background-color: #065F46;
color: #D1FAE5;
}
.comment-body {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .comment-body {
color: #cbd5e0;
}
.comment-mention {
color: #3182ce;
font-weight: 600;
}
.dark .comment-mention {
color: #63b3ed;
}
.comment-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-height: 28px;
}
.comment-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .comment-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .comment-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .comment-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
border-color: #f56565;
}
.comment-replies {
margin-top: 0.75rem;
}
/* Comment form */
.comment-form-wrapper {
margin-top: 1rem;
}
.comment-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .comment-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.comment-textarea:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
}
.comment-submit-btn {
background-color: #4299e1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.comment-submit-btn:hover {
background-color: #3182ce;
}
.comment-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .comment-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-edit-btns, .annotation-edit-btns {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.comment-reply-form {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
}
.dark .comment-reply-form {
border-top-color: #4a5568;
}
/* @mention dropdown */
.mention-dropdown-wrapper {
position: relative;
}
#mention-dropdown {
position: absolute;
bottom: 100%;
left: 0;
width: 280px;
max-height: 200px;
overflow-y: auto;
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
margin-bottom: 0.25rem;
}
.dark #mention-dropdown {
background-color: #2d3748;
border-color: #4a5568;
}
.mention-item {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: none;
cursor: pointer;
font-size: 0.875rem;
min-height: 44px;
justify-content: center;
}
.mention-item:hover, .mention-item:focus {
background-color: #edf2f7;
outline: none;
}
.dark .mention-item:hover, .dark .mention-item:focus {
background-color: #4a5568;
}
.mention-user-id {
font-weight: 600;
color: #2d3748;
}
.dark .mention-user-id {
color: #e2e8f0;
}
.mention-display-name {
font-size: 0.75rem;
color: #718096;
}
/* ── Annotations panel ────────────────────────────────────────────────── */
.annotation-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #ecc94b;
}
.dark .annotation-item {
background-color: #2d3748;
border-left-color: #d69e2e;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.annotation-type {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
text-transform: capitalize;
}
.annotation-type--note {
background-color: #DBEAFE;
color: #1E3A8A;
}
.annotation-type--highlight {
background-color: #FEF3C7;
color: #92400E;
}
.annotation-type--underline {
background-color: #D1FAE5;
color: #065F46;
}
.annotation-type--strikethrough {
background-color: #FEE2E2;
color: #991B1B;
}
.dark .annotation-type--note {
background-color: #1E3A8A;
color: #DBEAFE;
}
.dark .annotation-type--highlight {
background-color: #92400E;
color: #FEF3C7;
}
.dark .annotation-type--underline {
background-color: #065F46;
color: #D1FAE5;
}
.dark .annotation-type--strikethrough {
background-color: #991B1B;
color: #FEE2E2;
}
.annotation-color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.annotation-page {
font-size: 0.75rem;
color: #718096;
}
.annotation-content {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .annotation-content {
color: #cbd5e0;
}
.annotation-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #718096;
margin-bottom: 0.5rem;
}
.annotation-author {
font-weight: 600;
}
.annotation-actions {
display: flex;
gap: 0.5rem;
}
.annotation-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
min-height: 28px;
}
.annotation-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .annotation-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .annotation-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.annotation-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .annotation-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
}
.annotation-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .annotation-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-textarea:focus {
outline: none;
border-color: #ecc94b;
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
}
.annotation-select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
margin-top: 0.5rem;
width: 100%;
}
.dark .annotation-select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-submit-btn {
background-color: #ecc94b;
color: #744210;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.annotation-submit-btn:hover {
background-color: #d69e2e;
}
.annotation-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .annotation-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
/* Annotation form layout */
.annotation-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-top: 0.75rem;
}
.annotation-form-grid .form-group {
display: flex;
flex-direction: column;
}
.annotation-form-grid label {
font-size: 0.75rem;
font-weight: 600;
color: #4a5568;
margin-bottom: 0.25rem;
}
.dark .annotation-form-grid label {
color: #a0aec0;
}
.annotation-form-grid input,
.annotation-form-grid select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
}
.dark .annotation-form-grid input,
.dark .annotation-form-grid select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
/* ── Collaboration panels grid ────────────────────────────────────────── */
.collab-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.collab-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="annotations-container">
{% if error %}
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
{% elif file %}
<!-- ── Back + header ── -->
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File
</a>
</div>
<div class="annotations-header">
<div>
<div class="annotations-title">
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;margin-right:0.4rem;"></i>
Comments &amp; Annotations
</div>
<div class="annotations-subtitle">{{ file.original_filename }}</div>
</div>
</div>
<!-- ── PDF Viewer (EmbedPDF) ───────────────────────────────────────────── -->
{% if is_pdf and (processed_file_exists or original_file_exists) %}
<div class="pdf-viewer-card">
<div class="pdf-viewer-card-header">
<i class="fas fa-file-pdf" aria-hidden="true" style="color:#ef4444;"></i>
Document Viewer
</div>
<div id="embedpdf-viewer" aria-label="PDF document viewer"></div>
</div>
{% endif %}
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
<div class="collab-card">
<div class="collab-grid">
<!-- Comments Panel -->
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
</div>
<div id="comments-list" aria-live="polite"></div>
<!-- New comment form -->
<div class="comment-form-wrapper">
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
<div class="mention-dropdown-wrapper">
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
<textarea
id="comment-input"
class="comment-textarea"
placeholder="{{ _('comments.body_placeholder') }}"
rows="3"
aria-label="{{ _('comments.body_placeholder') }}"
maxlength="10000"
></textarea>
</div>
<button type="submit" class="comment-submit-btn">
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
</button>
</form>
</div>
</section>
<!-- Annotations Panel -->
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
</div>
<div id="annotations-list" aria-live="polite"></div>
<!-- New annotation form -->
<div class="comment-form-wrapper">
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
<textarea
id="annotation-content-input"
class="annotation-textarea"
placeholder="{{ _('annotations.content_placeholder') }}"
rows="2"
aria-label="{{ _('annotations.content_placeholder') }}"
maxlength="5000"
></textarea>
<div class="annotation-form-grid">
<div class="form-group">
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
</div>
<div class="form-group">
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
<select id="annotation-type-input" aria-label="Annotation type">
<option value="note">{{ _("annotations.type_note") }}</option>
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
<option value="underline">{{ _("annotations.type_underline") }}</option>
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
</select>
</div>
<div class="form-group">
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
</div>
</div>
<button type="submit" class="annotation-submit-btn">
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
</button>
</form>
</div>
</section>
</div>
</div>
{% else %}
<!-- file is None without error -->
<div class="error-box">Document not found.</div>
{% endif %}
</div>
<!-- Comments & Annotations JS -->
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
{% if file %}
var fileId = {{ file.id | tojson }};
// Detect current user from whoami endpoint
fetch('/api/auth/whoami')
.then(function (r) { return r.json(); })
.then(function (data) {
var userId = (data && (data.email || data.preferred_username)) || '';
var commentsI18n = {
empty: {{ _("comments.empty") | tojson }},
add_comment: {{ _("comments.add_comment") | tojson }},
add_reply: {{ _("comments.add_reply") | tojson }},
edit: {{ _("comments.edit") | tojson }},
save: {{ _("comments.save") | tojson }},
resolve: {{ _("comments.resolve") | tojson }},
resolved: {{ _("comments.resolved") | tojson }},
unresolve: {{ _("comments.unresolve") | tojson }},
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
mention_users: {{ _("comments.mention_users") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
var annotationsI18n = {
empty: {{ _("annotations.empty") | tojson }},
add: {{ _("annotations.add") | tojson }},
save: {{ _("annotations.save") | tojson }},
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
page: {{ _("annotations.page") | tojson }},
color: {{ _("annotations.color") | tojson }},
type_note: {{ _("annotations.type_note") | tojson }},
type_highlight: {{ _("annotations.type_highlight") | tojson }},
type_underline: {{ _("annotations.type_underline") | tojson }},
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
if (typeof initComments === 'function') {
initComments(fileId, userId, commentsI18n);
}
if (typeof initAnnotations === 'function') {
initAnnotations(fileId, userId, annotationsI18n);
}
})
.catch(function () {
// Auth disabled — initialise with empty user
if (typeof initComments === 'function') initComments(fileId, '', {});
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
});
{% endif %}
});
</script>
<!-- ── EmbedPDF Viewer init ── -->
{% if file and is_pdf and (processed_file_exists or original_file_exists) %}
<script async type="module">
import EmbedPDF from 'https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js';
const viewerEl = document.getElementById('embedpdf-viewer');
if (viewerEl) {
{% if processed_file_exists %}
const pdfUrl = '/api/files/{{ file.id }}/preview?version=processed';
{% else %}
const pdfUrl = '/api/files/{{ file.id }}/preview?version=original';
{% endif %}
EmbedPDF.init({
type: 'container',
target: viewerEl,
src: pdfUrl,
});
}
</script>
{% endif %}
{% endblock %}
+5 -5
View File
@@ -1056,7 +1056,7 @@
html += ` html += `
<div role="listitem"> <div role="listitem">
<a href="/files/${doc.file_id}/detail" aria-label="${title}${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;"> <a href="/files/${doc.file_id}" aria-label="${title}${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'"> <div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'">
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true"> <div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true">
${scorePercent}% ${scorePercent}%
@@ -1097,14 +1097,14 @@
{% block content %} {% block content %}
<div class="detail-container"> <div class="detail-container">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/files" class="back-button" style="margin-bottom:0;" aria-label="Back to File List"> <a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> <i class="fas fa-arrow-left" aria-hidden="true"></i>
Back to File List Back to File
</a> </a>
{% if file %} {% if file %}
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="View document for {{ file.original_filename }}"> <a href="/files/{{ file.id }}/detail" class="back-button" style="margin-bottom:0;" aria-label="View document detail for {{ file.original_filename }}">
<i class="fas fa-eye" aria-hidden="true"></i> <i class="fas fa-eye" aria-hidden="true"></i>
View Document Document Detail
</a> </a>
{% endif %} {% endif %}
</div> </div>
+196
View File
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}{{ file.document_title or file.original_filename or 'Document' }} - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.summary-container { max-width: 900px; margin: 0 auto; }
/* ── header ── */
.summary-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
}
.summary-title { font-size: 1.75rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
.summary-subtitle { font-size: 0.9rem; color: #6b7280; margin-top: 0.25rem; font-family: monospace; }
.dark .summary-title { color: #f3f4f6; }
.dark .summary-subtitle { color: #9ca3af; }
/* ── status pill ── */
.status-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.85rem; border-radius: 9999px;
font-size: 0.8rem; font-weight: 600; white-space: nowrap;
}
.pill-completed { background: #d1fae5; color: #065f46; }
.pill-failed { background: #fee2e2; color: #991b1b; }
.pill-processing { background: #dbeafe; color: #1e3a8a; }
.pill-pending { background: #fef3c7; color: #92400e; }
.pill-duplicate { background: #e5e7eb; color: #374151; }
/* ── cards ── */
.summary-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; margin-bottom: 1.25rem;
}
.dark .summary-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.summary-card-title {
font-size: 1rem; font-weight: 700; color: #374151;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.dark .summary-card-title { color: #d1d5db; }
/* ── info rows ── */
.info-row { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.875rem; }
.info-row:last-child { border-bottom: none; }
.info-key { color: #6b7280; }
.info-val { color: #1f2937; font-family: monospace; text-align: right; word-break: break-all; max-width: 60%; }
.dark .info-row { border-bottom-color: #374151; }
.dark .info-key { color: #9ca3af; }
.dark .info-val { color: #e5e7eb; }
/* ── nav cards ── */
.nav-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.25rem; }
@media (max-width: 768px) { .nav-grid { grid-template-columns: 1fr; } }
.nav-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; text-decoration: none; color: #374151;
transition: box-shadow 0.15s, transform 0.15s; min-height: 120px;
}
.nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.15); transform: translateY(-2px); }
.dark .nav-card { background: #1f2937; color: #e5e7eb; }
.dark .nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.4); }
.nav-card i { font-size: 1.5rem; margin-bottom: 0.75rem; }
.nav-card-title { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.25rem; }
.nav-card-desc { font-size: 0.8rem; color: #6b7280; text-align: center; }
.dark .nav-card-desc { color: #9ca3af; }
/* ── action buttons ── */
.action-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 1.1rem; border-radius: 0.375rem;
font-size: 0.875rem; font-weight: 600; text-decoration: none;
cursor: pointer; border: none; transition: filter 0.1s;
}
.action-btn:hover { filter: brightness(0.92); }
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #f3f4f6; color: #374151; }
/* ── error ── */
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
</style>
{% endblock %}
{% block content %}
<div class="summary-container">
{% if error %}
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
{% elif file %}
<!-- ── Back + header ── -->
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
</a>
<div class="summary-header">
<div>
<div class="summary-title">
{% if gpt_metadata and gpt_metadata.filename %}
{{ gpt_metadata.filename | replace('.pdf','') | replace('_',' ') }}
{% elif file.document_title %}
{{ file.document_title }}
{% else %}
{{ file.original_filename or '(untitled)' }}
{% endif %}
</div>
<div class="summary-subtitle">{{ file.original_filename }}</div>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<!-- Status pill -->
{% if file.is_duplicate %}
<span class="status-pill pill-duplicate"><i class="fas fa-copy" aria-hidden="true"></i> Duplicate</span>
{% elif step_summary %}
{% set main_completed = step_summary.main.success + step_summary.main.skipped %}
{% if step_summary.main.failure > 0 or step_summary.uploads.failure > 0 %}
<span class="status-pill pill-failed"><i class="fas fa-times-circle" aria-hidden="true"></i> Failed</span>
{% elif step_summary.main["in_progress"] > 0 or step_summary.uploads["in_progress"] > 0 %}
<span class="status-pill pill-processing"><i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> Processing</span>
{% elif step_summary.total_main_steps > 0 and main_completed == step_summary.total_main_steps and step_summary.main.failure == 0 %}
<span class="status-pill pill-completed"><i class="fas fa-check-circle" aria-hidden="true"></i> Completed</span>
{% else %}
<span class="status-pill pill-pending"><i class="fas fa-pause-circle" aria-hidden="true"></i> Pending</span>
{% endif %}
{% endif %}
</div>
</div>
<!-- ── Navigation cards ── -->
<nav class="nav-grid" aria-label="File sections">
<a href="/files/{{ file.id }}/detail" class="nav-card">
<i class="fas fa-file-alt" aria-hidden="true" style="color:#3b82f6;"></i>
<div class="nav-card-title">Document Detail</div>
<div class="nav-card-desc">Metadata, preview &amp; extracted text</div>
</a>
<a href="/files/{{ file.id }}/process" class="nav-card">
<i class="fas fa-cogs" aria-hidden="true" style="color:#6366f1;"></i>
<div class="nav-card-title">Processing</div>
<div class="nav-card-desc">Pipeline status &amp; processing history</div>
</a>
<a href="/files/{{ file.id }}/annotations" class="nav-card">
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;"></i>
<div class="nav-card-title">Comments &amp; Annotations</div>
<div class="nav-card-desc">Discussion &amp; document annotations</div>
</a>
</nav>
<!-- ── File info card ── -->
<div class="summary-card">
<div class="summary-card-title"><i class="fas fa-info-circle" aria-hidden="true" style="color:#6366f1;margin-right:0.4rem;"></i>File Information</div>
<div class="info-row"><span class="info-key">File ID</span><span class="info-val">{{ file.id }}</span></div>
<div class="info-row"><span class="info-key">Filename</span><span class="info-val">{{ file.original_filename }}</span></div>
<div class="info-row"><span class="info-key">Size</span><span class="info-val">{{ (file.file_size / 1024) | round(1) }} KB</span></div>
<div class="info-row"><span class="info-key">MIME Type</span><span class="info-val">{{ file.mime_type or 'unknown' }}</span></div>
<div class="info-row"><span class="info-key">Created</span><span class="info-val">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</span></div>
{% if file.detected_language %}
<div class="info-row"><span class="info-key">Language</span><span class="info-val">{{ file.detected_language }}</span></div>
{% endif %}
{% if pipeline_info %}
<div class="info-row"><span class="info-key">Pipeline</span><span class="info-val">{{ pipeline_info.name }}</span></div>
{% endif %}
{% if file.document_title %}
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
{% endif %}
</div>
<!-- ── Quick actions ── -->
<div class="summary-card">
<div class="summary-card-title"><i class="fas fa-bolt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>Quick Actions</div>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
{% if processed_file_exists %}
<a href="/api/files/{{ file.id }}/download?version=processed" class="action-btn btn-primary">
<i class="fas fa-download" aria-hidden="true"></i> Download Processed
</a>
{% endif %}
{% if original_file_exists %}
<a href="/api/files/{{ file.id }}/download?version=original" class="action-btn btn-secondary">
<i class="fas fa-download" aria-hidden="true"></i> Download Original
</a>
{% endif %}
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
</a>
</div>
</div>
{% else %}
<!-- file is None without error -->
<div class="error-box">Document not found.</div>
{% endif %}
</div>
{% endblock %}
+4 -636
View File
@@ -148,510 +148,6 @@
/* ── error ── */ /* ── error ── */
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; } .error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
/* ── Comments panel ───────────────────────────────────────────────────── */
.comments-panel, .annotations-panel {
margin-top: 0;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.panel-header h3 {
margin-bottom: 0;
}
.comments-empty, .annotations-empty {
text-align: center;
padding: 2rem;
color: #718096;
}
.comments-loading, .annotations-loading {
text-align: center;
padding: 1.5rem;
color: #718096;
}
.comments-error {
text-align: center;
padding: 1rem;
color: #991B1B;
}
/* Individual comment */
.comment-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #4299e1;
}
.dark .comment-item {
background-color: #2d3748;
border-left-color: #63b3ed;
}
.comment-item.comment-reply {
margin-left: 1.5rem;
border-left-color: #a0aec0;
background-color: #edf2f7;
}
.dark .comment-item.comment-reply {
background-color: #1a202c;
border-left-color: #4a5568;
}
.comment-item.comment-resolved {
opacity: 0.75;
border-left-color: #48bb78;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-author {
font-weight: 600;
color: #2d3748;
font-size: 0.875rem;
}
.dark .comment-author {
color: #e2e8f0;
}
.comment-time {
font-size: 0.75rem;
color: #718096;
}
.comment-resolved-badge {
font-size: 0.75rem;
color: #065F46;
background-color: #D1FAE5;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
}
.dark .comment-resolved-badge {
background-color: #065F46;
color: #D1FAE5;
}
.comment-body {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .comment-body {
color: #cbd5e0;
}
.comment-mention {
color: #3182ce;
font-weight: 600;
}
.dark .comment-mention {
color: #63b3ed;
}
.comment-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-height: 28px;
}
.comment-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .comment-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .comment-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .comment-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
border-color: #f56565;
}
.comment-replies {
margin-top: 0.75rem;
}
/* Comment form */
.comment-form-wrapper {
margin-top: 1rem;
}
.comment-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .comment-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.comment-textarea:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
}
.comment-submit-btn {
background-color: #4299e1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.comment-submit-btn:hover {
background-color: #3182ce;
}
.comment-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .comment-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-edit-btns, .annotation-edit-btns {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.comment-reply-form {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
}
.dark .comment-reply-form {
border-top-color: #4a5568;
}
/* @mention dropdown */
.mention-dropdown-wrapper {
position: relative;
}
#mention-dropdown {
position: absolute;
bottom: 100%;
left: 0;
width: 280px;
max-height: 200px;
overflow-y: auto;
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
margin-bottom: 0.25rem;
}
.dark #mention-dropdown {
background-color: #2d3748;
border-color: #4a5568;
}
.mention-item {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: none;
cursor: pointer;
font-size: 0.875rem;
min-height: 44px;
justify-content: center;
}
.mention-item:hover, .mention-item:focus {
background-color: #edf2f7;
outline: none;
}
.dark .mention-item:hover, .dark .mention-item:focus {
background-color: #4a5568;
}
.mention-user-id {
font-weight: 600;
color: #2d3748;
}
.dark .mention-user-id {
color: #e2e8f0;
}
.mention-display-name {
font-size: 0.75rem;
color: #718096;
}
/* ── Annotations panel ────────────────────────────────────────────────── */
.annotation-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #ecc94b;
}
.dark .annotation-item {
background-color: #2d3748;
border-left-color: #d69e2e;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.annotation-type {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
text-transform: capitalize;
}
.annotation-type--note {
background-color: #DBEAFE;
color: #1E3A8A;
}
.annotation-type--highlight {
background-color: #FEF3C7;
color: #92400E;
}
.annotation-type--underline {
background-color: #D1FAE5;
color: #065F46;
}
.annotation-type--strikethrough {
background-color: #FEE2E2;
color: #991B1B;
}
.dark .annotation-type--note {
background-color: #1E3A8A;
color: #DBEAFE;
}
.dark .annotation-type--highlight {
background-color: #92400E;
color: #FEF3C7;
}
.dark .annotation-type--underline {
background-color: #065F46;
color: #D1FAE5;
}
.dark .annotation-type--strikethrough {
background-color: #991B1B;
color: #FEE2E2;
}
.annotation-color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.annotation-page {
font-size: 0.75rem;
color: #718096;
}
.annotation-content {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .annotation-content {
color: #cbd5e0;
}
.annotation-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #718096;
margin-bottom: 0.5rem;
}
.annotation-author {
font-weight: 600;
}
.annotation-actions {
display: flex;
gap: 0.5rem;
}
.annotation-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
min-height: 28px;
}
.annotation-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .annotation-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .annotation-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.annotation-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .annotation-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
}
.annotation-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .annotation-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-textarea:focus {
outline: none;
border-color: #ecc94b;
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
}
.annotation-select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
margin-top: 0.5rem;
width: 100%;
}
.dark .annotation-select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-submit-btn {
background-color: #ecc94b;
color: #744210;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.annotation-submit-btn:hover {
background-color: #d69e2e;
}
.annotation-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .annotation-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
/* Annotation form layout */
.annotation-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-top: 0.75rem;
}
.annotation-form-grid .form-group {
display: flex;
flex-direction: column;
}
.annotation-form-grid label {
font-size: 0.75rem;
font-weight: 600;
color: #4a5568;
margin-bottom: 0.25rem;
}
.dark .annotation-form-grid label {
color: #a0aec0;
}
.annotation-form-grid input,
.annotation-form-grid select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
}
.dark .annotation-form-grid input,
.dark .annotation-form-grid select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
/* ── Collaboration panels grid ────────────────────────────────────────── */
.collab-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.collab-grid {
grid-template-columns: 1fr;
}
}
</style> </style>
{% endblock %} {% endblock %}
@@ -663,8 +159,8 @@
{% elif file %} {% elif file %}
<!-- ── Back + header ── --> <!-- ── Back + header ── -->
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files"> <a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files <i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File
</a> </a>
<div class="doc-header"> <div class="doc-header">
@@ -699,8 +195,8 @@
{% endif %} {% endif %}
<!-- Process detail link --> <!-- Process detail link -->
<a href="/files/{{ file.id }}/detail" class="action-btn btn-process"> <a href="/files/{{ file.id }}/process" class="action-btn btn-process">
<i class="fas fa-cogs" aria-hidden="true"></i> Processing Details <i class="fas fa-cogs" aria-hidden="true"></i> Processing
</a> </a>
</div> </div>
</div> </div>
@@ -1054,83 +550,6 @@
</div> </div>
{% endif %} {% endif %}
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
<div class="doc-card">
<div class="collab-grid">
<!-- Comments Panel -->
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
</div>
<div id="comments-list" aria-live="polite"></div>
<!-- New comment form -->
<div class="comment-form-wrapper">
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
<div class="mention-dropdown-wrapper">
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
<textarea
id="comment-input"
class="comment-textarea"
placeholder="{{ _('comments.body_placeholder') }}"
rows="3"
aria-label="{{ _('comments.body_placeholder') }}"
maxlength="10000"
></textarea>
</div>
<button type="submit" class="comment-submit-btn">
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
</button>
</form>
</div>
</section>
<!-- Annotations Panel -->
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
</div>
<div id="annotations-list" aria-live="polite"></div>
<!-- New annotation form -->
<div class="comment-form-wrapper">
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
<textarea
id="annotation-content-input"
class="annotation-textarea"
placeholder="{{ _('annotations.content_placeholder') }}"
rows="2"
aria-label="{{ _('annotations.content_placeholder') }}"
maxlength="5000"
></textarea>
<div class="annotation-form-grid">
<div class="form-group">
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
</div>
<div class="form-group">
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
<select id="annotation-type-input" aria-label="Annotation type">
<option value="note">{{ _("annotations.type_note") }}</option>
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
<option value="underline">{{ _("annotations.type_underline") }}</option>
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
</select>
</div>
<div class="form-group">
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
</div>
</div>
<button type="submit" class="annotation-submit-btn">
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
</button>
</form>
</div>
</section>
</div>
</div>
{% else %} {% else %}
<!-- file is None without error --> <!-- file is None without error -->
<div class="error-box">Document not found.</div> <div class="error-box">Document not found.</div>
@@ -1138,9 +557,6 @@
</div> </div>
<!-- Comments & Annotations JS -->
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
<script> <script>
// ── Toggle inline OCR text visibility ── // ── Toggle inline OCR text visibility ──
function toggleOcrText() { function toggleOcrText() {
@@ -1521,54 +937,6 @@
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}'); pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if file %}
// ── Initialise comments & annotations ──
var fileId = {{ file.id | tojson }};
fetch('/api/auth/whoami')
.then(function (r) { return r.json(); })
.then(function (data) {
var userId = (data && (data.email || data.preferred_username)) || '';
var commentsI18n = {
empty: {{ _("comments.empty") | tojson }},
add_comment: {{ _("comments.add_comment") | tojson }},
add_reply: {{ _("comments.add_reply") | tojson }},
edit: {{ _("comments.edit") | tojson }},
save: {{ _("comments.save") | tojson }},
resolve: {{ _("comments.resolve") | tojson }},
resolved: {{ _("comments.resolved") | tojson }},
unresolve: {{ _("comments.unresolve") | tojson }},
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
mention_users: {{ _("comments.mention_users") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
var annotationsI18n = {
empty: {{ _("annotations.empty") | tojson }},
add: {{ _("annotations.add") | tojson }},
save: {{ _("annotations.save") | tojson }},
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
page: {{ _("annotations.page") | tojson }},
color: {{ _("annotations.color") | tojson }},
type_note: {{ _("annotations.type_note") | tojson }},
type_highlight: {{ _("annotations.type_highlight") | tojson }},
type_underline: {{ _("annotations.type_underline") | tojson }},
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
if (typeof initComments === 'function') {
initComments(fileId, userId, commentsI18n);
}
if (typeof initAnnotations === 'function') {
initAnnotations(fileId, userId, annotationsI18n);
}
})
.catch(function () {
if (typeof initComments === 'function') initComments(fileId, '', {});
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
});
{% endif %}
}); });
</script> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -904,7 +904,7 @@
function viewFileDetail(fileId, event) { function viewFileDetail(fileId, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
window.location.href = `/files/${fileId}/detail`; window.location.href = `/files/${fileId}`;
} }
// ── Preview modal ── // ── Preview modal ──
+61 -35
View File
@@ -1,4 +1,4 @@
"""Tests for the comments and annotations UI on the file view page.""" """Tests for the comments and annotations UI on the file annotations page."""
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -7,7 +7,7 @@ from app.models import FileRecord
def _create_file(db_session, tmp_path) -> FileRecord: def _create_file(db_session, tmp_path) -> FileRecord:
"""Create a minimal FileRecord with a real file path for the view page.""" """Create a minimal FileRecord with a real file path for the annotations page."""
file_path = tmp_path / "test.pdf" file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4") file_path.write_bytes(b"%PDF-1.4")
f = FileRecord( f = FileRecord(
@@ -26,77 +26,77 @@ def _create_file(db_session, tmp_path) -> FileRecord:
@pytest.mark.unit @pytest.mark.unit
class TestCommentsUIRendering: class TestCommentsUIRendering:
"""Verify the file view page includes the comments panel HTML.""" """Verify the file annotations page includes the comments panel HTML."""
def test_view_page_contains_comments_section(self, client: TestClient, db_session, tmp_path): def test_annotations_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
"""The view page should render the comments panel container.""" """The annotations page should render the comments panel container."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'id="comments-list"' in html assert 'id="comments-list"' in html
assert 'id="comment-form"' in html assert 'id="comment-form"' in html
assert 'id="comment-input"' in html assert 'id="comment-input"' in html
def test_view_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path): def test_annotations_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
"""The view page should render the annotations panel container.""" """The annotations page should render the annotations panel container."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'id="annotations-list"' in html assert 'id="annotations-list"' in html
assert 'id="annotation-form"' in html assert 'id="annotation-form"' in html
assert 'id="annotation-content-input"' in html assert 'id="annotation-content-input"' in html
def test_view_page_loads_comments_js(self, client: TestClient, db_session, tmp_path): def test_annotations_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
"""The view page should include the comments JavaScript file.""" """The annotations page should include the comments JavaScript file."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
assert "js/comments.js" in resp.text assert "js/comments.js" in resp.text
def test_view_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path): def test_annotations_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
"""The view page should include the annotations JavaScript file.""" """The annotations page should include the annotations JavaScript file."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
assert "js/annotations.js" in resp.text assert "js/annotations.js" in resp.text
def test_view_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path): def test_annotations_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
"""The mention autocomplete dropdown should be present.""" """The mention autocomplete dropdown should be present."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
assert 'id="mention-dropdown"' in resp.text assert 'id="mention-dropdown"' in resp.text
def test_view_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path): def test_annotations_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
"""Annotation form should have page, type, and color inputs.""" """Annotation form should have page, type, and color inputs."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'id="annotation-page-input"' in html assert 'id="annotation-page-input"' in html
assert 'id="annotation-type-input"' in html assert 'id="annotation-type-input"' in html
assert 'id="annotation-color-input"' in html assert 'id="annotation-color-input"' in html
def test_view_page_has_collab_grid(self, client: TestClient, db_session, tmp_path): def test_annotations_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
"""Comments and annotations should be in a side-by-side grid layout.""" """Comments and annotations should be in a side-by-side grid layout."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
assert "collab-grid" in resp.text assert "collab-grid" in resp.text
def test_view_page_no_comments_for_missing_file(self, client: TestClient): def test_annotations_page_no_comments_for_missing_file(self, client: TestClient):
"""When file is not found, no comments section should appear.""" """When file is not found, no comments section should appear."""
resp = client.get("/files/99999") resp = client.get("/files/99999/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
# The error block is shown, not the main content # The error block is shown, not the main content
assert 'id="comments-list"' not in resp.text assert 'id="comments-list"' not in resp.text
def test_view_page_annotation_type_options(self, client: TestClient, db_session, tmp_path): def test_annotations_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
"""Annotation type selector should include all four types.""" """Annotation type selector should include all four types."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'value="note"' in html assert 'value="note"' in html
@@ -104,38 +104,64 @@ class TestCommentsUIRendering:
assert 'value="underline"' in html assert 'value="underline"' in html
assert 'value="strikethrough"' in html assert 'value="strikethrough"' in html
def test_view_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path): def test_annotations_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
"""Comments panel should have proper ARIA attributes.""" """Comments panel should have proper ARIA attributes."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'aria-live="polite"' in html assert 'aria-live="polite"' in html
assert 'role="listbox"' in html assert 'role="listbox"' in html
def test_view_page_init_script(self, client: TestClient, db_session, tmp_path): def test_annotations_page_init_script(self, client: TestClient, db_session, tmp_path):
"""The init script should call initComments and initAnnotations.""" """The init script should call initComments and initAnnotations."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}") resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert "initComments" in html assert "initComments" in html
assert "initAnnotations" in html assert "initAnnotations" in html
def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path): def test_comments_url_redirects_to_annotations(self, client: TestClient, db_session, tmp_path):
"""The detail page should NOT render the comments panel (moved to view page).""" """The /comments URL should redirect to /annotations."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/detail") resp = client.get(f"/files/{f.id}/comments", follow_redirects=False)
assert resp.status_code == 302
assert f"/files/{f.id}/annotations" in resp.headers["location"]
def test_process_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The process page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/process")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'id="comments-list"' not in html assert 'id="comments-list"' not in html
assert 'id="comment-form"' not in html assert 'id="comment-form"' not in html
def test_detail_page_no_annotations_section(self, client: TestClient, db_session, tmp_path): def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The detail page should NOT render the annotations panel (moved to view page).""" """The detail page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path) f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/detail") resp = client.get(f"/files/{f.id}/detail")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'id="annotations-list"' not in html assert 'id="comments-list"' not in html
assert 'id="annotation-form"' not in html assert 'id="annotation-form"' not in html
def test_annotations_page_has_embedpdf_viewer_for_pdf(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the EmbedPDF viewer for PDF files."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="embedpdf-viewer"' in html
assert "@embedpdf/snippet" in html
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
"""The summary page at /files/{id} should render correctly."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert "Document Detail" in html
assert "Processing" in html
assert "Comments" in html or "Annotations" in html
+18 -18
View File
@@ -57,7 +57,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test") pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -80,7 +80,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test") pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
# The preview section should use pdf-viewer, not iframe # The preview section should use pdf-viewer, not iframe
@@ -93,7 +93,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test") pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
assert 'id="pdf-prev-btn"' in html assert 'id="pdf-prev-btn"' in html
@@ -116,7 +116,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # minimal JPEG header img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # minimal JPEG header
rec = _create_file_record(db_session, filename="photo.jpg", mime_type="image/jpeg", file_path=str(img)) rec = _create_file_record(db_session, filename="photo.jpg", mime_type="image/jpeg", file_path=str(img))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -132,7 +132,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
rec = _create_file_record(db_session, filename="photo.png", mime_type="image/png", file_path=str(img)) rec = _create_file_record(db_session, filename="photo.png", mime_type="image/png", file_path=str(img))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
assert 'aria-label="Zoom in"' in html assert 'aria-label="Zoom in"' in html
@@ -146,7 +146,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"RIFF" + b"\x00" * 50) img.write_bytes(b"RIFF" + b"\x00" * 50)
rec = _create_file_record(db_session, filename="wide.webp", mime_type="image/webp", file_path=str(img)) rec = _create_file_record(db_session, filename="wide.webp", mime_type="image/webp", file_path=str(img))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
# Pan support is implemented via JavaScript on img-wrap # Pan support is implemented via JavaScript on img-wrap
@@ -170,7 +170,7 @@ class TestFileViewTextPreview:
txt.write_text("Hello world\nSecond line\n") txt.write_text("Hello world\nSecond line\n")
rec = _create_file_record(db_session, filename="readme.txt", mime_type="text/plain", file_path=str(txt)) rec = _create_file_record(db_session, filename="readme.txt", mime_type="text/plain", file_path=str(txt))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -184,7 +184,7 @@ class TestFileViewTextPreview:
txt.write_text("print('hello')\n") txt.write_text("print('hello')\n")
rec = _create_file_record(db_session, filename="code.py", mime_type="text/x-python", file_path=str(txt)) rec = _create_file_record(db_session, filename="code.py", mime_type="text/x-python", file_path=str(txt))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
assert "copyTextPreview" in html assert "copyTextPreview" in html
@@ -196,7 +196,7 @@ class TestFileViewTextPreview:
txt.write_text("a,b,c\n1,2,3\n") txt.write_text("a,b,c\n1,2,3\n")
rec = _create_file_record(db_session, filename="data.csv", mime_type="text/csv", file_path=str(txt)) rec = _create_file_record(db_session, filename="data.csv", mime_type="text/csv", file_path=str(txt))
response = client.get(f"/files/{rec.id}") response = client.get(f"/files/{rec.id}/detail")
html = response.text html = response.text
# JS builds line-number spans # JS builds line-number spans
@@ -218,7 +218,7 @@ class TestFileViewPreviewIcon:
pdf.write_bytes(b"%PDF-1.4") pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "fa-file-pdf" in html assert "fa-file-pdf" in html
def test_image_icon(self, client: TestClient, db_session, tmp_path): def test_image_icon(self, client: TestClient, db_session, tmp_path):
@@ -227,7 +227,7 @@ class TestFileViewPreviewIcon:
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 10) img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 10)
rec = _create_file_record(db_session, filename="p.jpg", mime_type="image/jpeg", file_path=str(img)) rec = _create_file_record(db_session, filename="p.jpg", mime_type="image/jpeg", file_path=str(img))
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "fa-image" in html assert "fa-image" in html
def test_text_icon(self, client: TestClient, db_session, tmp_path): def test_text_icon(self, client: TestClient, db_session, tmp_path):
@@ -236,7 +236,7 @@ class TestFileViewPreviewIcon:
txt.write_text("hello") txt.write_text("hello")
rec = _create_file_record(db_session, filename="t.txt", mime_type="text/plain", file_path=str(txt)) rec = _create_file_record(db_session, filename="t.txt", mime_type="text/plain", file_path=str(txt))
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "fa-file-code" in html assert "fa-file-code" in html
@@ -321,7 +321,7 @@ class TestFileDetailBottomPreview:
pdf.write_bytes(b"%PDF-1.4") pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf)) rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
response = client.get(f"/files/{rec.id}/detail") response = client.get(f"/files/{rec.id}/process")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -335,7 +335,7 @@ class TestFileDetailBottomPreview:
pdf.write_bytes(b"%PDF-1.4") pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf)) rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
response = client.get(f"/files/{rec.id}/detail") response = client.get(f"/files/{rec.id}/process")
html = response.text html = response.text
assert f"/api/files/{rec.id}/download" in html assert f"/api/files/{rec.id}/download" in html
@@ -350,7 +350,7 @@ class TestFileDetailBottomPreview:
file_path=str(img), file_path=str(img),
) )
response = client.get(f"/files/{rec.id}/detail") response = client.get(f"/files/{rec.id}/process")
html = response.text html = response.text
assert f"/api/files/{rec.id}/preview?version=original" in html assert f"/api/files/{rec.id}/preview?version=original" in html
@@ -372,7 +372,7 @@ class TestFileViewOcrText:
rec.ocr_text = "Sample extracted OCR text content" rec.ocr_text = "Sample extracted OCR text content"
db_session.commit() db_session.commit()
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "toggleOcrText" in html assert "toggleOcrText" in html
assert "ocr-text-block" in html assert "ocr-text-block" in html
assert "Sample extracted OCR text content" in html assert "Sample extracted OCR text content" in html
@@ -383,7 +383,7 @@ class TestFileViewOcrText:
pdf.write_bytes(b"%PDF-1.4") pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf)) rec = _create_file_record(db_session, file_path=str(pdf))
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "loadText" in html or "Extract" in html assert "loadText" in html or "Extract" in html
@@ -409,5 +409,5 @@ class TestFileViewNoFile:
db_session.commit() db_session.commit()
db_session.refresh(rec) db_session.refresh(rec)
html = client.get(f"/files/{rec.id}").text html = client.get(f"/files/{rec.id}/detail").text
assert "No file available for preview" in html assert "No file available for preview" in html
+3 -3
View File
@@ -447,7 +447,7 @@ class TestFileDetailView:
db_session.commit() db_session.commit()
# Test detail view # Test detail view
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
# Check that response contains HTML with file information # Check that response contains HTML with file information
assert b"File Information" in response.content assert b"File Information" in response.content
@@ -504,7 +504,7 @@ class TestFileDetailView:
db_session.commit() db_session.commit()
# Test detail view # Test detail view
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
# Check that response contains branching visualization elements # Check that response contains branching visualization elements
assert b"Process Flow Visualization" in response.content assert b"Process Flow Visualization" in response.content
@@ -514,7 +514,7 @@ class TestFileDetailView:
def test_file_detail_view_nonexistent(self, client: TestClient): def test_file_detail_view_nonexistent(self, client: TestClient):
"""Test file detail view for nonexistent file.""" """Test file detail view for nonexistent file."""
response = client.get("/files/99999/detail") response = client.get("/files/99999/process")
assert response.status_code == 200 # Returns page with error message assert response.status_code == 200 # Returns page with error message
assert b"not found" in response.content.lower() assert b"not found" in response.content.lower()
+3 -3
View File
@@ -50,7 +50,7 @@ def test_file_detail_page_with_metadata(client: TestClient, db_session, sample_p
db_session.refresh(file_record) db_session.refresh(file_record)
# Get detail page # Get detail page
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -85,7 +85,7 @@ def test_file_detail_with_gpt_metadata(client: TestClient, db_session, sample_pd
db_session.refresh(file_record) db_session.refresh(file_record)
# Get detail page # Get detail page
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
@@ -190,7 +190,7 @@ def test_file_detail_shows_file_status_indicators(client: TestClient, db_session
db_session.commit() db_session.commit()
db_session.refresh(file_record) db_session.refresh(file_record)
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
html = response.text html = response.text
+4 -4
View File
@@ -142,7 +142,7 @@ class TestFileDetailPage:
db_session.commit() db_session.commit()
# Test file detail page # Test file detail page
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
content = response.text content = response.text
assert "test.pdf" in content assert "test.pdf" in content
@@ -150,7 +150,7 @@ class TestFileDetailPage:
def test_file_detail_page_with_missing_file(self, client: TestClient, db_session): def test_file_detail_page_with_missing_file(self, client: TestClient, db_session):
"""Test file detail page with non-existent file""" """Test file detail page with non-existent file"""
# Try to access non-existent file # Try to access non-existent file
response = client.get("/files/99999/detail") response = client.get("/files/99999/process")
assert response.status_code == 200 assert response.status_code == 200
content = response.text content = response.text
assert "not found" in content.lower() assert "not found" in content.lower()
@@ -193,7 +193,7 @@ class TestFileDetailPage:
db_session.commit() db_session.commit()
# Test file detail page # Test file detail page
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
content = response.text content = response.text
assert "create_file_record" in content assert "create_file_record" in content
@@ -232,7 +232,7 @@ class TestFileDetailPage:
db_session.commit() db_session.commit()
# Test file detail page # Test file detail page
response = client.get(f"/files/{file_record.id}/detail") response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200 assert response.status_code == 200
content = response.text content = response.text
# Should show metadata # Should show metadata
+16 -16
View File
@@ -206,12 +206,12 @@ class TestFileDetailPage:
db_session.add(file) db_session.add(file)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_page_not_found(self, client: TestClient, db_session): def test_file_detail_page_not_found(self, client: TestClient, db_session):
"""Test file detail page for non-existent file.""" """Test file detail page for non-existent file."""
response = client.get("/files/99999/detail") response = client.get("/files/99999/process")
assert response.status_code == 200 # Still renders template with error assert response.status_code == 200 # Still renders template with error
def test_file_detail_page_with_processing_logs(self, client: TestClient, db_session, tmp_path): def test_file_detail_page_with_processing_logs(self, client: TestClient, db_session, tmp_path):
@@ -244,7 +244,7 @@ class TestFileDetailPage:
db_session.add(log2) db_session.add(log2)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_page_with_metadata_json(self, client: TestClient, db_session, tmp_path): def test_file_detail_page_with_metadata_json(self, client: TestClient, db_session, tmp_path):
@@ -272,7 +272,7 @@ class TestFileDetailPage:
db_session.add(file) db_session.add(file)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_checks_original_file_exists(self, client: TestClient, db_session, tmp_path): def test_file_detail_checks_original_file_exists(self, client: TestClient, db_session, tmp_path):
@@ -289,7 +289,7 @@ class TestFileDetailPage:
db_session.add(file) db_session.add(file)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_error_handling(self, client: TestClient, db_session): def test_file_detail_error_handling(self, client: TestClient, db_session):
@@ -1113,7 +1113,7 @@ class TestFileDetailPageAdditional:
db_session.add(file) db_session.add(file)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_step_summary_fallback(self, client: TestClient, db_session, tmp_path): def test_file_detail_step_summary_fallback(self, client: TestClient, db_session, tmp_path):
@@ -1144,7 +1144,7 @@ class TestFileDetailPageAdditional:
db_session.commit() db_session.commit()
with patch("app.utils.step_manager.get_step_summary", side_effect=Exception("Table not found")): with patch("app.utils.step_manager.get_step_summary", side_effect=Exception("Table not found")):
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
def test_file_detail_error_handling(self, client: TestClient, db_session): def test_file_detail_error_handling(self, client: TestClient, db_session):
@@ -1155,7 +1155,7 @@ class TestFileDetailPageAdditional:
Mock(status_code=200), Mock(status_code=200),
] ]
try: try:
response = client.get("/files/1/detail") response = client.get("/files/1/process")
assert response.status_code in (200, 500) assert response.status_code in (200, 500)
except Exception: except Exception:
pass pass
@@ -1638,7 +1638,7 @@ class TestFileDetailNoJsonSidecar:
db_session.add(file) db_session.add(file)
db_session.commit() db_session.commit()
response = client.get(f"/files/{file.id}/detail") response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200 assert response.status_code == 200
@@ -1936,7 +1936,7 @@ class TestPipelineInfoInViews:
pipeline = self._make_system_pipeline(db_session) pipeline = self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None) file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}/detail") response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200 assert response.status_code == 200
assert b"Standard Processing Pipeline" in response.content assert b"Standard Processing Pipeline" in response.content
@@ -1946,7 +1946,7 @@ class TestPipelineInfoInViews:
self._make_system_pipeline(db_session) self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None) file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}/detail") response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200 assert response.status_code == 200
assert b"System Default" in response.content assert b"System Default" in response.content
@@ -1956,28 +1956,28 @@ class TestPipelineInfoInViews:
pipeline = self._make_custom_pipeline(db_session) pipeline = self._make_custom_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=pipeline.id) file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
response = client.get(f"/files/{file_rec.id}/detail") response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200 assert response.status_code == 200
assert b"My Custom Pipeline" in response.content assert b"My Custom Pipeline" in response.content
assert b"Custom" in response.content assert b"Custom" in response.content
def test_file_view_page_includes_pipeline_name(self, client, db_session): def test_file_view_page_includes_pipeline_name(self, client, db_session):
"""GET /files/{id} response body contains the pipeline name in the sidebar.""" """GET /files/{id}/detail response body contains the pipeline name in the sidebar."""
pipeline = self._make_system_pipeline(db_session) pipeline = self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None) file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}") response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200 assert response.status_code == 200
assert b"Standard Processing Pipeline" in response.content assert b"Standard Processing Pipeline" in response.content
def test_file_view_page_no_pipeline_shows_standard(self, client, db_session): def test_file_view_page_no_pipeline_shows_standard(self, client, db_session):
"""When no pipeline exists, file view shows 'Standard' fallback text.""" """When no pipeline exists, file detail view shows 'Standard' fallback text."""
# No pipeline in DB # No pipeline in DB
file_rec = self._make_file(db_session, pipeline_id=None) file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}") response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200 assert response.status_code == 200
assert b"Standard" in response.content assert b"Standard" in response.content
Vendored Submodule
+1
Submodule vendor/embed-pdf-viewer added at aa45d6ef07