From a7a88218c391ba3642ef60b433e5e6a657bea239 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:45:24 +0000 Subject: [PATCH] feat(ui): add comments and annotations UX to file detail page Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/20bbea80-fdfd-42d2-b0c2-756ca25b240b --- docs/UserGuide.md | 58 +++ frontend/static/js/annotations.js | 295 +++++++++++++ frontend/static/js/comments.js | 464 ++++++++++++++++++++ frontend/templates/file_detail.html | 636 ++++++++++++++++++++++++++++ tests/test_comments_ui.py | 123 ++++++ 5 files changed, 1576 insertions(+) create mode 100644 frontend/static/js/annotations.js create mode 100644 frontend/static/js/comments.js create mode 100644 tests/test_comments_ui.py diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 8f791cfb..8ea9486f 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -705,6 +705,64 @@ You can test your rules without actually routing a document using the **evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full API reference, see [API Documentation](API.md#routing-rules). +## Comments & Annotations + +The file detail page includes a **collaboration panel** for threaded +comments and PDF annotations, allowing team members to discuss documents +directly within DocuElevate. + +### Comments + +The **Comments** panel is on the left side of the collaboration section at +the bottom of the file detail page. + +#### Viewing Comments +Open any file's detail page (`/files/{id}/detail`). Existing comments load +automatically, displayed in a threaded tree — replies are nested under their +parent. + +#### Adding a Comment +1. Type your comment in the text area at the bottom of the Comments panel. +2. Use `@username` to mention another user — an autocomplete dropdown + appears as you type after the `@` symbol. Use arrow keys and Enter to + select a user. +3. Click **Add comment** to post. + +#### Replying to a Comment +Click the **Reply** button on any top-level comment. A reply text area +appears inline; type your response and click **Reply** to post. + +#### Editing & Deleting +You can edit or delete your own comments using the **Edit** and trash +buttons. Edits re-extract @mentions automatically. + +#### Resolving Threads +Click **Resolve** on a top-level comment to mark the thread as resolved +(shown with a green badge). Click **Reopen** to re-open it. + +### Annotations + +The **Annotations** panel is on the right side of the collaboration +section. + +#### Adding an Annotation +1. Type the annotation content in the text area. +2. Set the **Page** number the annotation refers to. +3. Choose a **Type**: Note, Highlight, Underline, or Strikethrough. +4. Pick a **Color** using the color picker. +5. Click **Add annotation** to save. + +#### Editing & Deleting +You can edit or delete your own annotations using the pencil and trash +buttons. When editing, you can also change the annotation type. + +### @Mention Autocomplete + +When typing `@` followed by characters in the comment input, an +autocomplete dropdown shows matching users (sourced from the +`/api/users/mentionable` endpoint). Navigate with arrow keys and press +Enter or click to insert the mention. + ## API Access For programmatic access, DocuElevate provides a comprehensive REST API: diff --git a/frontend/static/js/annotations.js b/frontend/static/js/annotations.js new file mode 100644 index 00000000..985b4135 --- /dev/null +++ b/frontend/static/js/annotations.js @@ -0,0 +1,295 @@ +// frontend/static/js/annotations.js +// Annotations panel — CRUD for PDF page annotations + +(function () { + 'use strict'; + + var _fileId = null; + var _currentUserId = null; + var _i18n = {}; + + // ------------------------------------------------------------------------- + // Initialisation + // ------------------------------------------------------------------------- + + /** + * Bootstrap the annotations panel. + * @param {number} fileId + * @param {string} currentUserId + * @param {object} i18n + */ + function initAnnotations(fileId, currentUserId, i18n) { + _fileId = fileId; + _currentUserId = currentUserId; + _i18n = i18n || {}; + _loadAnnotations(); + + var form = document.getElementById('annotation-form'); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + _createAnnotation(); + }); + } + } + + // ------------------------------------------------------------------------- + // Data fetching + // ------------------------------------------------------------------------- + + function _loadAnnotations() { + var container = document.getElementById('annotations-list'); + if (!container) return; + container.innerHTML = '
'; + + fetch('/api/files/' + _fileId + '/annotations') + .then(function (r) { return r.json(); }) + .then(function (data) { + _renderAnnotations(data.annotations || [], container); + }) + .catch(function () { + container.innerHTML = '

' + (_i18n.empty || 'No annotations yet') + '

'; + }); + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function _renderAnnotations(annotations, container) { + container.innerHTML = ''; + if (!annotations.length) { + container.innerHTML = '

' + + (_i18n.empty || 'No annotations yet') + '

'; + return; + } + for (var i = 0; i < annotations.length; i++) { + container.appendChild(_buildAnnotationNode(annotations[i])); + } + } + + function _buildAnnotationNode(ann) { + var div = document.createElement('div'); + div.className = 'annotation-item'; + div.setAttribute('data-annotation-id', ann.id); + + // Type badge + color indicator + var header = document.createElement('div'); + header.className = 'annotation-header'; + + var typeBadge = document.createElement('span'); + typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type; + typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type; + + var pageInfo = document.createElement('span'); + pageInfo.className = 'annotation-page'; + pageInfo.innerHTML = ' ' + + (_i18n.page || 'Page') + ' ' + ann.page; + + header.appendChild(typeBadge); + if (ann.color) { + var colorDot = document.createElement('span'); + colorDot.className = 'annotation-color-dot'; + colorDot.style.backgroundColor = ann.color; + colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color); + header.appendChild(colorDot); + } + header.appendChild(pageInfo); + + div.appendChild(header); + + // Content + var content = document.createElement('div'); + content.className = 'annotation-content'; + content.id = 'annotation-content-' + ann.id; + content.textContent = ann.content; + div.appendChild(content); + + // Meta + var meta = document.createElement('div'); + meta.className = 'annotation-meta'; + + var author = document.createElement('span'); + author.className = 'annotation-author'; + author.textContent = ann.user_id; + + var time = document.createElement('time'); + time.className = 'annotation-time'; + time.setAttribute('datetime', ann.created_at); + time.textContent = _formatDate(ann.created_at); + + meta.appendChild(author); + meta.appendChild(time); + div.appendChild(meta); + + // Actions (author only) + if (ann.user_id === _currentUserId) { + var actions = document.createElement('div'); + actions.className = 'annotation-actions'; + + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'annotation-action-btn'; + editBtn.innerHTML = ''; + editBtn.setAttribute('aria-label', 'Edit annotation'); + editBtn.addEventListener('click', function () { _showEditForm(ann); }); + actions.appendChild(editBtn); + + var deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger'; + deleteBtn.innerHTML = ''; + deleteBtn.setAttribute('aria-label', 'Delete annotation'); + deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); }); + actions.appendChild(deleteBtn); + + div.appendChild(actions); + } + + return div; + } + + function _formatDate(iso) { + if (!iso) return ''; + try { + var d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch (_e) { + return iso; + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + function _createAnnotation() { + var content = document.getElementById('annotation-content-input'); + var page = document.getElementById('annotation-page-input'); + var type = document.getElementById('annotation-type-input'); + var color = document.getElementById('annotation-color-input'); + + if (!content || !content.value.trim()) return; + + var payload = { + content: content.value.trim(), + page: parseInt(page ? page.value : '1', 10) || 1, + annotation_type: type ? type.value : 'note', + color: color ? color.value : null, + x: 0, + y: 0, + width: 0, + height: 0, + }; + + fetch('/api/files/' + _fileId + '/annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + return r.json(); + }) + .then(function () { + content.value = ''; + if (page) page.value = '1'; + _loadAnnotations(); + }) + .catch(function () {}); + } + + function _deleteAnnotation(annotationId) { + if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return; + + fetch('/api/files/' + _fileId + '/annotations/' + annotationId, { + method: 'DELETE', + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadAnnotations(); + }) + .catch(function () {}); + } + + function _showEditForm(ann) { + var contentDiv = document.getElementById('annotation-content-' + ann.id); + if (!contentDiv) return; + if (contentDiv.querySelector('.annotation-edit-form')) return; + + var originalText = contentDiv.textContent; + contentDiv.textContent = ''; + + var form = document.createElement('div'); + form.className = 'annotation-edit-form'; + + var textarea = document.createElement('textarea'); + textarea.className = 'annotation-textarea'; + textarea.value = ann.content; + textarea.rows = 3; + textarea.setAttribute('aria-label', 'Edit annotation'); + + var typeSelect = document.createElement('select'); + typeSelect.className = 'annotation-select'; + typeSelect.setAttribute('aria-label', 'Annotation type'); + var types = ['note', 'highlight', 'underline', 'strikethrough']; + for (var i = 0; i < types.length; i++) { + var opt = document.createElement('option'); + opt.value = types[i]; + opt.textContent = _i18n['type_' + types[i]] || types[i]; + if (types[i] === ann.annotation_type) opt.selected = true; + typeSelect.appendChild(opt); + } + + var btns = document.createElement('div'); + btns.className = 'annotation-edit-btns'; + + var saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'annotation-submit-btn'; + saveBtn.textContent = _i18n.save || 'Save'; + saveBtn.addEventListener('click', function () { + var newContent = textarea.value.trim(); + if (!newContent) return; + fetch('/api/files/' + _fileId + '/annotations/' + ann.id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: newContent, + annotation_type: typeSelect.value, + page: ann.page, + x: ann.x, + y: ann.y, + }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadAnnotations(); + }) + .catch(function () { + contentDiv.textContent = originalText; + }); + }); + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'annotation-cancel-btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', function () { + contentDiv.textContent = originalText; + }); + + btns.appendChild(saveBtn); + btns.appendChild(cancelBtn); + + form.appendChild(textarea); + form.appendChild(typeSelect); + form.appendChild(btns); + contentDiv.appendChild(form); + textarea.focus(); + } + + // Expose + window.initAnnotations = initAnnotations; +})(); diff --git a/frontend/static/js/comments.js b/frontend/static/js/comments.js new file mode 100644 index 00000000..e0cf9259 --- /dev/null +++ b/frontend/static/js/comments.js @@ -0,0 +1,464 @@ +// frontend/static/js/comments.js +// Comments panel — threaded comments with @mention autocomplete + +(function () { + 'use strict'; + + var _fileId = null; + var _currentUserId = null; + var _i18n = {}; + var _mentionableUsers = []; + + // ------------------------------------------------------------------------- + // Initialisation + // ------------------------------------------------------------------------- + + /** + * Bootstrap the comments panel. + * @param {number} fileId + * @param {string} currentUserId + * @param {object} i18n + */ + function initComments(fileId, currentUserId, i18n) { + _fileId = fileId; + _currentUserId = currentUserId; + _i18n = i18n || {}; + _loadComments(); + _loadMentionableUsers(); + + var form = document.getElementById('comment-form'); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + _submitComment(null); + }); + } + + var input = document.getElementById('comment-input'); + if (input) { + input.addEventListener('input', function () { + _handleMentionInput(this); + }); + input.addEventListener('keydown', function (e) { + _handleMentionKeydown(e); + }); + // Close dropdown when clicking outside + document.addEventListener('click', function (e) { + var dropdown = document.getElementById('mention-dropdown'); + if (dropdown && !dropdown.contains(e.target) && e.target !== input) { + dropdown.classList.add('hidden'); + } + }); + } + } + + // ------------------------------------------------------------------------- + // Data fetching + // ------------------------------------------------------------------------- + + function _loadComments() { + var container = document.getElementById('comments-list'); + if (!container) return; + container.innerHTML = '
'; + + fetch('/api/files/' + _fileId + '/comments') + .then(function (r) { return r.json(); }) + .then(function (data) { + _renderComments(data.comments || [], container); + }) + .catch(function () { + container.innerHTML = '

' + (_i18n.empty || 'No comments yet') + '

'; + }); + } + + function _loadMentionableUsers() { + fetch('/api/users/mentionable') + .then(function (r) { return r.json(); }) + .then(function (users) { + _mentionableUsers = users || []; + }) + .catch(function () { + _mentionableUsers = []; + }); + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function _renderComments(comments, container) { + container.innerHTML = ''; + if (!comments.length) { + container.innerHTML = '

' + + (_i18n.empty || 'No comments yet') + '

'; + return; + } + for (var i = 0; i < comments.length; i++) { + container.appendChild(_buildCommentNode(comments[i], false)); + } + } + + function _buildCommentNode(comment, isReply) { + var div = document.createElement('div'); + div.className = 'comment-item' + (isReply ? ' comment-reply' : '') + + (comment.is_resolved ? ' comment-resolved' : ''); + div.setAttribute('data-comment-id', comment.id); + + // Header + var header = document.createElement('div'); + header.className = 'comment-header'; + + var author = document.createElement('span'); + author.className = 'comment-author'; + author.textContent = comment.user_id; + + var time = document.createElement('time'); + time.className = 'comment-time'; + time.setAttribute('datetime', comment.created_at); + time.textContent = _formatDate(comment.created_at); + + header.appendChild(author); + header.appendChild(time); + + if (comment.is_resolved) { + var badge = document.createElement('span'); + badge.className = 'comment-resolved-badge'; + badge.innerHTML = ' ' + (_i18n.resolved || 'Resolved'); + header.appendChild(badge); + } + + div.appendChild(header); + + // Body + var bodyDiv = document.createElement('div'); + bodyDiv.className = 'comment-body'; + bodyDiv.id = 'comment-body-' + comment.id; + bodyDiv.innerHTML = _renderMentions(comment.body); + div.appendChild(bodyDiv); + + // Actions + var actions = document.createElement('div'); + actions.className = 'comment-actions'; + + // Reply button (only for top-level) + if (!isReply) { + var replyBtn = document.createElement('button'); + replyBtn.type = 'button'; + replyBtn.className = 'comment-action-btn'; + replyBtn.innerHTML = ' ' + (_i18n.add_reply || 'Reply'); + replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply'); + replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); }); + actions.appendChild(replyBtn); + + // Resolve / Unresolve + var resolveBtn = document.createElement('button'); + resolveBtn.type = 'button'; + resolveBtn.className = 'comment-action-btn'; + if (comment.is_resolved) { + resolveBtn.innerHTML = ' ' + (_i18n.unresolve || 'Reopen'); + resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen'); + } else { + resolveBtn.innerHTML = ' ' + (_i18n.resolve || 'Resolve'); + resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve'); + } + resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); }); + actions.appendChild(resolveBtn); + } + + // Edit (author only) + if (comment.user_id === _currentUserId) { + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'comment-action-btn'; + editBtn.innerHTML = ' ' + (_i18n.edit || 'Edit'); + editBtn.setAttribute('aria-label', _i18n.edit || 'Edit'); + editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); }); + actions.appendChild(editBtn); + + // Delete + var deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'comment-action-btn comment-action-btn--danger'; + deleteBtn.innerHTML = ''; + deleteBtn.setAttribute('aria-label', 'Delete comment'); + deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); }); + actions.appendChild(deleteBtn); + } + + div.appendChild(actions); + + // Replies + if (comment.replies && comment.replies.length) { + var repliesDiv = document.createElement('div'); + repliesDiv.className = 'comment-replies'; + for (var j = 0; j < comment.replies.length; j++) { + repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true)); + } + div.appendChild(repliesDiv); + } + + return div; + } + + function _renderMentions(text) { + if (!text) return ''; + // Escape HTML first + var escaped = text.replace(/&/g, '&').replace(//g, '>'); + // Highlight @mentions + return escaped.replace(/@([\w.\-]+)/g, '@$1'); + } + + function _formatDate(iso) { + if (!iso) return ''; + try { + var d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch (_e) { + return iso; + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + function _submitComment(parentId) { + var inputId = parentId ? 'reply-input-' + parentId : 'comment-input'; + var input = document.getElementById(inputId); + if (!input) return; + var body = input.value.trim(); + if (!body) return; + + var payload = { body: body }; + if (parentId) payload.parent_id = parentId; + + fetch('/api/files/' + _fileId + '/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + return r.json(); + }) + .then(function () { + input.value = ''; + _loadComments(); + }) + .catch(function () { + // Silently fail — the CSRF wrapper in common.js handles token injection + }); + } + + function _toggleResolve(commentId, resolve) { + fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resolve: resolve }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () {}); + } + + function _deleteComment(commentId) { + if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return; + + fetch('/api/files/' + _fileId + '/comments/' + commentId, { + method: 'DELETE', + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () {}); + } + + function _showReplyForm(commentId, containerNode) { + // Remove existing reply forms + var existing = containerNode.querySelector('.comment-reply-form'); + if (existing) { existing.remove(); return; } + + var form = document.createElement('div'); + form.className = 'comment-reply-form'; + + var textarea = document.createElement('textarea'); + textarea.id = 'reply-input-' + commentId; + textarea.className = 'comment-textarea'; + textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...'; + textarea.rows = 2; + textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...'); + + var submitBtn = document.createElement('button'); + submitBtn.type = 'button'; + submitBtn.className = 'comment-submit-btn'; + submitBtn.textContent = _i18n.add_reply || 'Reply'; + submitBtn.addEventListener('click', function () { _submitComment(commentId); }); + + form.appendChild(textarea); + form.appendChild(submitBtn); + + // Insert before the replies section or at end + var repliesDiv = containerNode.querySelector('.comment-replies'); + if (repliesDiv) { + containerNode.insertBefore(form, repliesDiv); + } else { + containerNode.appendChild(form); + } + textarea.focus(); + } + + function _showEditForm(commentId, currentBody, containerNode) { + var bodyDiv = document.getElementById('comment-body-' + commentId); + if (!bodyDiv) return; + + // Already editing? + if (bodyDiv.querySelector('.comment-edit-form')) return; + + var originalHTML = bodyDiv.innerHTML; + bodyDiv.innerHTML = ''; + + var form = document.createElement('div'); + form.className = 'comment-edit-form'; + + var textarea = document.createElement('textarea'); + textarea.className = 'comment-textarea'; + textarea.value = currentBody; + textarea.rows = 3; + textarea.setAttribute('aria-label', _i18n.edit || 'Edit'); + + var btns = document.createElement('div'); + btns.className = 'comment-edit-btns'; + + var saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'comment-submit-btn'; + saveBtn.textContent = _i18n.save || 'Save'; + saveBtn.addEventListener('click', function () { + var newBody = textarea.value.trim(); + if (!newBody) return; + fetch('/api/files/' + _fileId + '/comments/' + commentId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: newBody }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Failed'); + _loadComments(); + }) + .catch(function () { + bodyDiv.innerHTML = originalHTML; + }); + }); + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'comment-cancel-btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', function () { + bodyDiv.innerHTML = originalHTML; + }); + + btns.appendChild(saveBtn); + btns.appendChild(cancelBtn); + form.appendChild(textarea); + form.appendChild(btns); + bodyDiv.appendChild(form); + textarea.focus(); + } + + // ------------------------------------------------------------------------- + // @mention autocomplete + // ------------------------------------------------------------------------- + + function _handleMentionInput(input) { + var val = input.value; + var cursorPos = input.selectionStart; + var textBefore = val.substring(0, cursorPos); + var match = textBefore.match(/@([\w.\-]*)$/); + + var dropdown = document.getElementById('mention-dropdown'); + if (!dropdown) return; + + if (!match) { + dropdown.classList.add('hidden'); + return; + } + + var query = match[1].toLowerCase(); + var filtered = _mentionableUsers.filter(function (u) { + return u.user_id.toLowerCase().indexOf(query) !== -1 || + (u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1); + }).slice(0, 8); + + if (!filtered.length) { + dropdown.classList.add('hidden'); + return; + } + + dropdown.innerHTML = ''; + for (var i = 0; i < filtered.length; i++) { + (function (user) { + var item = document.createElement('button'); + item.type = 'button'; + item.className = 'mention-item'; + item.setAttribute('role', 'option'); + item.innerHTML = '' + _escapeHtml(user.user_id) + '' + + (user.display_name ? '' + _escapeHtml(user.display_name) + '' : ''); + item.addEventListener('click', function () { + _insertMention(input, match.index, cursorPos, user.user_id); + dropdown.classList.add('hidden'); + }); + dropdown.appendChild(item); + })(filtered[i]); + } + dropdown.classList.remove('hidden'); + } + + function _handleMentionKeydown(e) { + var dropdown = document.getElementById('mention-dropdown'); + if (!dropdown || dropdown.classList.contains('hidden')) return; + + if (e.key === 'Escape') { + dropdown.classList.add('hidden'); + e.preventDefault(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + var items = dropdown.querySelectorAll('.mention-item'); + var focused = dropdown.querySelector('.mention-item:focus'); + var idx = Array.prototype.indexOf.call(items, focused); + if (e.key === 'ArrowDown') { + idx = (idx + 1) % items.length; + } else { + idx = idx <= 0 ? items.length - 1 : idx - 1; + } + items[idx].focus(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + var active = dropdown.querySelector('.mention-item:focus'); + if (active) { + active.click(); + e.preventDefault(); + } + } + } + + function _insertMention(input, matchStart, cursorPos, userId) { + var before = input.value.substring(0, matchStart); + var after = input.value.substring(cursorPos); + input.value = before + '@' + userId + ' ' + after; + var newPos = matchStart + userId.length + 2; + input.setSelectionRange(newPos, newPos); + input.focus(); + } + + function _escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // Expose + window.initComments = initComments; +})(); diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html index 2e9d3ae1..9bb08b4e 100644 --- a/frontend/templates/file_detail.html +++ b/frontend/templates/file_detail.html @@ -1091,6 +1091,513 @@ } {% endif %} + + + {% endblock %} {% block content %} @@ -1777,6 +2284,135 @@ {% endif %} + +
+
+ +
+
+

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

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

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

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + + + + + {% endif %} {% endblock %} diff --git a/tests/test_comments_ui.py b/tests/test_comments_ui.py new file mode 100644 index 00000000..50d9e9cf --- /dev/null +++ b/tests/test_comments_ui.py @@ -0,0 +1,123 @@ +"""Tests for the comments and annotations UI on the file detail page.""" + +import pytest +from fastapi.testclient import TestClient + +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.""" + file_path = tmp_path / "test.pdf" + file_path.write_bytes(b"%PDF-1.4") + f = FileRecord( + filehash="uihash123", + original_filename="test.pdf", + local_filename=str(file_path), + original_file_path=str(file_path), + file_size=1024, + mime_type="application/pdf", + ) + db_session.add(f) + db_session.commit() + db_session.refresh(f) + return f + + +@pytest.mark.unit +class TestCommentsUIRendering: + """Verify the file detail 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.""" + 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"' 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.""" + 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"' 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.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + 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.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + 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): + """The mention autocomplete dropdown should be present.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + 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): + """Annotation form should have page, type, and color inputs.""" + 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="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): + """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") + assert resp.status_code == 200 + assert "collab-grid" in resp.text + + def test_detail_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") + 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): + """Annotation type selector should include all four types.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + assert resp.status_code == 200 + html = resp.text + assert 'value="note"' in html + assert 'value="highlight"' in html + assert 'value="underline"' in html + assert 'value="strikethrough"' in html + + def test_detail_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") + 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): + """The init script should call initComments and initAnnotations.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/detail") + assert resp.status_code == 200 + html = resp.text + assert "initComments" in html + assert "initAnnotations" in html