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:
@@ -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
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 & 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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 & 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 & Annotations</div>
|
||||||
|
<div class="nav-card-desc">Discussion & 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
Submodule vendor/embed-pdf-viewer added at aa45d6ef07
Reference in New Issue
Block a user