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 %} - -
-
- -
-
-

{{ _("comments.heading") }}

-
-
- - -
-
-
- - -
- -
-
-
- - -
-
-

{{ _("annotations.heading") }}

-
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- - - - - - {% 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 %} + +
+
+ +
+
+

{{ _("comments.heading") }}

+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+

{{ _("annotations.heading") }}

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ {% 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