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
This commit is contained in:
copilot-swe-agent[bot]
2026-03-21 21:45:24 +00:00
parent 4b6412734b
commit a7a88218c3
5 changed files with 1576 additions and 0 deletions
+58
View File
@@ -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:
+295
View File
@@ -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 = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/annotations')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderAnnotations(data.annotations || [], container);
})
.catch(function () {
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
});
}
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
function _renderAnnotations(annotations, container) {
container.innerHTML = '';
if (!annotations.length) {
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
(_i18n.empty || 'No annotations yet') + '</p>';
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 = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
(_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 = '<i class="fas fa-edit" aria-hidden="true"></i>';
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 = '<i class="fas fa-trash" aria-hidden="true"></i>';
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;
})();
+464
View File
@@ -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 = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/comments')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderComments(data.comments || [], container);
})
.catch(function () {
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
});
}
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 = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
(_i18n.empty || 'No comments yet') + '</p>';
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 = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_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 = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_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 = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
} else {
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_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 = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_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 = '<i class="fas fa-trash" aria-hidden="true"></i>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Highlight @mentions
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
}
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 = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// Expose
window.initComments = initComments;
})();
+636
View File
@@ -1091,6 +1091,513 @@
}
</script>
{% endif %}
<!-- Comments & Annotations panel styles -->
<style>
/* ── 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 %}
@@ -1777,6 +2284,135 @@
</div>
{% endif %}
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
<div class="detail-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">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>
<!-- 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 () {
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 }}
};
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 }}
};
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, '', {});
});
});
</script>
{% endif %}
</div>
{% endblock %}
+123
View File
@@ -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