diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html
index 2c1242bc..1642313b 100644
--- a/frontend/templates/file_detail.html
+++ b/frontend/templates/file_detail.html
@@ -1092,512 +1092,6 @@
{% endif %}
-
-
{% endblock %}
{% block content %}
@@ -2284,137 +1778,6 @@
{% endif %}
-
-
-
-
-
-
-
-
{% endif %}
{% endblock %}
diff --git a/frontend/templates/file_view.html b/frontend/templates/file_view.html
index 01083b78..3c1fd0ec 100644
--- a/frontend/templates/file_view.html
+++ b/frontend/templates/file_view.html
@@ -148,6 +148,510 @@
/* ── error ── */
.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;
+ }
+ }
{% endblock %}
@@ -550,6 +1054,83 @@
{% endif %}
+
+
+
{% else %}
Document not found.
@@ -557,6 +1138,9 @@
+
+
+
{% endblock %}
diff --git a/tests/test_comments_ui.py b/tests/test_comments_ui.py
index 50d9e9cf..7a44a7b0 100644
--- a/tests/test_comments_ui.py
+++ b/tests/test_comments_ui.py
@@ -1,4 +1,4 @@
-"""Tests for the comments and annotations UI on the file detail page."""
+"""Tests for the comments and annotations UI on the file view page."""
import pytest
from fastapi.testclient import TestClient
@@ -7,7 +7,7 @@ from app.models import FileRecord
def _create_file(db_session, tmp_path) -> FileRecord:
- """Create a minimal FileRecord with a real file path for the detail page."""
+ """Create a minimal FileRecord with a real file path for the view page."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
f = FileRecord(
@@ -26,77 +26,77 @@ def _create_file(db_session, tmp_path) -> FileRecord:
@pytest.mark.unit
class TestCommentsUIRendering:
- """Verify the file detail page includes the comments panel HTML."""
+ """Verify the file view page includes the comments panel HTML."""
- def test_detail_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
- """The detail page should render the comments panel container."""
+ def test_view_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
+ """The view page should render the comments panel container."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' in html
assert 'id="comment-form"' in html
assert 'id="comment-input"' in html
- def test_detail_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
- """The detail page should render the annotations panel container."""
+ def test_view_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
+ """The view page should render the annotations panel container."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert 'id="annotations-list"' in html
assert 'id="annotation-form"' in html
assert 'id="annotation-content-input"' in html
- def test_detail_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
- """The detail page should include the comments JavaScript file."""
+ def test_view_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
+ """The view page should include the comments JavaScript file."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
assert "js/comments.js" in resp.text
- def test_detail_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
- """The detail page should include the annotations JavaScript file."""
+ def test_view_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
+ """The view page should include the annotations JavaScript file."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
assert "js/annotations.js" in resp.text
- def test_detail_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
"""The mention autocomplete dropdown should be present."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
assert 'id="mention-dropdown"' in resp.text
- def test_detail_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
"""Annotation form should have page, type, and color inputs."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert 'id="annotation-page-input"' in html
assert 'id="annotation-type-input"' in html
assert 'id="annotation-color-input"' in html
- def test_detail_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
"""Comments and annotations should be in a side-by-side grid layout."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
assert "collab-grid" in resp.text
- def test_detail_page_no_comments_for_missing_file(self, client: TestClient):
+ def test_view_page_no_comments_for_missing_file(self, client: TestClient):
"""When file is not found, no comments section should appear."""
- resp = client.get("/files/99999/detail")
+ resp = client.get("/files/99999")
assert resp.status_code == 200
# The error block is shown, not the main content
assert 'id="comments-list"' not in resp.text
- def test_detail_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
"""Annotation type selector should include all four types."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert 'value="note"' in html
@@ -104,20 +104,38 @@ class TestCommentsUIRendering:
assert 'value="underline"' in html
assert 'value="strikethrough"' in html
- def test_detail_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
"""Comments panel should have proper ARIA attributes."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert 'aria-live="polite"' in html
assert 'role="listbox"' in html
- def test_detail_page_init_script(self, client: TestClient, db_session, tmp_path):
+ def test_view_page_init_script(self, client: TestClient, db_session, tmp_path):
"""The init script should call initComments and initAnnotations."""
f = _create_file(db_session, tmp_path)
- resp = client.get(f"/files/{f.id}/detail")
+ resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert "initComments" in html
assert "initAnnotations" in html
+
+ def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
+ """The detail page should NOT render the comments panel (moved to view page)."""
+ f = _create_file(db_session, tmp_path)
+ resp = client.get(f"/files/{f.id}/detail")
+ assert resp.status_code == 200
+ html = resp.text
+ assert 'id="comments-list"' not in html
+ assert 'id="comment-form"' not in html
+
+ def test_detail_page_no_annotations_section(self, client: TestClient, db_session, tmp_path):
+ """The detail page should NOT render the annotations panel (moved to view page)."""
+ f = _create_file(db_session, tmp_path)
+ resp = client.get(f"/files/{f.id}/detail")
+ assert resp.status_code == 200
+ html = resp.text
+ assert 'id="annotations-list"' not in html
+ assert 'id="annotation-form"' not in html
{{ _("comments.heading") }}
-