1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-04-27 11:01:14 +00:00

basic file support comment (#7731)

* basic file support comment

* 01 add init loading icon

* delete useless comment

* 02 delete comment tip

* update api validation

* 03 update API params

* 04 delete useless api

* 05 remove read all notification

* 06 change comment and reply permission

* 07 change docUuid to fileUuid

---------

Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
Michael An 2025-04-21 21:33:13 +08:00 committed by GitHub
parent 1cf26c3d2c
commit 7ff4b52005
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2241 additions and 46 deletions

View File

@ -50,6 +50,7 @@
"react-dnd-html5-backend": "^2.6.0",
"react-dom": "18.3.1",
"react-i18next": "^10.12.2",
"react-mentions": "4.4.10",
"react-responsive": "10.0.0",
"react-select": "5.9.0",
"react-transition-group": "4.4.5",
@ -24819,6 +24820,37 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-mentions": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/react-mentions/-/react-mentions-4.4.10.tgz",
"integrity": "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "7.4.5",
"invariant": "^2.2.4",
"prop-types": "^15.5.8",
"substyle": "^9.1.0"
},
"peerDependencies": {
"react": ">=16.8.3",
"react-dom": ">=16.8.3"
}
},
"node_modules/react-mentions/node_modules/@babel/runtime": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
"integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.13.2"
}
},
"node_modules/react-mentions/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/react-modal": {
"version": "3.16.3",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz",
@ -28811,6 +28843,19 @@
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
"node_modules/substyle": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/substyle/-/substyle-9.4.1.tgz",
"integrity": "sha512-VOngeq/W1/UkxiGzeqVvDbGDPM8XgUyJVWjrqeh+GgKqspEPiLYndK+XRcsKUHM5Muz/++1ctJ1QCF/OqRiKWA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.4",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": ">=16.8.3"
}
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz",

View File

@ -38,6 +38,7 @@
"prop-types": "^15.8.1",
"qrcode.react": "4.2.0",
"react": "18.3.1",
"react-mentions": "4.4.10",
"react-app-polyfill": "^2.0.0",
"react-chartjs-2": "5.3.0",
"react-cookies": "^0.1.0",

View File

@ -19,6 +19,7 @@ const MSG_TYPE_REPO_SHARE_TO_GROUP = 'repo_share_to_group';
const MSG_TYPE_REPO_TRANSFER = 'repo_transfer';
const MSG_TYPE_FILE_UPLOADED = 'file_uploaded';
const MSG_TYPE_FOLDER_UPLOADED = 'folder_uploaded';
const MSG_TYPE_FILE_COMMENT = 'file_comment';
// const MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted';
const MSG_TYPE_REPO_MONITOR = 'repo_monitor';
const MSG_TYPE_DELETED_FILES = 'deleted_files';
@ -38,6 +39,23 @@ class NoticeItem extends React.Component {
let noticeType = noticeItem.type;
let detail = noticeItem.detail;
if (noticeType === MSG_TYPE_FILE_COMMENT) {
let avatar_url = detail.author_avatar_url;
let author = detail.author_name;
let fileName = detail.file_name;
let fileUrl = siteRoot + 'lib/' + detail.repo_id + '/' + 'file' + detail.file_path;
// 1. handle translate
let notice = gettext('File {file_link} has a new comment form user {author}.');
// 2. handle xss(cross-site scripting)
notice = notice.replace('{file_link}', `{tagA}${fileName}{/tagA}`);
notice = notice.replace('{author}', author);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href=${Utils.encodePath(fileUrl)}>`);
notice = notice.replace('{/tagA}', '</a>');
return { avatar_url, notice };
}
if (noticeType === MSG_TYPE_ADD_USER_TO_GROUP) {
let avatar_url = detail.group_staff_avatar_url;
let groupStaff = detail.group_staff_name;

View File

@ -0,0 +1,213 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { seafileAPI } from '../../utils/seafile-api';
import { Utils } from '../../utils/utils';
import toaster from '../toast';
import CommentList from './comment-widget/comment-list';
import ReplyList from './comment-widget/reply-list';
import '../../css/comments-list.css';
const { username, repoID, filePath, fileUuid } = window.app.pageOptions;
const CommentPanelPropTypes = {
toggleCommentPanel: PropTypes.func.isRequired,
participants: PropTypes.array,
onParticipantsChange: PropTypes.func,
};
class CommentPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoading: true,
commentsList: [],
showResolvedComment: true,
participants: null,
relatedUsers: null,
currentComment: null,
};
this.toBeAddedParticipant = [];
}
listComments = () => {
seafileAPI.listComments(repoID, fileUuid).then((res) => {
this.setState({
commentsList: res.data.comments,
isLoading: false,
});
if (this.state.currentComment) {
let newCurrentComment = res.data.comments.find(comment => comment.id === this.state.currentComment.id);
if (newCurrentComment) {
this.setState({
currentComment: newCurrentComment
});
}
}
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
listRepoRelatedUsers = () => {
seafileAPI.listRepoRelatedUsers(repoID).then((res) => {
let users = res.data.user_list.map((item) => {
return { id: item.email, display: item.name, avatar_url: item.avatar_url, contact_email: item.contact_email };
});
this.setState({ relatedUsers: users });
});
};
handleCommentChange = (event) => {
this.setState({ comment: event.target.value });
};
addComment = (comment) => {
seafileAPI.postComment(repoID, fileUuid, comment).then(() => {
this.listComments();
}).catch(err => {
toaster.danger(Utils.getErrorMsg(err));
});
};
addReply = (reply) => {
const replyData = {
author: username,
reply,
type: 'reply',
updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
};
seafileAPI.insertReply(repoID, fileUuid, this.state.currentComment.id, replyData).then(() => {
this.listComments();
}).catch(err => {
toaster.danger(Utils.getErrorMsg(err));
});
};
resolveComment = (comment, resolveState = 'true') => {
seafileAPI.updateComment(repoID, fileUuid, comment.id, resolveState, null, null).then(() => {
this.listComments();
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
deleteComment = (comment) => {
seafileAPI.deleteComment(repoID, fileUuid, comment.id).then(() => {
this.clearCurrentComment();
this.listComments();
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
deleteReply = (commentId, replyId) => {
seafileAPI.deleteReply(repoID, fileUuid, commentId, replyId).then(() => {
this.listComments();
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
updateReply = (commentId, replyId, reply) => {
const replyData = {
author: username,
reply,
type: 'reply',
updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
};
seafileAPI.updateReply(repoID, fileUuid, commentId, replyId, replyData).then(() => {
this.listComments();
}).catch(err => {
toaster.danger(Utils.getErrorMsg(err));
});
};
editComment = (comment, newComment) => {
seafileAPI.updateComment(repoID, fileUuid, comment.id, null, null, newComment).then((res) => {
this.listComments();
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
onParticipantsChange = () => {
if (this.props.onParticipantsChange) {
this.props.onParticipantsChange();
} else {
this.getParticipants();
}
};
getParticipants = () => {
if (this.props.participants) {
this.setState({ participants: this.props.participants });
} else {
seafileAPI.listFileParticipants(repoID, filePath).then((res) => {
this.setState({ participants: res.data.participant_list });
});
}
};
componentDidMount() {
this.listComments();
this.getParticipants();
this.listRepoRelatedUsers();
}
onClickComment = (currentComment) => {
this.setState({ currentComment });
};
clearCurrentComment = () => {
this.setState({ currentComment: null });
};
render() {
const { commentsList } = this.state;
return (
<div className="seafile-comment">
{
this.state.currentComment ?
<ReplyList
currentComment={this.state.currentComment}
clearCurrentComment={this.clearCurrentComment}
toggleCommentList={this.props.toggleCommentPanel}
commentsList={commentsList}
relatedUsers={this.state.relatedUsers}
participants={this.state.participants}
deleteComment={this.deleteComment}
resolveComment={this.resolveComment}
editComment={this.editComment}
addReply={this.addReply}
deleteReply={this.deleteReply}
updateReply={this.updateReply}
onParticipantsChange={this.onParticipantsChange}
/>
:
<CommentList
onClickComment={this.onClickComment}
commentsList={commentsList}
relatedUsers={this.state.relatedUsers}
participants={this.state.participants}
addComment={this.addComment}
toggleCommentList={this.props.toggleCommentPanel}
onParticipantsChange={this.onParticipantsChange}
isLoading={this.state.isLoading}
/>
}
</div>
);
}
}
CommentPanel.propTypes = CommentPanelPropTypes;
export default CommentPanel;

View File

@ -0,0 +1,28 @@
.comments-panel-body__header {
display: flex;
flex-direction: column;
padding: 0 16px;
}
.comments-panel-body__header .comments-types-count {
height: 38px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.comments-panel-body__header .comment-type {
color: #212529;
font-size: 12px;
}
.comments-panel-body__header .comment-type {
color: #212529;
font-size: 12px;
}
.comments-panel-body__header .comment-count-tip {
color: #999;
font-size: 12px;
}

View File

@ -0,0 +1,59 @@
import React, { useState } from 'react';
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import { gettext } from '../../../utils/constants';
import './comment-body-header.css';
const t = gettext;
const CommentBodyHeader = ({ commentList = [], commentType, setCommentType }) => {
const [isDropdownOpen, setDropdownOpen] = useState(false);
let commentTip = null;
if (commentList.length === 1) {
commentTip = gettext('Total {comments_count} comment');
commentTip = commentTip.replace('{comments_count}', commentList.length);
}
if (commentList.length > 1) {
commentTip = gettext('Total {comments_count} comments');
commentTip = commentTip.replace('{comments_count}', commentList.length);
}
const getText = (type) => {
switch (type) {
case 'All comments':
return gettext('All comments');
case 'Resolved comments':
return gettext('Resolved comments');
case 'Unresolved comments':
return gettext('Unresolved comments');
default:
return gettext('All comments');
}
};
return (
<div className='comments-panel-body__header'>
<div className="comments-types-count">
<div id="comment-types" className='comment-type'>
<Dropdown isOpen={isDropdownOpen} toggle={() => setDropdownOpen(!isDropdownOpen)}>
<DropdownToggle tag={'div'} caret className='d-flex align-items-center justify-content-center'>
<div id={'comment-type-controller'}>{getText(commentType)}</div>
</DropdownToggle>
<DropdownMenu className='sdoc-dropdown-menu sdoc-comment-filter-dropdown' container="comment-types">
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'All comments')}>
{t('All comments')}
</DropdownItem>
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'Resolved comments')}>{t('Resolved comments')}</DropdownItem>
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'Unresolved comments')}>{t('Unresolved comments')}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
<div className='comment-count-tip'>{commentTip}</div>
</div>
</div>
);
};
export default CommentBodyHeader;

View File

@ -0,0 +1,9 @@
.comment-delete-popover .comment-delete-popover-container {
padding: 16px;
}
.comment-delete-popover .comment-delete-popover-container .delete-control {
display: flex;
justify-content: flex-end;
width: 100%;
}

View File

@ -0,0 +1,71 @@
import React, { useCallback, useRef, useEffect } from 'react';
import isHotkey from 'is-hotkey';
import { Button, UncontrolledPopover } from 'reactstrap';
import { getEventClassName } from '@/utils/dom';
import { gettext } from '../../../utils/constants';
import './comment-delete-popover.css';
const CommentDeletePopover = ({ type, setIsShowDeletePopover, deleteConfirm, targetId, parentDom = document.body }) => {
const popoverRef = useRef(null);
const hide = useCallback((event) => {
if (popoverRef.current && !getEventClassName(event).includes('popover') && !popoverRef.current.contains(event.target)) {
setIsShowDeletePopover(false);
event.preventDefault();
event.stopPropagation();
return false;
}
}, [setIsShowDeletePopover]);
const onHotKey = useCallback((event) => {
if (isHotkey('esc', event)) {
event.preventDefault();
setIsShowDeletePopover(false);
}
}, [setIsShowDeletePopover]);
useEffect(() => {
document.addEventListener('click', hide, true);
document.addEventListener('keydown', onHotKey);
return () => {
document.removeEventListener('click', hide, true);
document.removeEventListener('keydown', onHotKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onDeleteCancel = useCallback((event) => {
event.stopPropagation();
setIsShowDeletePopover(false);
}, [setIsShowDeletePopover]);
const handleConfirm = useCallback((event) => {
event.stopPropagation();
deleteConfirm();
}, [deleteConfirm]);
return (
<UncontrolledPopover
container={parentDom}
target={targetId}
onClick={event => event.stopPropagation()}
placement="left"
className='comment-delete-popover'
isOpen={true}
>
<div className='comment-delete-popover-container' ref={popoverRef}>
<div className='delete-tip'>
{type === 'comment' ? gettext('Are you sure to delete this comment?') : gettext('Are you sure to delete this reply?')}
</div>
<div className='delete-control mt-5'>
<Button color='secondary' size='sm' className='mr-2' onClick={onDeleteCancel}>{gettext('Cancel')}</Button>
<Button color='primary' size='sm' onClick={handleConfirm}>{gettext('Confirm')}</Button>
</div>
</div>
</UncontrolledPopover>
);
};
export default CommentDeletePopover;

View File

@ -0,0 +1,99 @@
import React from 'react';
import PropTypes from 'prop-types';
import { processor } from '@seafile/seafile-editor';
const commentItemPropTypes = {
time: PropTypes.string,
item: PropTypes.object,
showResolvedComment: PropTypes.bool,
onClickComment: PropTypes.func,
};
class CommentItemReadOnly extends React.Component {
constructor(props) {
super(props);
this.state = {
html: '',
newComment: this.props.item.comment,
};
}
convertComment = (mdFile) => {
processor.process(mdFile).then((result) => {
let html = String(result);
this.setState({ html: html });
});
};
handleCommentChange = (event) => {
this.setState({
newComment: event.target.value,
});
};
componentWillMount() {
this.convertComment(this.props.item.comment);
}
componentWillReceiveProps(nextProps) {
this.convertComment(nextProps.item.comment);
}
onCommentContentClick = (e) => {
// click participant link, page shouldn't jump
if (e.target.nodeName !== 'A') return;
const preNode = e.target.previousSibling;
if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
e.preventDefault();
}
};
render() {
const item = this.props.item;
const replies = item.replies || [];
const lastReply = replies[replies.length - 1];
return (
<li className={'seafile-comment-item'} id={item.id} onClick={() => this.props.onClickComment(item)}>
<div className="seafile-comment-info">
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
<div className="comment-author-info">
<div className="comment-author-name ellipsis">{item.user_name}</div>
<div className="comment-author-time">
{this.props.time}
{item.resolved &&
<span className="comment-success-resolved sdocfont sdoc-mark-as-resolved"></span>
}
</div>
</div>
</div>
<div
className="seafile-comment-content"
dangerouslySetInnerHTML={{ __html: this.state.html }}
onClick={e => this.onCommentContentClick(e)}
>
</div>
{replies.length > 0 &&
<div className="comment-footer">
<span className="comments-count">
<i className="sdocfont sdoc-comments"></i>
<span className="comments-count-number">{replies.length}</span>
</span>
<div className="comment-author">
<span className="comment-author__avatar">
<img alt="" src={lastReply.avatar_url}/>
</span>
<div className="comment-author__latest-reply">
<p>{lastReply.reply}</p>
</div>
</div>
</div>
}
</li>
);
}
}
CommentItemReadOnly.propTypes = commentItemPropTypes;
export default CommentItemReadOnly;

View File

@ -0,0 +1,210 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { processor } from '@seafile/seafile-editor';
import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { gettext } from '../../../utils/constants';
import CommentDeletePopover from './comment-delete-popover';
const commentItemPropTypes = {
time: PropTypes.string,
item: PropTypes.object,
deleteComment: PropTypes.func,
showResolvedComment: PropTypes.bool,
editComment: PropTypes.func,
};
const { username } = window.app.pageOptions;
class CommentItem extends React.Component {
constructor(props) {
super(props);
this.state = {
dropdownOpen: false,
html: '',
newComment: this.props.item.comment,
editable: false,
isShowDeletePopover: false,
};
}
toggleDropDownMenu = () => {
this.setState({
dropdownOpen: !this.state.dropdownOpen,
});
};
convertComment = (mdFile) => {
processor.process(mdFile).then((result) => {
let html = String(result);
this.setState({ html: html });
});
};
toggleEditComment = () => {
this.setState({
editable: !this.state.editable
});
};
updateComment = (event) => {
const newComment = this.state.newComment.trim();
if (this.props.item.comment !== newComment) {
this.props.editComment(this.props.item, newComment);
}
this.toggleEditComment();
};
handleCommentChange = (event) => {
this.setState({
newComment: event.target.value,
});
};
onCommentContentClick = (e) => {
// click participant link, page shouldn't jump
if (e.target.nodeName !== 'A') return;
const preNode = e.target.previousSibling;
if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
e.preventDefault();
}
};
componentWillMount() {
this.convertComment(this.props.item.comment);
}
componentWillReceiveProps(nextProps) {
this.convertComment(nextProps.item.comment);
}
onCommentClick = (e) => {
// click participant link, page shouldn't jump
if (e.target.nodeName !== 'A') return;
const preNode = e.target.previousSibling;
if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
e.preventDefault();
}
};
toggleShowDeletePopover = () => {
this.setState({
isShowDeletePopover: !this.state.isShowDeletePopover
});
};
render() {
const item = this.props.item;
let oldTime = (new Date(item.created_at)).getTime();
let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
const commentOpToolsId = `commentOpTools_${item?.id}`;
if (this.state.editable) {
return (
<li className="seafile-comment-item" id={item.id}>
<div className="seafile-comment-info">
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
<div className="comment-author-info">
<div className="comment-author-name ellipsis">{item.user_name}</div>
<div className="comment-author-time">{time}</div>
</div>
</div>
<div className="seafile-edit-comment">
<textarea className="edit-comment-input" value={this.state.newComment} onChange={this.handleCommentChange} clos="100" rows="3" warp="virtual"></textarea>
<Button className="comment-btn" color="primary" size="sm" onClick={this.updateComment} id={item.id}>{gettext('Update')}</Button>{' '}
<Button className="comment-btn" color="secondary" size="sm" onClick={this.toggleEditComment}>{gettext('Cancel')}</Button>
</div>
</li>
);
}
return (
<li className={'seafile-comment-item'} id={item.id}>
<div className="seafile-comment-info">
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
<div className="comment-author-info">
<div className="comment-author-name ellipsis">{item.user_name}</div>
<div className="comment-author-time">
{time}
{item.resolved &&
<span className="comment-success-resolved sdocfont sdoc-mark-as-resolved"></span>
}
</div>
</div>
{(item.user_email === username) &&
<Dropdown
isOpen={this.state.dropdownOpen}
size="sm"
className="seafile-comment-dropdown"
toggle={this.toggleDropDownMenu}
id={commentOpToolsId}
>
<DropdownToggle
tag="i"
role="button"
tabIndex="0"
className="seafile-comment-dropdown-btn sf-dropdown-toggle sf3-font-more sf3-font"
title={gettext('More operations')}
aria-label={gettext('More operations')}
data-toggle="dropdown"
aria-expanded={this.state.dropdownOpen}
aria-haspopup={true}
/>
<DropdownMenu>
<DropdownItem
onClick={this.toggleShowDeletePopover}
className="delete-comment"
id={item.id}
>
{gettext('Delete')}
</DropdownItem>
<DropdownItem
onClick={this.toggleEditComment}
className="edit-comment"
id={item.id}
>
{gettext('Edit')}
</DropdownItem>
{!item.resolved &&
<DropdownItem
onClick={() => this.props.resolveComment(this.props.item, 'true')}
className="resolve-comment"
id={item.id}
>
{gettext('Mark as resolved')}
</DropdownItem>
}
{item.resolved &&
<DropdownItem
onClick={() => this.props.resolveComment(this.props.item, 'false')}
className="resolve-comment"
id={item.id}
>
{gettext('Resubmit')}
</DropdownItem>
}
</DropdownMenu>
</Dropdown>
}
</div>
<div
className="seafile-comment-content"
dangerouslySetInnerHTML={{ __html: this.state.html }}
onClick={e => this.onCommentContentClick(e)}
>
</div>
{this.state.isShowDeletePopover && (
<CommentDeletePopover
type="comment"
targetId={commentOpToolsId}
deleteConfirm={() => this.props.deleteComment(this.props.item)}
setIsShowDeletePopover={this.toggleShowDeletePopover}
/>
)}
</li>
);
}
}
CommentItem.propTypes = commentItemPropTypes;
export default CommentItem;

View File

@ -0,0 +1,251 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import classname from 'classnames';
import deepCopy from 'deep-copy';
import { gettext } from '../../../utils/constants';
import { seafileAPI } from '../../../utils/seafile-api';
import { Utils } from '../../../utils/utils';
import toaster from '../../toast';
import Loading from '../../loading';
import { MentionsInput, Mention } from 'react-mentions';
import { defaultStyle } from '../../../css/react-mentions-default-style';
import CommentItemReadOnly from './comment-item-readonly';
import CommentBodyHeader from './comment-body-header';
const { username, repoID, filePath } = window.app.pageOptions;
const CommentListPropTypes = {
toggleCommentList: PropTypes.func.isRequired,
participants: PropTypes.array,
onParticipantsChange: PropTypes.func,
};
class CommentList extends React.Component {
constructor(props) {
super(props);
const initStyle = defaultStyle;
initStyle['&multiLine']['input'].minHeight = 40;
initStyle['&multiLine']['input'].height = 40;
initStyle['&multiLine']['input'].borderRadius = '5px';
initStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
initStyle['&multiLine']['input'].lineHeight = '24px';
this.state = {
comment: '',
isInputFocus: false,
defaultStyle: initStyle,
commentType: 'All comments',
};
this.toBeAddedParticipant = [];
this.commentListScrollRef = React.createRef();
}
componentDidUpdate(prevProps) {
if (prevProps.commentsList.length < this.props.commentsList.length) {
let container = this.commentListScrollRef.current;
if (container) {
container.scrollTop = container.scrollHeight + 100;
}
}
}
onKeyDown = (e) => {
if (e.key == 'Enter') {
e.preventDefault();
this.onSubmit();
}
};
handleCommentChange = (event) => {
this.setState({ comment: event.target.value });
};
onSubmit = () => {
if (!this.state.comment.trim()) {
return;
}
this.addParticipant(username);
if (this.toBeAddedParticipant.length === 0) {
this.props.addComment(this.state.comment.trim());
this.setState({ comment: '' });
} else {
seafileAPI.addFileParticipants(repoID, filePath, this.toBeAddedParticipant).then((res) => {
this.onParticipantsChange(repoID, filePath);
this.toBeAddedParticipant = [];
this.props.addComment(this.state.comment.trim());
this.setState({ comment: '' });
}).catch((err) => {
toaster.danger(Utils.getErrorMsg(err));
});
}
};
onParticipantsChange = () => {
if (this.props.onParticipantsChange) {
this.props.onParticipantsChange();
} else {
this.getParticipants();
}
};
checkParticipant = (email) => {
return this.props.participants.map((participant) => {return participant.email;}).includes(email);
};
addParticipant = (email) => {
if (this.checkParticipant(email)) return;
this.toBeAddedParticipant.push(email);
};
renderUserSuggestion = (entry, search, highlightedDisplay, index, focused) => {
return (
<div className={`comment-participant-item user ${focused ? 'active' : ''}`}>
<div className="comment-participant-container">
<img className="comment-participant-avatar" alt={highlightedDisplay} src={entry.avatar_url}/>
<div className="comment-participant-name">{highlightedDisplay}</div>
</div>
</div>
);
};
onInputFocus = () => {
if (this.inpurBlurTimer) {
clearTimeout(this.inpurBlurTimer);
this.inpurBlurTimer = null;
}
if (this.state.isInputFocus === false) {
let defaultStyle = this.state.defaultStyle;
defaultStyle['&multiLine']['input'].maxHeight = 90;
defaultStyle['&multiLine']['input'].minHeight = 90;
defaultStyle['&multiLine']['input'].height = 90;
defaultStyle['&multiLine']['input'].borderBottom = 'none';
defaultStyle['&multiLine']['input'].borderRadius = '5px 5px 0 0';
defaultStyle['&multiLine']['input'].overflowY = 'auto';
defaultStyle['&multiLine']['input'].lineHeight = 'default';
this.setState({
isInputFocus: true,
defaultStyle: deepCopy(defaultStyle),
});
}
};
onInputBlur = () => {
if (this.state.isInputFocus === true) {
this.inpurBlurTimer = setTimeout(() => {
let defaultStyle = this.state.defaultStyle;
defaultStyle['&multiLine']['input'].minHeight = 40;
defaultStyle['&multiLine']['input'].height = 40;
defaultStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
defaultStyle['&multiLine']['input'].borderRadius = '5px';
defaultStyle['&multiLine']['input'].lineHeight = '24px';
this.setState({
isInputFocus: false,
defaultStyle: deepCopy(defaultStyle),
});
}, 100);
}
};
setCommentType = (e, commentType) => {
this.setState({ commentType });
};
getFilteredComments = () => {
const { commentsList } = this.props;
if (this.state.commentType === 'All comments') {
return commentsList;
} else if (this.state.commentType === 'Resolved comments') {
return commentsList.filter((comment) => comment.resolved);
} else if (this.state.commentType === 'Unresolved comments') {
return commentsList.filter((comment) => !comment.resolved);
}
return commentsList;
};
render() {
const { commentsList, isLoading } = this.props;
const filteredComments = this.getFilteredComments();
return (
<div className="seafile-comment-page h-100">
<div className="seafile-comment-title">
<div className="comments-panel-header-left">
{gettext('Comments')}
</div>
<div className="comments-panel-header-right">
<span className="sdoc-icon-btn" onClick={this.props.toggleCommentList}>
<i className="sdocfont sdoc-sm-close"></i>
</span>
</div>
</div>
<div
className="flex-fill o-auto"
style={{ height: this.state.isInputFocus ? 'calc(100% - 170px)' : 'calc(100% - 124px)' }}
ref={this.commentListScrollRef}
>
<CommentBodyHeader
commentList={commentsList}
commentType={this.state.commentType}
setCommentType={this.setCommentType}
/>
{isLoading && <Loading/>}
{!isLoading && filteredComments.length > 0 &&
<ul className="seafile-comment-list">
{filteredComments.map((item) => {
let oldTime = (new Date(item.created_at)).getTime();
let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
return (
<CommentItemReadOnly
key={item.id}
item={item}
time={time}
onClickComment={this.props.onClickComment}
/>
);
})}
</ul>
}
{!isLoading && filteredComments.length === 0 &&
<p className="text-center my-4">{gettext('No comment yet.')}</p>
}
</div>
<div
className={classname('seafile-comment-footer flex-shrink-0')}
style={{ height: this.state.isInputFocus ? '120px' : '72px' }}
>
<MentionsInput
value={this.state.comment}
onChange={this.handleCommentChange}
onKeyDown={this.onKeyDown}
placeholder={gettext('Enter comment, Shift + Enter for new line, Enter to send')}
style={this.state.defaultStyle}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
>
<Mention
trigger="@"
displayTransform={(username, display) => `@${display}`}
data={this.props.relatedUsers}
renderSuggestion={this.renderUserSuggestion}
onAdd={(id, display) => {this.addParticipant(id);}}
appendSpaceOnAdd={true}
/>
</MentionsInput>
{this.state.isInputFocus &&
<div className="comment-submit-container">
<div onClick={this.onSubmit}>
<i className="sdocfont sdoc-save sdoc-comment-btn"></i>
</div>
</div>
}
</div>
</div>
);
}
}
CommentList.propTypes = CommentListPropTypes;
export default CommentList;

View File

@ -0,0 +1,175 @@
import React from 'react';
import PropTypes from 'prop-types';
import { processor } from '@seafile/seafile-editor';
import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { gettext } from '../../../utils/constants';
import CommentDeletePopover from './comment-delete-popover';
const { username } = window.app.pageOptions;
const commentItemPropTypes = {
time: PropTypes.string,
item: PropTypes.object,
deleteReply: PropTypes.func,
showResolvedComment: PropTypes.bool,
editComment: PropTypes.func,
};
class ReplyItem extends React.Component {
constructor(props) {
super(props);
this.state = {
dropdownOpen: false,
html: '',
newReply: this.props.item.reply,
editable: false,
isShowDeletePopover: false,
};
}
componentWillMount() {
this.convertComment(this.props.item.reply);
}
componentWillReceiveProps(nextProps) {
this.convertComment(nextProps.item.reply);
}
toggleDropDownMenu = () => {
this.setState({
dropdownOpen: !this.state.dropdownOpen,
});
};
convertComment = (mdFile) => {
processor.process(mdFile).then((result) => {
let html = String(result);
this.setState({ html: html });
});
};
toggleEditComment = () => {
this.setState({
editable: !this.state.editable
});
};
updateComment = () => {
const newReply = this.state.newReply.trim();
if (this.props.item.reply !== newReply) {
this.props.updateReply(newReply);
}
this.toggleEditComment();
};
handleCommentChange = (event) => {
this.setState({
newReply: event.target.value,
});
};
onCommentContentClick = (e) => {
// click participant link, page shouldn't jump
if (e.target.nodeName !== 'A') return;
const preNode = e.target.previousSibling;
if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
e.preventDefault();
}
};
toggleShowDeletePopover = () => {
this.setState({
isShowDeletePopover: !this.state.isShowDeletePopover
});
};
render() {
const item = this.props.item;
const replyOpToolsId = `commentOpTools_${item?.id}`;
if (this.state.editable) {
return (
<li className="seafile-comment-item" id={item.id}>
<div className="seafile-comment-info mt-1">
<img className="avatar" src={item.avatar_url} alt=""/>
<div className="comment-author-info">
<div className="comment-author-name ellipsis">{item.user_name}</div>
<div className="comment-author-time">{this.props.time}</div>
</div>
</div>
<div className="seafile-edit-comment">
<textarea className="edit-comment-input" value={this.state.newReply} onChange={this.handleCommentChange} clos="100" rows="3" warp="virtual"></textarea>
<Button className="comment-btn" color="primary" size="sm" onClick={this.updateComment} id={item.id}>{gettext('Update')}</Button>{' '}
<Button className="comment-btn" color="secondary" size="sm" onClick={this.toggleEditComment}>{gettext('Cancel')}</Button>
</div>
</li>
);
}
return (
<li className={'seafile-comment-item'} id={item.id}>
<div className="seafile-comment-info mt-1">
<img className="avatar" src={item.avatar_url} alt=""/>
<div className="comment-author-info">
<div className="comment-author-name ellipsis">{item.user_name}</div>
<div className="comment-author-time">{this.props.time}</div>
</div>
{(item.user_email === username) &&
<Dropdown
isOpen={this.state.dropdownOpen}
size="sm"
className="seafile-comment-dropdown"
toggle={this.toggleDropDownMenu}
id={replyOpToolsId}
>
<DropdownToggle
tag="i"
role="button"
tabIndex="0"
className="seafile-comment-dropdown-btn sf-dropdown-toggle sf3-font-more sf3-font"
title={gettext('More operations')}
aria-label={gettext('More operations')}
data-toggle="dropdown"
aria-expanded={this.state.dropdownOpen}
aria-haspopup={true}
/>
<DropdownMenu>
<DropdownItem
onClick={this.toggleShowDeletePopover}
className="delete-comment"
id={item.id}
>
{gettext('Delete')}
</DropdownItem>
<DropdownItem
onClick={this.toggleEditComment}
className="edit-comment"
id={item.id}
>
{gettext('Edit')}
</DropdownItem>
</DropdownMenu>
</Dropdown>
}
</div>
<div
className="seafile-comment-content"
dangerouslySetInnerHTML={{ __html: this.state.html }}
onClick={e => this.onCommentContentClick(e)}
>
</div>
{this.state.isShowDeletePopover && (
<CommentDeletePopover
type="reply"
deleteConfirm={this.props.deleteReply}
setIsShowDeletePopover={this.toggleShowDeletePopover}
targetId={replyOpToolsId}
/>
)}
</li>
);
}
}
ReplyItem.propTypes = commentItemPropTypes;
export default ReplyItem;

View File

@ -0,0 +1,234 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import classname from 'classnames';
import deepCopy from 'deep-copy';
import { gettext } from '../../../utils/constants';
import { seafileAPI } from '../../../utils/seafile-api';
import { Utils } from '../../../utils/utils';
import toaster from '../../toast';
import { MentionsInput, Mention } from 'react-mentions';
import { defaultStyle } from '../../../css/react-mentions-default-style';
import CommentItem from './comment-item';
import ReplyItem from './reply-item';
const { username, repoID, filePath } = window.app.pageOptions;
const ReplyListPropTypes = {
toggleCommentList: PropTypes.func.isRequired,
participants: PropTypes.array,
onParticipantsChange: PropTypes.func,
currentComment: PropTypes.object,
clearCurrentComment: PropTypes.func,
commentsList: PropTypes.array,
};
class ReplyList extends React.Component {
constructor(props) {
super(props);
const initStyle = defaultStyle;
initStyle['&multiLine']['input'].minHeight = 40;
initStyle['&multiLine']['input'].height = 40;
initStyle['&multiLine']['input'].borderRadius = '5px';
initStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
initStyle['&multiLine']['input'].lineHeight = '24px';
this.state = {
comment: '',
isInputFocus: false,
defaultStyle: initStyle,
};
this.toBeAddedParticipant = [];
this.commentListScrollRef = React.createRef();
}
componentDidUpdate(prevProps) {
if (prevProps.currentComment.replies.length < this.props.currentComment.replies.length) {
let container = this.commentListScrollRef.current;
if (container) {
container.scrollTop = container.scrollHeight + 100;
}
}
}
onKeyDown = (e) => {
if (e.key == 'Enter') {
e.preventDefault();
this.onSubmit();
}
};
handleCommentChange = (event) => {
this.setState({ comment: event.target.value });
};
onSubmit = () => {
if (!this.state.comment.trim()) return;
this.addParticipant(username);
if (this.toBeAddedParticipant.length === 0) {
this.props.addReply(this.state.comment.trim());
this.setState({ comment: '' });
} else {
seafileAPI.addFileParticipants(repoID, filePath, this.toBeAddedParticipant).then((res) => {
this.onParticipantsChange(repoID, filePath);
this.toBeAddedParticipant = [];
this.props.addReply(this.state.comment.trim());
this.setState({ comment: '' });
}).catch((err) => {
toaster.danger(Utils.getErrorMsg(err));
});
}
};
onParticipantsChange = () => {
if (this.props.onParticipantsChange) {
this.props.onParticipantsChange();
} else {
this.getParticipants();
}
};
checkParticipant = (email) => {
return this.props.participants.map((participant) => {return participant.email;}).includes(email);
};
addParticipant = (email) => {
if (this.checkParticipant(email)) return;
this.toBeAddedParticipant.push(email);
};
renderUserSuggestion = (entry, search, highlightedDisplay, index, focused) => {
return (
<div className={`comment-participant-item user ${focused ? 'active' : ''}`}>
<div className="comment-participant-container">
<img className="comment-participant-avatar" alt={highlightedDisplay} src={entry.avatar_url}/>
<div className="comment-participant-name">{highlightedDisplay}</div>
</div>
</div>
);
};
onInputFocus = () => {
if (this.inpurBlurTimer) {
clearTimeout(this.inpurBlurTimer);
this.inpurBlurTimer = null;
}
if (this.state.isInputFocus === false) {
let defaultStyle = this.state.defaultStyle;
defaultStyle['&multiLine']['input'].maxHeight = 90;
defaultStyle['&multiLine']['input'].minHeight = 90;
defaultStyle['&multiLine']['input'].height = 90;
defaultStyle['&multiLine']['input'].borderBottom = 'none';
defaultStyle['&multiLine']['input'].borderRadius = '5px 5px 0 0';
defaultStyle['&multiLine']['input'].overflowY = 'auto';
defaultStyle['&multiLine']['input'].lineHeight = 'default';
this.setState({
isInputFocus: true,
defaultStyle: deepCopy(defaultStyle),
});
}
};
onInputBlur = () => {
if (this.state.isInputFocus === true) {
this.inpurBlurTimer = setTimeout(() => {
let defaultStyle = this.state.defaultStyle;
defaultStyle['&multiLine']['input'].minHeight = 40;
defaultStyle['&multiLine']['input'].height = 40;
defaultStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
defaultStyle['&multiLine']['input'].borderRadius = '5px';
defaultStyle['&multiLine']['input'].lineHeight = '24px';
this.setState({
isInputFocus: false,
defaultStyle: deepCopy(defaultStyle),
});
}, 100);
}
};
render() {
const { currentComment } = this.props;
const { replies } = currentComment;
return (
<div className="seafile-reply-page h-100">
<div className="seafile-comment-title">
<div className="comments-panel-header-left">
<div className="goback sdoc-icon-btn ml-0 mr-1" onClick={this.props.clearCurrentComment}>
<i className="sdocfont sdoc-previous-page" style={{ transform: 'scale(1.2)' }}></i>
</div>
<span className="title">{gettext('Comment details')}</span>
</div>
<div className="comments-panel-header-right">
<div className="sdoc-icon-btn" onClick={this.props.toggleCommentList}>
<i className="sdocfont sdoc-sm-close"></i>
</div>
</div>
</div>
<div
className="flex-fill o-auto"
style={{ height: this.state.isInputFocus ? 'calc(100% - 170px)' : 'calc(100% - 124px)' }}
ref={this.commentListScrollRef}
>
<ul className="seafile-comment-list">
<CommentItem
key={currentComment.id}
item={currentComment}
deleteComment={this.props.deleteComment}
resolveComment={this.props.resolveComment}
editComment={this.props.editComment}
/>
{replies.map((item) => {
let oldTime = (new Date(item.created_at)).getTime();
let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
return (
<ReplyItem
key={item.id}
item={item}
time={time}
deleteReply={() => this.props.deleteReply(currentComment.id, item.id)}
updateReply={(replyContent) => this.props.updateReply(currentComment.id, item.id, replyContent)}
/>
);
})}
</ul>
</div>
<div
className={classname('seafile-comment-footer flex-shrink-0')}
style={{ height: this.state.isInputFocus ? '120px' : '72px' }}
>
<MentionsInput
value={this.state.comment}
onChange={this.handleCommentChange}
placeholder={gettext('Enter reply, Shift + Enter for new line, Enter to send')}
onKeyDown={this.onKeyDown}
style={this.state.defaultStyle}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
>
<Mention
trigger="@"
displayTransform={(username, display) => `@${display}`}
data={this.state.relatedUsers}
renderSuggestion={this.renderUserSuggestion}
onAdd={(id, display) => {this.addParticipant(id);}}
appendSpaceOnAdd={true}
/>
</MentionsInput>
{this.state.isInputFocus &&
<div className="comment-submit-container">
<div onClick={this.onSubmit}>
<i className="sdocfont sdoc-save sdoc-comment-btn"></i>
</div>
</div>
}
</div>
</div>
);
}
}
ReplyList.propTypes = ReplyListPropTypes;
export default ReplyList;

View File

@ -18,6 +18,7 @@ const propTypes = {
isSaving: PropTypes.bool,
needSave: PropTypes.bool,
toggleLockFile: PropTypes.func.isRequired,
toggleCommentPanel: PropTypes.func.isRequired,
toggleDetailsPanel: PropTypes.func.isRequired,
setImageScale: PropTypes.func,
rotateImage: PropTypes.func
@ -157,15 +158,6 @@ class FileToolbar extends React.Component {
onClick={this.props.toggleLockFile}
/>
)}
{showShareBtn && (
<IconButton
id="share-file"
icon='share'
text={gettext('Share')}
onClick={this.toggleShareDialog}
/>
)}
{(canEditFile && fileType != 'SDoc' && !err) &&
(this.props.isSaving ?
<div type='button' aria-label={gettext('Saving...')} className={'file-toolbar-btn'}>
@ -198,12 +190,19 @@ class FileToolbar extends React.Component {
text={gettext('Details')}
onClick={this.props.toggleDetailsPanel}
/>
{filePerm == 'rw' && (
<div
className='file-toolbar-btn'
onClick={this.props.toggleCommentPanel}
aria-label={gettext('Comment')}
>
<i className="sdocfont sdoc-comments"></i>
</div>
{showShareBtn && (
<IconButton
id="open-via-client"
icon="client"
text={gettext('Open via Client')}
href={`seafile://openfile?repo_id=${encodeURIComponent(repoID)}&path=${encodeURIComponent(filePath)}`}
id="share-file"
icon='share'
text={gettext('Share')}
onClick={this.toggleShareDialog}
/>
)}
<Dropdown isOpen={moreDropdownOpen} toggle={this.toggleMoreOpMenu}>
@ -216,6 +215,11 @@ class FileToolbar extends React.Component {
<Icon symbol="more-vertical" />
</DropdownToggle>
<DropdownMenu>
{/* {(
<DropdownItem onClick={this.props.toggleCommentPanel}>
{gettext('Comment')}
</DropdownItem>
)} */}
{filePerm == 'rw' && (
<a href={`${siteRoot}repo/file_revisions/${repoID}/?p=${encodeURIComponent(filePath)}&referer=${encodeURIComponent(location.href)}`} className="dropdown-item">
{gettext('History')}
@ -224,6 +228,11 @@ class FileToolbar extends React.Component {
<a href={`${siteRoot}library/${repoID}/${Utils.encodePath(repoName + parentDir)}`} className="dropdown-item">
{gettext('Open parent folder')}
</a>
{filePerm == 'rw' && (
<a href={`seafile://openfile?repo_id=${encodeURIComponent(repoID)}&path=${encodeURIComponent(filePath)}`} className="dropdown-item">
{gettext('Open via client')}
</a>
)}
</DropdownMenu>
</Dropdown>
</div>
@ -275,6 +284,11 @@ class FileToolbar extends React.Component {
</a>
</DropdownItem>
)}
{(
<DropdownItem onClick={this.props.toggleCommentPanel}>
{gettext('Comment')}
</DropdownItem>
)}
<DropdownItem onClick={this.props.toggleDetailsPanel}>{gettext('Details')}</DropdownItem>
</DropdownMenu>
</Dropdown>

View File

@ -10,6 +10,7 @@ import toaster from '../toast';
import IconButton from '../icon-button';
import FileInfo from './file-info';
import FileToolbar from './file-toolbar';
import CommentPanel from './comment-panel';
import OnlyofficeFileToolbar from './onlyoffice-file-toolbar';
import EmbeddedFileDetails from '../dirent-detail/embedded-file-details';
import { MetadataStatusProvider } from '../../hooks';
@ -43,6 +44,7 @@ class FileView extends React.Component {
isStarred: isStarred,
isLocked: isLocked,
lockedByMe: lockedByMe,
isCommentPanelOpen: false,
isHeaderShown: (storedIsHeaderShown === null) || (storedIsHeaderShown == 'true'),
isDetailsPanelOpen: false
};
@ -53,8 +55,18 @@ class FileView extends React.Component {
document.getElementById('favicon').href = fileIcon;
}
toggleCommentPanel = () => {
this.setState({
isCommentPanelOpen: !this.state.isCommentPanelOpen,
isDetailsPanelOpen: false,
});
};
toggleDetailsPanel = () => {
this.setState({ isDetailsPanelOpen: !this.state.isDetailsPanelOpen });
this.setState({
isDetailsPanelOpen: !this.state.isDetailsPanelOpen,
isCommentPanelOpen: false,
});
};
toggleStar = () => {
@ -142,6 +154,7 @@ class FileView extends React.Component {
isSaving={this.props.isSaving}
needSave={this.props.needSave}
toggleLockFile={this.toggleLockFile}
toggleCommentPanel={this.toggleCommentPanel}
toggleDetailsPanel={this.toggleDetailsPanel}
setImageScale={this.props.setImageScale}
rotateImage={this.props.rotateImage}
@ -158,6 +171,13 @@ class FileView extends React.Component {
/>
}
{this.props.content}
{this.state.isCommentPanelOpen &&
<CommentPanel
toggleCommentPanel={this.toggleCommentPanel}
participants={this.props.participants}
onParticipantsChange={this.props.onParticipantsChange}
/>
}
{isDetailsPanelOpen && (
<MetadataStatusProvider repoID={repoID} repoInfo={repoInfo}>
<CollaboratorsProvider repoID={repoID}>

View File

@ -0,0 +1,267 @@
.seafile-comment {
background-color: #fff;
display: flex;
flex-direction: column;
}
.seafile-comment-title {
border-bottom: 1px solid #e5e5e5;
background-color: #fff;
height: 46px;
line-height: 46px;
padding: 0 16px;
display: flex;
justify-content: space-between;
}
.seafile-comment-title .comments-panel-header-left {
font-size: 16px;
font-weight: 500;
}
.seafile-comment-title .sdoc-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
cursor: pointer;
margin-left: 4px;
border-radius: 3px;
}
.seafile-comment-title .sdoc-icon-btn .sdocfont {
color: #999;
}
.seafile-comment-title .sdoc-icon-btn:hover {
background-color: #efefef;
}
.seafile-comment-item {
padding: 16px;
margin-bottom: 0;
}
.seafile-comment-page .seafile-comment-item:hover {
background-color: #f5f5f5;
cursor: pointer;
}
.seafile-comment-item .seafile-comment-info {
padding-bottom: 0.5em;
height: 3em;
display: flex;
justify-content: flex-start;
}
.seafile-comment-item .seafile-comment-info .comment-author-name {
color: #1f1f1f;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.seafile-comment-item .seafile-comment-info .avatar {
width: 24px;
height: 24px;
}
.seafile-comment-item .seafile-comment-info .comment-author-info {
padding-left: 10px;
max-width: 75%;
}
.seafile-comment-item .seafile-comment-info .comment-author-time {
display: inline-flex;
align-items: center;
color: #444746;
font-size: 12px;
line-height: 16px;
}
.seafile-comment-item .seafile-comment-info .comment-author-time .comment-success-resolved {
color: rgb(71, 184, 129);
margin-left: 6px;
font-size: 14px;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown {
margin-left: auto;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle {
padding: 4px;
border-radius: 3px;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle:focus,
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle:hover {
color: #999;
background-color: #efefef;
}
.seafile-comment-item .seafile-comment-content {
margin-left: 24px;
padding: 5px 10px;
border-radius: 4px;
}
.seafile-comment-item .seafile-comment-content a {
color: #212529;
cursor: default;
}
.seafile-comment-item .seafile-comment-content p {
word-break: break-all;
margin: 0;
}
.seafile-comment-item .seafile-comment-content ol,
.seafile-comment-item .seafile-comment-content ul,
.seafile-comment-item .seafile-comment-content li {
margin-left: 10px;
}
.seafile-comment-item .seafile-comment-content table,
.seafile-comment-item .seafile-comment-content th,
.seafile-comment-item .seafile-comment-content td {
border: 1px solid #333;
}
.seafile-comment-footer {
padding: 16px 16px 0px;
margin-bottom: 16px;
border-top: 1px solid #e5e5e5;
display: flex;
flex-direction: column;
}
.seafile-comment-footer .add-comment-input,
.seafile-edit-comment .edit-comment-input {
border: 1px solid #e6e6dd;
padding: 5px;
width: 100%;
min-height: 90px;
border-radius: 5px;
background-color: #fff;
}
.seafile-comment-footer .add-comment-input {
border-bottom: none;
border-radius: 5px 5px 0 0;
}
.seafile-comment-footer .add-comment-input:focus {
outline: none;
}
.seafile-comment-footer .comment-submit-container {
border: 1px solid #e6e6dd;
border-top: none;
border-radius: 0 0 5px 5px;
padding: 0px 5px;
background: #fff;
text-align: right;
position: absolute;
bottom: 10px;
width: 327px;
}
.seafile-comment-footer .comment-submit-container::before {
border-top: 1px solid #e6e6dd;
content: '';
position: absolute;
left: 5px;
right: 5px;
top: 0;
}
.seafile-comment-footer .sdoc-comment-btn {
color: #ff8e03;
cursor: pointer;
}
.seafile-comment-footer .sdoc-comment-btn:hover {
color: #d47604;
}
.seafile-edit-comment .comment-btn {
height: 28px;
line-height: 20px;
}
.seafile-comment-item .comment-footer {
color: #666;
display: flex;
font-size: 14px;
margin-left: 35px;
margin-top: 16px;
}
.seafile-comment-item .comment-footer .comments-count {
align-items: center;
display: flex;
position: relative;
}
.seafile-comment-item .comment-footer .comments-count .comments-count-number {
margin-left: 8px;
margin-top: -2px;
}
.seafile-comment-item .comment-footer .comment-author {
align-items: normal;
margin-left: 20px;
display: flex;
justify-content: space-between;
}
.seafile-comment-item .comment-footer .comment-author__avatar {
height: 16px;
margin-top: -1px;
width: 16px;
}
.seafile-comment-item .comment-footer .comment-author__latest-reply {
margin-left: 8px;
}
.seafile-comment-item .comment-footer .comment-author__latest-reply p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 220px;
}
.seafile-comment-item .comment-footer .comment-author__avatar img {
border-radius: 50%;
}
.comment-participant-item.active,
.comment-participant-item:hover {
cursor: pointer;
}
.comment-participant-item .comment-participant-container {
align-items: center;
display: flex;
padding: 0 8px;
}
.comment-participant-item .comment-participant-avatar {
border-radius: 50%;
height: 16px;
vertical-align: middle;
width: 16px;
}
.comment-participant-item .comment-participant-name {
flex: 1 1;
font-size: 14px;
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -52,6 +52,10 @@ body {
align-items: center;
}
.file-view-header .file-toolbar-btn .sdocfont {
color: #666;
}
.file-view-header .file-toolbar-btn .seafile-multicolor-icon {
fill: #666;
}
@ -145,7 +149,7 @@ body {
@media (min-width: 768px) {
.file-view-body .seafile-comment {
width: 300px;
width: 360px;
border-left: 1px solid #e6e6dd;
}
}

View File

@ -0,0 +1,66 @@
const defaultStyle = {
control: {
backgroundColor: '#fff',
fontSize: 14,
fontWeight: 'normal',
},
highlighter: {
overflow: 'hidden',
},
input: {
margin: 0,
},
'&singleLine': {
control: {
display: 'inline-block',
width: 130,
},
highlighter: {
padding: 1,
border: '2px inset transparent',
},
input: {
padding: 1,
border: '2px inset',
},
},
'&multiLine': {
control: {
},
highlighter: {
padding: 9,
},
input: {
padding: '8px 6px 20px 6px',
minHeight: 90,
height: 90,
border: '1px solid #e6e6dd',
overfflowY: 'auto',
outline: 'none',
},
},
suggestions: {
list: {
backgroundColor: 'white',
border: '1px solid rgba(0,0,0,0.15)',
fontSize: 14,
maxHeight: 200,
overflow: 'auto',
position: 'absolute',
bottom: 14,
width: '150px',
},
item: {
width: 'auto',
padding: '5px 0px',
overflowX: 'auto',
borderBottom: '1px solid rgba(0, 0, 0, 0)',
'&focused': {
backgroundColor: '#f5f5f5',
fontWeight: '400',
},
},
},
};
export { defaultStyle };

View File

@ -1277,47 +1277,62 @@ class SeafileAPI {
return this.req.post(url, form);
}
// file commit api
deleteComment(repoID, commentID) {
const url = this.server + '/api2/repos/' + repoID + '/file/comments/' + commentID + '/';
return this.req.delete(url);
}
listComments(repoID, filePath, resolved) {
const path = encodeURIComponent(filePath);
let url = this.server + '/api2/repos/' + repoID + '/file/comments/?p=' + path;
if (resolved) {
url = url + '&resolved=' + resolved;
}
listComments(repoID, fileUuid) {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/`;
return this.req.get(url);
}
postComment(repoID, filePath, comment, detail) {
const path = encodeURIComponent(filePath);
const url = this.server + '/api2/repos/' + repoID + '/file/comments/?p=' + path;
postComment(repoID, fileUuid, comment) {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/`;
let form = new FormData();
form.append('comment', comment);
if (detail) {
form.append('detail', detail);
}
return this._sendPostRequest(url, form);
}
getCommentsNumber(repoID, path) {
const p = encodeURIComponent(path);
const url = this.server + '/api2/repos/' + repoID + '/file/comments/counts/?p=' + p;
return this.req.get(url);
deleteComment(repoID, fileUuid, commentID) {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/`;
return this.req.delete(url);
}
updateComment(repoID, commentID, resolved, detail, comment) {
const url = this.server + '/api2/repos/' + repoID + '/file/comments/' + commentID + '/';
updateComment(repoID, fileUuid, commentID, resolved, detail, comment) {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/`;
let params = {};
if (resolved) params.resolved = resolved;
if (detail) params.detail = detail;
if (comment) params.comment = comment;
if (resolved) {
params.resolved = resolved;
}
if (detail) {
params.detail = detail;
}
if (comment) {
params.comment = comment;
}
return this.req.put(url, params);
}
listReplies = (repoID, fileUuid, commentID) => {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/`;
return this.req.get(url);
};
insertReply = (repoID, fileUuid, commentID, replyData) => {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/`;
let form = new FormData();
for (let key in replyData) {
form.append(key, replyData[key]);
}
return this._sendPostRequest(url, form);
};
deleteReply = (repoID, fileUuid, commentID, replyId) => {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/${replyId}/`;
return this.req.delete(url);
};
updateReply = (repoID, fileUuid, commentID, replyId, replyData) => {
let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/${replyId}/`;
return this.req.put(url, replyData);
};
// starred
listStarredItems() {
const url = this.server + '/api/v2.1/starred-items/';

View File

@ -0,0 +1,383 @@
# Copyright (c) 2012-2016 Seafile Ltd.
import logging
import json
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from seaserv import seafile_api
from pysearpc import SearpcError
from django.urls import reverse
from django.utils import timezone
from seahub.api2.authentication import TokenAuthentication
from seahub.api2.permissions import IsRepoAccessible
from seahub.api2.throttling import UserRateThrottle
from seahub.api2.utils import api_error, user_to_dict, to_python_boolean
from seahub.avatar.settings import AVATAR_DEFAULT_SIZE
from seahub.base.models import FileComment
from seahub.utils.repo import get_repo_owner
from seahub.signals import comment_file_successful
from seahub.api2.endpoints.utils import generate_links_header_for_paginator
from seahub.views import check_folder_permission
from seahub.seadoc.models import SeadocCommentReply, SeadocNotification
from seahub.file_participants.models import FileParticipant
from seahub.utils.timeutils import utc_to_local, datetime_to_isoformat_timestr, datetime_to_timestamp
logger = logging.getLogger(__name__)
class FileCommentsView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, IsRepoAccessible)
throttle_classes = (UserRateThrottle,)
def get(self, request, repo_id, file_uuid):
"""list comments of a sdoc, same as FileCommentsView
"""
resolved = request.GET.get('resolved', None)
if resolved not in ('true', 'false', None):
error_msg = 'resolved invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
start = None
end = None
page = request.GET.get('page', '')
if page:
try:
page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '25'))
except ValueError:
page = 1
per_page = 25
start = (page - 1) * per_page
end = page * per_page
total_count = FileComment.objects.list_by_file_uuid(file_uuid).count()
comments = []
if resolved is None:
file_comments = FileComment.objects.list_by_file_uuid(file_uuid)[start: end]
else:
comment_resolved = to_python_boolean(resolved)
file_comments = (
FileComment.objects
.list_by_file_uuid(file_uuid)
.filter(resolved=comment_resolved)
[start:end]
)
reply_queryset = SeadocCommentReply.objects.list_by_doc_uuid(file_uuid)
for file_comment in file_comments:
comment = file_comment.to_dict(reply_queryset)
comment.update(user_to_dict(file_comment.author, request=request))
comments.append(comment)
result = {'comments': comments, 'total_count': total_count}
return Response(result)
def post(self, request, repo_id, file_uuid):
comment = request.data.get('comment', '')
detail = request.data.get('detail', '')
author = request.data.get('author', '')
username = request.user.username
if comment is None:
return api_error(status.HTTP_400_BAD_REQUEST, 'comment invalid.')
if not username:
return api_error(status.HTTP_400_BAD_REQUEST, 'author invalid.')
file_comment = FileComment.objects.add_by_file_uuid(
file_uuid, username, comment, detail)
comment = file_comment.to_dict()
comment.update(user_to_dict(username, request=request))
# notification
to_users = set()
participant_queryset = FileParticipant.objects.get_participants(file_uuid)
for participant in participant_queryset:
to_users.add(participant.username)
to_users.discard(username) # remove author
to_users = list(to_users)
detail = {
'author': username,
'comment_id': int(file_comment.id),
'comment': str(file_comment.comment),
'msg_type': 'comment',
'created_at': datetime_to_isoformat_timestr(file_comment.created_at),
'updated_at': datetime_to_isoformat_timestr(file_comment.updated_at),
}
detail.update(user_to_dict(username, request=request))
new_notifications = []
for to_user in to_users:
new_notifications.append(
SeadocNotification(
doc_uuid=file_uuid,
username=to_user,
msg_type='comment',
detail=json.dumps(detail),
))
try:
SeadocNotification.objects.bulk_create(new_notifications)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
#
notification = detail
notification['to_users'] = to_users
comment['notification'] = notification
return Response(comment)
class FileCommentView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, IsRepoAccessible)
throttle_classes = (UserRateThrottle,)
def get(self, request, repo_id, file_uuid, comment_id):
# resource check
try:
file_comment = FileComment.objects.get(pk=comment_id)
except FileComment.DoesNotExist:
return api_error(status.HTTP_400_BAD_REQUEST, 'Wrong comment id')
if str(file_comment.uuid.uuid) != file_uuid:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
comment = file_comment.to_dict()
comment.update(user_to_dict(
file_comment.author, request=request))
return Response(comment)
def delete(self, request, repo_id, file_uuid, comment_id):
# resource check
try:
file_comment = FileComment.objects.get(pk=comment_id)
except FileComment.DoesNotExist:
return api_error(status.HTTP_400_BAD_REQUEST, 'Wrong comment id')
if str(file_comment.uuid.uuid) != file_uuid:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
file_comment.delete()
SeadocCommentReply.objects.filter(comment_id=comment_id).delete()
return Response({'success': True})
def put(self, request, repo_id, file_uuid, comment_id):
# argument check
resolved = request.data.get('resolved')
if resolved not in ('true', 'false', None):
error_msg = 'resolved invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
detail = request.data.get('detail')
comment = request.data.get('comment')
# resource check
try:
file_comment = FileComment.objects.get(pk=comment_id)
except FileComment.DoesNotExist:
error_msg = 'FileComment %s not found.' % comment_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
if str(file_comment.uuid.uuid) != file_uuid:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
if resolved is not None:
# do not refresh updated_at
comment_resolved = to_python_boolean(resolved)
file_comment.resolved = comment_resolved
file_comment.save(update_fields=['resolved'])
if detail is not None or comment is not None:
if detail is not None:
file_comment.detail = detail
if comment is not None:
file_comment.comment = comment
# save
file_comment.updated_at = timezone.now()
file_comment.save()
comment = file_comment.to_dict()
comment.update(user_to_dict(file_comment.author, request=request))
return Response(comment)
class FileCommentRepliesView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, IsRepoAccessible)
throttle_classes = (UserRateThrottle,)
def get(self, request, repo_id, file_uuid, comment_id):
start = None
end = None
page = request.GET.get('page', '')
if page:
try:
page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '25'))
except ValueError:
page = 1
per_page = 25
start = (page - 1) * per_page
end = page * per_page
# resource check
file_comment = FileComment.objects.filter(
id=comment_id, uuid=file_uuid).first()
if not file_comment:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
total_count = SeadocCommentReply.objects.list_by_comment_id(comment_id).count()
replies = []
reply_queryset = SeadocCommentReply.objects.list_by_comment_id(comment_id)[start: end]
for reply in reply_queryset:
data = reply.to_dict()
data.update(
user_to_dict(reply.author, request=request))
replies.append(data)
result = {'replies': replies, 'total_count': total_count}
return Response(result)
def post(self, request, repo_id, file_uuid, comment_id):
"""post a comment reply of a sdoc.
"""
reply_content = request.data.get('reply', '')
type_content = request.data.get('type', 'reply')
author = request.data.get('author', '')
username = request.user.username or author
if reply_content is None:
return api_error(status.HTTP_400_BAD_REQUEST, 'reply invalid.')
if not username:
return api_error(status.HTTP_400_BAD_REQUEST, 'author invalid.')
# resource check
file_comment = FileComment.objects.filter(
id=comment_id, uuid=file_uuid).first()
if not file_comment:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
reply = SeadocCommentReply.objects.create(
author=username,
reply=str(reply_content),
type=str(type_content),
comment_id=comment_id,
doc_uuid=file_uuid,
)
data = reply.to_dict()
data.update(
user_to_dict(reply.author, request=request))
# notification
to_users = set()
participant_queryset = FileParticipant.objects.get_participants(file_uuid)
for participant in participant_queryset:
to_users.add(participant.username)
to_users.discard(username) # remove author
to_users = list(to_users)
detail = {
'author': username,
'comment_id': int(comment_id),
'reply_id': reply.pk,
'reply': str(reply_content),
'msg_type': 'reply',
'created_at': datetime_to_isoformat_timestr(reply.created_at),
'updated_at': datetime_to_isoformat_timestr(reply.updated_at),
'is_resolved': type(reply_content) is bool and reply_content is True,
'resolve_comment': file_comment.comment.strip()
}
detail.update(user_to_dict(username, request=request))
new_notifications = []
for to_user in to_users:
new_notifications.append(
SeadocNotification(
doc_uuid=file_uuid,
username=to_user,
msg_type='reply',
detail=json.dumps(detail),
))
try:
SeadocNotification.objects.bulk_create(new_notifications)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
#
notification = detail
notification['to_users'] = to_users
data['notification'] = notification
return Response(data)
class FileCommentReplyView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, IsRepoAccessible)
throttle_classes = (UserRateThrottle,)
def get(self, request, repo_id, file_uuid, comment_id, reply_id):
"""Get a comment reply
"""
# resource check
file_comment = FileComment.objects.filter(
id=comment_id, uuid=file_uuid).first()
if not file_comment:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
reply = SeadocCommentReply.objects.filter(
id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
if not reply:
return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
data = reply.to_dict()
data.update(
user_to_dict(reply.author, request=request))
return Response(data)
def delete(self, request, repo_id, file_uuid, comment_id, reply_id):
# resource check
file_comment = FileComment.objects.filter(
id=comment_id, uuid=file_uuid).first()
if not file_comment:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
reply = SeadocCommentReply.objects.filter(
id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
if not reply:
return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
reply.delete()
return Response({'success': True})
def put(self, request, repo_id, file_uuid, comment_id, reply_id):
# argument check
reply_content = request.data.get('reply')
if reply_content is None:
return api_error(status.HTTP_400_BAD_REQUEST, 'reply invalid.')
# resource check
file_comment = FileComment.objects.filter(
id=comment_id, uuid=file_uuid).first()
if not file_comment:
return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
reply = SeadocCommentReply.objects.filter(
id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
if not reply:
return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
# save
reply.reply = str(reply_content)
reply.updated_at = timezone.now()
reply.save()
data = reply.to_dict()
data.update(
user_to_dict(reply.author, request=request))
return Response(data)

View File

@ -23,6 +23,7 @@
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/seafile-ui.css?t=20250313" />
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/seahub_react.css?t=20250218" />
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}seafile-editor/seafile-editor-font.css" />
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}sdoc-editor/sdoc-editor-font.css" />
{% block extra_style %}{% endblock %}
{% if branding_css != '' %}<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}{{ branding_css }}" />{% endif %}
{% if enable_branding_css %}<link rel="stylesheet" type="text/css" href="{% url 'custom_css' %}" />{% endif %}

View File

@ -28,6 +28,7 @@ window.app.pageOptions = {
latestContributorName: '{{ latest_contributor|email2nickname|escapejs }}',
lastModificationTime: '{{ last_modified }}',
repoID: '{{ repo.id }}',
fileUuid: '{{ file_uuid }}',
repoName: '{{ repo.name|escapejs }}',
repoEncrypted: {% if repo.encrypted %}true{% else %}false{% endif %},
isRepoAdmin: {% if is_repo_admin %}true{% else %}false{% endif %},

View File

@ -3,6 +3,8 @@ from django.urls import include, path, re_path
from django.views.generic import TemplateView
from seahub.ai.apis import ImageCaption, GenerateSummary, GenerateFileTags, OCR, Translate, WritingAssistant
from seahub.api2.endpoints.file_comments import FileCommentsView, FileCommentView, FileCommentRepliesView, \
FileCommentReplyView
from seahub.api2.endpoints.share_link_auth import ShareLinkUserAuthView, ShareLinkEmailAuthView
from seahub.api2.endpoints.internal_api import InternalUserListView, InternalCheckShareLinkAccess, \
InternalCheckFileOperationAccess, CheckThumbnailAccess, CheckShareLinkThumbnailAccess
@ -480,6 +482,14 @@ urlpatterns = [
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/image-rotate/$', RepoImageRotateView.as_view(), name='api-v2.1-repo-image-rotate-view'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/office-suite/$', OfficeSuiteConfig.as_view(), name='api-v2.1-repo-office-suite'),
## user: repo file comments
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/$', FileCommentsView.as_view(), name='api-v2.1-file-comments'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/$', FileCommentView.as_view(), name='api-v2.1-file-comment'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/replies/$', FileCommentRepliesView.as_view(), name='api-v2.1-file-comment-replies'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/replies/(?P<reply_id>\d+)/$', FileCommentReplyView.as_view(), name='api-v2.1-file-comment-repolies'),
## user:: repo-api-tokens
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/repo-api-tokens/$', RepoAPITokensView.as_view(), name='api-v2.1-repo-api-tokens'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/repo-api-tokens/(?P<app_name>.*)/$', RepoAPITokenView.as_view(), name='api-v2.1-repo-api-token'),

View File

@ -670,6 +670,9 @@ def view_lib_file(request, repo_id, path):
return_dict['fileext'] = fileext
return_dict['filetype'] = filetype
file_uuid = get_seadoc_file_uuid(repo, path)
return_dict['file_uuid'] = file_uuid
# get file raw url
raw_path = ''
inner_path = ''
@ -692,8 +695,6 @@ def view_lib_file(request, repo_id, path):
template = 'common_file_view_react.html'
if filetype == SEADOC:
file_uuid = get_seadoc_file_uuid(repo, path)
return_dict['file_uuid'] = file_uuid
return_dict['assets_url'] = '/api/v2.1/seadoc/download-image/' + file_uuid
return_dict['seadoc_server_url'] = SEADOC_SERVER_URL