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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Expose
|
||||
window.initComments = initComments;
|
||||
})();
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user