mirror of
https://github.com/haiwen/seahub.git
synced 2025-04-28 03:10:45 +00:00
Compare commits
222 Commits
v13.0.0-pr
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
388a28c00e | ||
|
c0e252d53b | ||
|
ea580c6569 | ||
|
281b469b78 | ||
|
f46da268ff | ||
|
5fc9b4877c | ||
|
2006ff57d6 | ||
|
371dd4a057 | ||
|
09ea1ce72d | ||
|
11734fe181 | ||
|
52f71ffae1 | ||
|
66d923ccca | ||
|
3488ea86b0 | ||
|
29fdd70f99 | ||
|
6549f48998 | ||
|
04aec222ce | ||
|
dda7d350be | ||
|
726e34d47c | ||
|
065ecb0b1f | ||
|
0b00f08e3f | ||
|
b43ad4132b | ||
|
2ec7bc4da9 | ||
|
75cde31370 | ||
|
bffdcdfa7e | ||
|
f2952cb6fb | ||
|
f10c51c461 | ||
|
c6afca8eba | ||
|
8e2be63d3f | ||
|
444e690255 | ||
|
0cd14ab806 | ||
|
1a4d7038a9 | ||
|
bfc2812a4f | ||
|
1ba5c44839 | ||
|
45c9411973 | ||
|
36af8862b1 | ||
|
145f2d17db | ||
|
e68a442a3e | ||
|
6874c2a40c | ||
|
52e020e227 | ||
|
d7e4866fc0 | ||
|
7ff4b52005 | ||
|
1cf26c3d2c | ||
|
84e763cddf | ||
|
63f51d6d2a | ||
|
8cc5815107 | ||
|
0b8aa00f4d | ||
|
4c3cc1ae19 | ||
|
920b7fe430 | ||
|
e11dd9e34c | ||
|
f24516e88a | ||
|
38c6ea36ae | ||
|
e20902279e | ||
|
62f31eee77 | ||
|
cb05c2390e | ||
|
de09b014a0 | ||
|
d733bbccc8 | ||
|
8cb5ecf83c | ||
|
b3602c6fa5 | ||
|
d7c3b459d2 | ||
|
d988c3f0cb | ||
|
a524757a92 | ||
|
a3df5ddd0e | ||
|
b398c493c2 | ||
|
759189ae78 | ||
|
ca4aa8b0cb | ||
|
344cd865b2 | ||
|
e157f8675e | ||
|
ef8eb9137f | ||
|
c23a153818 | ||
|
31e0b24e07 | ||
|
da86a8e1b0 | ||
|
1ec93e6f5a | ||
|
6e6f49beed | ||
|
1f77db68e5 | ||
|
9249e4da17 | ||
|
7a7079ed48 | ||
|
39f12f1279 | ||
|
59c719e64f | ||
|
17a4f2c637 | ||
|
19d555880b | ||
|
8d837c8195 | ||
|
8a18ada09a | ||
|
8ebf4e7225 | ||
|
034f1e2a04 | ||
|
c9984a8319 | ||
|
05f5d13c20 | ||
|
6b51e54596 | ||
|
1134469495 | ||
|
466e8f3a40 | ||
|
885b60f566 | ||
|
272010d55f | ||
|
7fb3b29e3a | ||
|
8e36f412da | ||
|
efb1ac8286 | ||
|
c3fab488f6 | ||
|
b599bed239 | ||
|
72f7e68bdc | ||
|
3ec8c646b1 | ||
|
951698555d | ||
|
618f75ab13 | ||
|
2e5b2797b3 | ||
|
2be71dd00b | ||
|
1be01e5186 | ||
|
e67fc4a3d9 | ||
|
cb69c6662e | ||
|
53bedae485 | ||
|
a865aecb6f | ||
|
ef4fbafa04 | ||
|
a1686622b3 | ||
|
cf262f09db | ||
|
ee61bbd7c5 | ||
|
b28e97970f | ||
|
baddc4bad7 | ||
|
641eb1fca5 | ||
|
dfa86ebc45 | ||
|
ee09f7b0f8 | ||
|
538bf10d18 | ||
|
04f948f7e4 | ||
|
b58e48db0c | ||
|
b27bbf6cce | ||
|
09581a961a | ||
|
247a5b06ae | ||
|
6d32b4409b | ||
|
b27198bf02 | ||
|
acd1a4a957 | ||
|
56d4ebc785 | ||
|
894679436c | ||
|
073af84027 | ||
|
cc99ce2e90 | ||
|
e332873d8e | ||
|
75460d1d7c | ||
|
792135a224 | ||
|
f6ede9c8a7 | ||
|
6614fd20a6 | ||
|
efe9ecce29 | ||
|
d58584e0d7 | ||
|
13098287d3 | ||
|
6d9d952079 | ||
|
dd3f25e216 | ||
|
c39c7c1f34 | ||
|
700e933863 | ||
|
71da2e685e | ||
|
4ef9496557 | ||
|
2ed3b81934 | ||
|
8eb1c65ae3 | ||
|
da16173f35 | ||
|
a4835a9d7b | ||
|
beef890a47 | ||
|
7723ef6fb7 | ||
|
5eb303c76b | ||
|
c8026ddb6c | ||
|
872ae595b8 | ||
|
796600eef6 | ||
|
281a81cbd2 | ||
|
ebe1c54153 | ||
|
29c8c12fa8 | ||
|
f7aaa0bff4 | ||
|
f06981267d | ||
|
30036bf83f | ||
|
005ddb4dca | ||
|
0abb343b4b | ||
|
0666b7a303 | ||
|
3ec456fff5 | ||
|
055bd575b9 | ||
|
28fb4f4887 | ||
|
8d4377f85b | ||
|
18c72b9391 | ||
|
e872b4eff8 | ||
|
27c5d0294f | ||
|
5b63ef83f0 | ||
|
db0e17b645 | ||
|
2152dca689 | ||
|
7b60cc38aa | ||
|
213927e1a7 | ||
|
ff7fd0f0d5 | ||
|
ef9be3fed1 | ||
|
7d46c7aaa2 | ||
|
5e456c569a | ||
|
75034bd9f1 | ||
|
8151f7cf1c | ||
|
3c9394ced4 | ||
|
61cfae3d08 | ||
|
1841d799a2 | ||
|
b93e47b606 | ||
|
41a12fa90e | ||
|
d27ea6be9f | ||
|
e9c61f2bec | ||
|
baa144cf80 | ||
|
cacef99651 | ||
|
03e326d56f | ||
|
67e78e76e3 | ||
|
a998903f52 | ||
|
39aac08f3d | ||
|
0aa2d11f36 | ||
|
592354b3cf | ||
|
db5a8b0695 | ||
|
34992f7ee7 | ||
|
7365db5295 | ||
|
ec9e513699 | ||
|
fa79c2b3a3 | ||
|
61426b04d9 | ||
|
9a8b731780 | ||
|
f6f39685a7 | ||
|
a25bb24b05 | ||
|
abf09b4593 | ||
|
618e25d1ab | ||
|
88de887f82 | ||
|
4cc3250c4a | ||
|
742d7eb311 | ||
|
34b63a771d | ||
|
0dda9864d0 | ||
|
0b698bf13c | ||
|
cba13592d0 | ||
|
3f0228e829 | ||
|
d3c0a1c6ec | ||
|
bab0d71302 | ||
|
578ede3e87 | ||
|
634646bb36 | ||
|
d180e6e662 | ||
|
b78e6767e9 | ||
|
770cc7e049 | ||
|
23e04a7431 |
3
.github/workflows/dist.yml
vendored
3
.github/workflows/dist.yml
vendored
@ -10,6 +10,9 @@ env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@ -6,10 +6,20 @@ on:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
REDIS_HOST: localhost
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 3
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
@ -25,6 +35,7 @@ jobs:
|
||||
sudo apt-get install -y libfuse-dev cmake re2c flex sqlite3
|
||||
sudo apt-get install -y libssl-dev libsasl2-dev libldap2-dev libonig-dev
|
||||
sudo apt-get install -y libxml2 libxml2-dev libjwt-dev
|
||||
sudo apt-get install -y libhiredis-dev
|
||||
|
||||
- name: clone and build
|
||||
run: |
|
||||
@ -39,6 +50,10 @@ jobs:
|
||||
pip install -r test-requirements.txt
|
||||
sudo rm -rf /usr/lib/python3/dist-packages/pytz/
|
||||
|
||||
- name: Set REDIS_HOST environment variable
|
||||
run: |
|
||||
echo "REDIS_HOST=localhost" >> $GITHUB_ENV
|
||||
|
||||
- name: run pytest
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
|
@ -22,8 +22,10 @@ const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
|
||||
// );
|
||||
|
||||
// reset by custom
|
||||
const HOST = '127.0.0.1';
|
||||
const PORT = process.env.PORT || '3000';
|
||||
const CONFIG_HOST = process.env.HOST;
|
||||
const isRunInDocker = CONFIG_HOST === '0.0.0.0';
|
||||
const HOST = isRunInDocker ? '127.0.0.1' : CONFIG_HOST;
|
||||
const PORT = process.env.PORT || '3001';
|
||||
const publicPath = process.env.PUBLIC_PATH || '/assets/bundles/';
|
||||
const publicUrlOrPath = `http://${HOST}:${PORT}${publicPath}`;
|
||||
|
||||
|
@ -438,12 +438,17 @@ module.exports = function (webpackEnv) {
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('file-loader'),
|
||||
{ loader: 'svgo-loader',
|
||||
options: {
|
||||
name: 'static/media/[name].[hash].[ext]',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
'removeTitle',
|
||||
'removeStyleElement',
|
||||
'cleanupIDs',
|
||||
'inlineStyles',
|
||||
'removeXMLProcInst',
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
issuer: {
|
||||
and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
|
||||
@ -596,7 +601,16 @@ module.exports = function (webpackEnv) {
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'svg-sprite-loader', options: {}
|
||||
loader: require.resolve('@svgr/webpack'),
|
||||
options: {
|
||||
prettier: false,
|
||||
svgo: false,
|
||||
svgoConfig: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
{ loader: 'svgo-loader', options: {
|
||||
plugins: [
|
||||
@ -627,6 +641,12 @@ module.exports = function (webpackEnv) {
|
||||
// Make sure to add the new loader(s) before the "file" loader.
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.m?js$/,
|
||||
resolve: {
|
||||
fullySpecified: false
|
||||
}
|
||||
}
|
||||
].filter(Boolean),
|
||||
},
|
||||
plugins: [
|
||||
|
@ -2,6 +2,7 @@ const paths = require('./paths');
|
||||
|
||||
const entryFiles = {
|
||||
tldrawEditor: '/tldrawEditor.js',
|
||||
excalidrawEditor: '/excalidraw-editor.js',
|
||||
markdownEditor: '/index.js',
|
||||
plainMarkdownEditor: '/pages/plain-markdown-editor/index.js',
|
||||
TCAccept: '/tc-accept.js',
|
||||
@ -24,6 +25,7 @@ const entryFiles = {
|
||||
sharedFileViewAudio: '/shared-file-view-audio.js',
|
||||
sharedFileViewDocument: '/shared-file-view-document.js',
|
||||
sharedFileViewSpreadsheet: '/shared-file-view-spreadsheet.js',
|
||||
sharedFileViewExdraw: '/shared-file-view-exdraw.js',
|
||||
sharedFileViewSdoc: '/shared-file-view-sdoc.js',
|
||||
sharedFileViewUnknown: '/shared-file-view-unknown.js',
|
||||
historyTrashFileView: '/history-trash-file-view.js',
|
||||
|
5109
frontend/package-lock.json
generated
5109
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,12 +8,13 @@
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@gatsbyjs/reach-router": "2.0.1",
|
||||
"@seafile/react-image-lightbox": "4.0.2",
|
||||
"@seafile/resumablejs": "1.1.16",
|
||||
"@seafile/sdoc-editor": "2.0.28",
|
||||
"@seafile/sdoc-editor": "2.0.54",
|
||||
"@seafile/seafile-calendar": "0.0.28",
|
||||
"@seafile/seafile-editor": "2.0.1",
|
||||
"@seafile/seafile-editor": "2.0.2",
|
||||
"@seafile/stldraw-editor": "1.0.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.19.4",
|
||||
"@uiw/codemirror-themes": "^4.23.5",
|
||||
@ -32,6 +33,7 @@
|
||||
"i18next-xhr-backend": "^3.1.2",
|
||||
"is-hotkey": "0.2.0",
|
||||
"MD5": "^1.3.0",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
"object-assign": "4.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
@ -43,12 +45,12 @@
|
||||
"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",
|
||||
"reactstrap": "9.2.3",
|
||||
"socket.io-client": "^2.2.0",
|
||||
"svg-sprite-loader": "^6.0.11",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svgo-loader": "^3.0.1",
|
||||
"unified": "^7.0.0",
|
||||
"url-parse": "^1.4.3",
|
||||
|
@ -8,6 +8,7 @@ import { Utils, isMobile } from './utils/utils';
|
||||
import SystemNotification from './components/system-notification';
|
||||
import EventBus from './components/common/event-bus';
|
||||
import Header from './components/header';
|
||||
import SystemUserNotification from './components/system-user-notification';
|
||||
import SidePanel from './components/side-panel';
|
||||
import ResizeBar from './components/resize-bar';
|
||||
import {
|
||||
@ -157,19 +158,6 @@ class App extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
onGroupChanged = (groupID) => {
|
||||
setTimeout(function () {
|
||||
let url;
|
||||
if (groupID) {
|
||||
url = siteRoot + 'group/' + groupID + '/';
|
||||
}
|
||||
else {
|
||||
url = siteRoot + 'libraries/';
|
||||
}
|
||||
window.location = url.toString();
|
||||
}, 1);
|
||||
};
|
||||
|
||||
tabItemClick = (tabName, groupID) => {
|
||||
let pathPrefix = [];
|
||||
if (groupID || this.dirViewPanels.indexOf(tabName) > -1) {
|
||||
@ -291,6 +279,7 @@ class App extends Component {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<SystemNotification />
|
||||
<SystemUserNotification />
|
||||
<Header
|
||||
isSidePanelClosed={isSidePanelClosed}
|
||||
onCloseSidePanel={this.onCloseSidePanel}
|
||||
@ -343,7 +332,7 @@ class App extends Component {
|
||||
<InvitationsView path={siteRoot + 'invitations/'} />
|
||||
<FilesActivities path={siteRoot + 'dashboard'} />
|
||||
<MyFileActivities path={siteRoot + 'my-activities'} />
|
||||
<GroupView path={siteRoot + 'group/:groupID'} onGroupChanged={this.onGroupChanged} />
|
||||
<GroupView path={siteRoot + 'group/:groupID'} />
|
||||
<LinkedDevices path={siteRoot + 'linked-devices'} />
|
||||
<ShareAdminLibraries path={siteRoot + 'share-admin-libs'} />
|
||||
<ShareAdminFolders path={siteRoot + 'share-admin-folders'} />
|
||||
|
15
frontend/src/assets/icons/filter-circled.svg
Normal file
15
frontend/src/assets/icons/filter-circled.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#999999;}
|
||||
</style>
|
||||
<title>filter-circled</title>
|
||||
<g id="filter-circled">
|
||||
<path id="形状结合" class="st0" d="M16,1c8.3,0,15,6.7,15,15s-6.7,15-15,15S1,24.3,1,16S7.7,1,16,1z M16,4C9.4,4,4,9.4,4,16
|
||||
s5.4,12,12,12s12-5.4,12-12S22.6,4,16,4z M20,20c0.6,0,1,0.4,1,1s-0.4,1-1,1h-8c-0.6,0-1-0.4-1-1s0.4-1,1-1H20z M22,15
|
||||
c0.6,0,1,0.4,1,1s-0.4,1-1,1H10c-0.6,0-1-0.4-1-1s0.4-1,1-1H22z M24,10c0.6,0,1,0.4,1,1s-0.4,1-1,1H8c-0.6,0-1-0.4-1-1s0.4-1,1-1
|
||||
H24z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 846 B |
@ -21,7 +21,7 @@
|
||||
|
||||
.lds-ripple div {
|
||||
position: absolute;
|
||||
border: 4px solid #eb8205;
|
||||
border: 4px solid #EC8000;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
|
@ -10,4 +10,14 @@ export const EVENT_BUS_TYPE = {
|
||||
|
||||
RESTORE_IMAGE: 'restore_image',
|
||||
OPEN_MARKDOWN: 'open_markdown',
|
||||
|
||||
// migrate tags
|
||||
OPEN_TREE_PANEL: 'open_tree_panel',
|
||||
OPEN_LIBRARY_SETTINGS_TAGS: 'open_library_settings_tags',
|
||||
|
||||
// tags
|
||||
TAG_STATUS: 'tag_status',
|
||||
TAGS_DATA: 'tags_data',
|
||||
SELECT_TAG: 'select_tag',
|
||||
UPDATE_SELECTED_TAG: 'update_selected_tag',
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -27,22 +27,12 @@
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-container .notification-header .notification-close-icon {
|
||||
.notification-container .notification-header .seahub-modal-btn {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
opacity: 0.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-container .notification-header .notification-close-icon:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.notification-container .notification-body {
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Popover } from 'reactstrap';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import SeahubModalCloseIcon from '../seahub-modal-close';
|
||||
|
||||
import './index.css';
|
||||
|
||||
@ -48,9 +49,9 @@ class NotificationPopover extends React.Component {
|
||||
placement="bottom"
|
||||
>
|
||||
<div className="notification-container" ref={ref => this.notificationContainerRef = ref}>
|
||||
<div className="notification-header">
|
||||
<div className="notification-header modal">
|
||||
{headerText}
|
||||
<span className="sf3-font sf3-font-x-01 notification-close-icon" onClick={this.props.onNotificationListToggle}></span>
|
||||
<SeahubModalCloseIcon toggle={this.props.onNotificationListToggle} />
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
<div className="mark-notifications">
|
||||
|
15
frontend/src/components/common/seahub-modal-close.js
Normal file
15
frontend/src/components/common/seahub-modal-close.js
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import '../../css/seahub-modal-header.css';
|
||||
|
||||
const SeahubModalCloseIcon = (props) => {
|
||||
return (
|
||||
<button type="button" className={`close seahub-modal-btn ${props.className ? props.className : ''}`} data-dismiss="modal" aria-label={gettext('Close')} onClick={props.toggle}>
|
||||
<span className="seahub-modal-btn-inner">
|
||||
<i className="sf3-font sf3-font-x-01" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeahubModalCloseIcon;
|
@ -33,7 +33,6 @@ const propTypes = {
|
||||
direntList: PropTypes.array.isRequired,
|
||||
repoTags: PropTypes.array.isRequired,
|
||||
filePermission: PropTypes.string,
|
||||
onFileTagChanged: PropTypes.func.isRequired,
|
||||
onItemMove: PropTypes.func.isRequired,
|
||||
loadDirentList: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -1,23 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, DropdownMenu, DropdownToggle, DropdownItem } from 'reactstrap';
|
||||
import { gettext, enableFileTags } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import TextTranslation from '../../utils/text-translation';
|
||||
import CustomizePopover from '../customize-popover';
|
||||
import ListTagPopover from '../popover/list-tag-popover';
|
||||
import ViewModes from '../../components/view-modes';
|
||||
import SortMenu from '../../components/sort-menu';
|
||||
import MetadataViewToolBar from '../../metadata/components/view-toolbar';
|
||||
import TagsTableSearcher from '../../tag/views/all-tags/tags-table/tags-searcher';
|
||||
import { PRIVATE_FILE_TYPE } from '../../constants';
|
||||
import { ALL_TAGS_ID } from '../../tag/constants';
|
||||
import AllTagsSortSetter from '../../tag/views/all-tags/tags-table/sort-setter';
|
||||
import TagFilesSortSetter from '../../tag/views/tag-files/sort-setter';
|
||||
|
||||
const propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
userPerm: PropTypes.string,
|
||||
currentPath: PropTypes.string.isRequired,
|
||||
updateUsedRepoTags: PropTypes.func.isRequired,
|
||||
onDeleteRepoTag: PropTypes.func.isRequired,
|
||||
currentMode: PropTypes.string.isRequired,
|
||||
switchViewMode: PropTypes.func.isRequired,
|
||||
isCustomPermission: PropTypes.bool,
|
||||
@ -31,75 +26,17 @@ const propTypes = {
|
||||
|
||||
class DirTool extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isRepoTagDialogOpen: false,
|
||||
isDropdownMenuOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggleDropdownMenu = () => {
|
||||
this.setState({
|
||||
isDropdownMenuOpen: !this.state.isDropdownMenuOpen
|
||||
});
|
||||
};
|
||||
|
||||
hidePopover = (e) => {
|
||||
if (e) {
|
||||
let dom = e.target;
|
||||
while (dom) {
|
||||
if (typeof dom.className === 'string' && dom.className.includes('tag-color-popover')) return;
|
||||
dom = dom.parentNode;
|
||||
}
|
||||
}
|
||||
this.setState({ isRepoTagDialogOpen: false });
|
||||
};
|
||||
|
||||
toggleCancel = () => {
|
||||
this.setState({ isRepoTagDialogOpen: false });
|
||||
};
|
||||
|
||||
getMenu = () => {
|
||||
const list = [];
|
||||
const { userPerm, currentPath } = this.props;
|
||||
if (userPerm !== 'rw' || Utils.isMarkdownFile(currentPath)) {
|
||||
return list;
|
||||
}
|
||||
const { TAGS } = TextTranslation;
|
||||
if (enableFileTags) {
|
||||
list.push(TAGS);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
onMenuItemClick = (item) => {
|
||||
const { key } = item;
|
||||
switch (key) {
|
||||
case 'Tags':
|
||||
this.setState({ isRepoTagDialogOpen: !this.state.isRepoTagDialogOpen });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onMenuItemKeyDown = (e, item) => {
|
||||
if (e.key == 'Enter' || e.key == 'Space') {
|
||||
this.onMenuItemClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
onSelectSortOption = (item) => {
|
||||
const [sortBy, sortOrder] = item.value.split('-');
|
||||
this.props.sortItems(sortBy, sortOrder);
|
||||
};
|
||||
|
||||
render() {
|
||||
const menuItems = this.getMenu();
|
||||
const { isDropdownMenuOpen } = this.state;
|
||||
const { repoID, currentMode, currentPath, sortBy, sortOrder, viewId, isCustomPermission, onToggleDetail, onCloseDetail } = this.props;
|
||||
const { currentMode, currentPath, sortBy, sortOrder, viewId, isCustomPermission, onToggleDetail, onCloseDetail } = this.props;
|
||||
const propertiesText = TextTranslation.PROPERTIES.value;
|
||||
const isFileExtended = currentPath.startsWith('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/');
|
||||
const isTagView = currentPath.startsWith('/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/');
|
||||
const isAllTagsView = currentPath.split('/').pop() === ALL_TAGS_ID;
|
||||
|
||||
if (isFileExtended) {
|
||||
return (
|
||||
@ -117,68 +54,22 @@ class DirTool extends React.Component {
|
||||
if (isTagView) {
|
||||
return (
|
||||
<div className="dir-tool">
|
||||
<TagsTableSearcher />
|
||||
{isAllTagsView && <TagsTableSearcher />}
|
||||
{isAllTagsView ? <AllTagsSortSetter /> : <TagFilesSortSetter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="dir-tool d-flex">
|
||||
<ViewModes currentViewMode={currentMode} switchViewMode={this.props.switchViewMode} />
|
||||
<SortMenu sortBy={sortBy} sortOrder={sortOrder} onSelectSortOption={this.onSelectSortOption} />
|
||||
{(!isCustomPermission) &&
|
||||
<div className="cur-view-path-btn" onClick={onToggleDetail}>
|
||||
<span className="sf3-font sf3-font-info" aria-label={propertiesText} title={propertiesText}></span>
|
||||
</div>
|
||||
}
|
||||
{menuItems.length > 0 &&
|
||||
<Dropdown isOpen={isDropdownMenuOpen} toggle={this.toggleDropdownMenu}>
|
||||
<DropdownToggle
|
||||
tag="i"
|
||||
id="cur-folder-more-op-toggle"
|
||||
className='cur-view-path-btn sf3-font-more sf3-font'
|
||||
data-toggle="dropdown"
|
||||
title={gettext('More operations')}
|
||||
aria-label={gettext('More operations')}
|
||||
aria-expanded={isDropdownMenuOpen}
|
||||
>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
{menuItems.map((menuItem, index) => {
|
||||
if (menuItem === 'Divider') {
|
||||
return <DropdownItem key={index} divider />;
|
||||
} else {
|
||||
return (
|
||||
<DropdownItem
|
||||
key={index}
|
||||
onClick={this.onMenuItemClick.bind(this, menuItem)}
|
||||
onKeyDown={this.onMenuItemKeyDown.bind(this, menuItem)}
|
||||
>{menuItem.value}
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
</div>
|
||||
{this.state.isRepoTagDialogOpen &&
|
||||
<CustomizePopover
|
||||
popoverClassName="list-tag-popover"
|
||||
target="cur-folder-more-op-toggle"
|
||||
hidePopover={this.hidePopover}
|
||||
hidePopoverWithEsc={this.hidePopover}
|
||||
boundariesElement={document.body}
|
||||
placement={'bottom-end'}
|
||||
>
|
||||
<ListTagPopover
|
||||
repoID={repoID}
|
||||
onListTagCancel={this.toggleCancel}
|
||||
/>
|
||||
</CustomizePopover>
|
||||
<div className="dir-tool d-flex">
|
||||
<ViewModes currentViewMode={currentMode} switchViewMode={this.props.switchViewMode} />
|
||||
<SortMenu sortBy={sortBy} sortOrder={sortOrder} onSelectSortOption={this.onSelectSortOption} />
|
||||
{(!isCustomPermission) &&
|
||||
<div className="cur-view-path-btn" onClick={onToggleDetail}>
|
||||
<span className="sf3-font sf3-font-info" aria-label={propertiesText} title={propertiesText}></span>
|
||||
</div>
|
||||
}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -89,7 +89,7 @@ Picker.propTypes = {
|
||||
showHourAndMinute: PropTypes.bool.isRequired,
|
||||
disabledDate: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
disabled: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.func,
|
||||
inputWidth: PropTypes.number.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
@ -1,126 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, ModalBody, ModalFooter, Input, Label } from 'reactstrap';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { TAG_COLORS } from '../../constants';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
const propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
onRepoTagCreated: PropTypes.func,
|
||||
toggleCancel: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class CreateTagDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tagName: '',
|
||||
tagColor: TAG_COLORS[0],
|
||||
newTag: {},
|
||||
errorMsg: '',
|
||||
};
|
||||
}
|
||||
|
||||
inputNewName = (e) => {
|
||||
this.setState({
|
||||
tagName: e.target.value,
|
||||
});
|
||||
if (this.state.errorMsg) {
|
||||
this.setState({ errorMsg: '' });
|
||||
}
|
||||
};
|
||||
|
||||
selectTagcolor = (e) => {
|
||||
this.setState({
|
||||
tagColor: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
createTag = () => {
|
||||
let name = this.state.tagName;
|
||||
let color = this.state.tagColor;
|
||||
let repoID = this.props.repoID;
|
||||
seafileAPI.createRepoTag(repoID, name, color).then((res) => {
|
||||
let repoTagID = res.data.repo_tag.repo_tag_id;
|
||||
if (this.props.onRepoTagCreated) this.props.onRepoTagCreated(repoTagID);
|
||||
this.props.toggleCancel();
|
||||
}).catch((error) => {
|
||||
let errMessage;
|
||||
if (error.response.status === 500) {
|
||||
errMessage = gettext('Internal Server Error');
|
||||
} else if (error.response.status === 400) {
|
||||
errMessage = gettext('Tag "{name}" already exists.');
|
||||
errMessage = errMessage.replace('{name}', Utils.HTMLescape(name));
|
||||
}
|
||||
this.setState({ errorMsg: errMessage });
|
||||
});
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.createTag();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let canSave = this.state.tagName.trim() ? true : false;
|
||||
return (
|
||||
<Fragment>
|
||||
<SeahubModalHeader toggle={this.props.onClose}>
|
||||
<span className="tag-dialog-back sf3-font sf3-font-arrow rotate-180 d-inline-block" onClick={this.props.toggleCancel} aria-label={gettext('Back')}></span>
|
||||
{gettext('New Tag')}
|
||||
</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<div role="form" className="tag-create">
|
||||
<div className="form-group">
|
||||
<Label>{gettext('Name')}</Label>
|
||||
<Input
|
||||
name="tag-name"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoFocus={true}
|
||||
value={this.state.tagName}
|
||||
onChange={this.inputNewName}
|
||||
/>
|
||||
<div className="mt-2"><span className="error">{this.state.errorMsg}</span></div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<Label>{gettext('Select a color')}</Label>
|
||||
<div className="d-flex justify-content-between">
|
||||
{TAG_COLORS.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className="tag-color-option" onChange={this.selectTagcolor}>
|
||||
<label className="colorinput">
|
||||
{index === 0 ?
|
||||
<input name="color" type="radio" value={item} className="colorinput-input" defaultChecked onClick={this.selectTagcolor}></input> :
|
||||
<input name="color" type="radio" value={item} className="colorinput-input" onClick={this.selectTagcolor}></input>}
|
||||
<span className="colorinput-color rounded-circle d-flex align-items-center justify-content-center" style={{ backgroundColor: item }}>
|
||||
<i className="sf2-icon-tick color-selected"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.props.toggleCancel}>{gettext('Cancel')}</Button>
|
||||
{canSave ?
|
||||
<Button color="primary" onClick={this.createTag}>{gettext('Save')}</Button> :
|
||||
<Button color="primary" disabled>{gettext('Save')}</Button>
|
||||
}
|
||||
</ModalFooter>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CreateTagDialog.propTypes = propTypes;
|
||||
|
||||
export default CreateTagDialog;
|
@ -9,14 +9,11 @@ import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
class DismissGroupDialog extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
dismissGroup = () => {
|
||||
let that = this;
|
||||
seafileAPI.deleteGroup(this.props.groupID).then((res) => {
|
||||
that.props.onGroupChanged();
|
||||
const { groupID } = this.props;
|
||||
seafileAPI.deleteGroup(groupID).then((res) => {
|
||||
this.props.onGroupDeleted();
|
||||
toaster.success(gettext('Group deleted'));
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
@ -25,13 +22,13 @@ class DismissGroupDialog extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal isOpen={this.props.showDismissGroupDialog} toggle={this.props.toggleDismissGroupDialog}>
|
||||
<SeahubModalHeader>{gettext('Delete Group')}</SeahubModalHeader>
|
||||
<Modal isOpen={true} toggle={this.props.toggleDialog}>
|
||||
<SeahubModalHeader toggle={this.props.toggleDialog}>{gettext('Delete Group')}</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<span>{gettext('Really want to delete this group?')}</span>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.props.toggleDismissGroupDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="secondary" onClick={this.props.toggleDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" onClick={this.dismissGroup}>{gettext('Delete')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@ -40,11 +37,9 @@ class DismissGroupDialog extends React.Component {
|
||||
}
|
||||
|
||||
const DismissGroupDialogPropTypes = {
|
||||
showDismissGroupDialog: PropTypes.bool.isRequired,
|
||||
toggleDismissGroupDialog: PropTypes.func.isRequired,
|
||||
loadGroup: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.string,
|
||||
onGroupChanged: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired,
|
||||
onGroupDeleted: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
DismissGroupDialog.propTypes = DismissGroupDialogPropTypes;
|
||||
|
@ -1,220 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from 'reactstrap';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import CreateTagDialog from './create-tag-dialog';
|
||||
import toaster from '../toast';
|
||||
import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
require('../../css/repo-tag.css');
|
||||
|
||||
const TagItemPropTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
repoTag: PropTypes.object.isRequired,
|
||||
filePath: PropTypes.string.isRequired,
|
||||
fileTagList: PropTypes.array.isRequired,
|
||||
onFileTagChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
class TagItem extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isTagHighlighted: false
|
||||
};
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
this.setState({
|
||||
isTagHighlighted: true
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
isTagHighlighted: false
|
||||
});
|
||||
};
|
||||
|
||||
getRepoTagIdList = () => {
|
||||
let repoTagIdList = [];
|
||||
let fileTagList = this.props.fileTagList || [];
|
||||
repoTagIdList = fileTagList.map((fileTag) => fileTag.repo_tag_id);
|
||||
return repoTagIdList;
|
||||
};
|
||||
|
||||
onEditFileTag = () => {
|
||||
let { repoID, repoTag, filePath } = this.props;
|
||||
let repoTagIdList = this.getRepoTagIdList();
|
||||
if (repoTagIdList.indexOf(repoTag.id) === -1) {
|
||||
let id = repoTag.id;
|
||||
seafileAPI.addFileTag(repoID, filePath, id).then(() => {
|
||||
repoTagIdList = this.getRepoTagIdList();
|
||||
this.props.onFileTagChanged();
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
} else {
|
||||
let fileTag = null;
|
||||
let fileTagList = this.props.fileTagList;
|
||||
for (let i = 0; i < fileTagList.length; i++) {
|
||||
if (fileTagList[i].repo_tag_id === repoTag.id) {
|
||||
fileTag = fileTagList[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
seafileAPI.deleteFileTag(repoID, fileTag.id).then(() => {
|
||||
repoTagIdList = this.getRepoTagIdList();
|
||||
this.props.onFileTagChanged();
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isTagHighlighted } = this.state;
|
||||
const { repoTag } = this.props;
|
||||
const repoTagIdList = this.getRepoTagIdList();
|
||||
const isTagSelected = repoTagIdList.indexOf(repoTag.id) != -1;
|
||||
return (
|
||||
<li
|
||||
className={`tag-list-item cursor-pointer px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
|
||||
onClick={this.onEditFileTag}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="tag-color w-4 h-4 rounded-circle" style={{ backgroundColor: repoTag.color }}></span>
|
||||
<span className="tag-name mx-2">{repoTag.name}</span>
|
||||
</div>
|
||||
{isTagSelected && <i className="sf2-icon-tick tag-selected-icon"></i>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TagItem.propTypes = TagItemPropTypes;
|
||||
|
||||
const TagListPropTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
repoTags: PropTypes.array.isRequired,
|
||||
filePath: PropTypes.string.isRequired,
|
||||
fileTagList: PropTypes.array.isRequired,
|
||||
onFileTagChanged: PropTypes.func.isRequired,
|
||||
toggleCancel: PropTypes.func.isRequired,
|
||||
createNewTag: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
class TagList extends React.Component {
|
||||
|
||||
render() {
|
||||
const { repoTags } = this.props;
|
||||
return (
|
||||
<Fragment>
|
||||
<SeahubModalHeader toggle={this.props.toggleCancel}>{gettext('Select Tags')}</SeahubModalHeader>
|
||||
<ModalBody className="px-0">
|
||||
<ul className="tag-list tag-list-container">
|
||||
{repoTags.map((repoTag) => {
|
||||
return (
|
||||
<TagItem
|
||||
key={repoTag.id}
|
||||
repoTag={repoTag}
|
||||
repoID={this.props.repoID}
|
||||
filePath={this.props.filePath}
|
||||
fileTagList={this.props.fileTagList}
|
||||
onFileTagChanged={this.props.onFileTagChanged}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<a
|
||||
href="#"
|
||||
className="add-tag-link px-4 py-2 d-flex align-items-center"
|
||||
onClick={this.props.createNewTag}
|
||||
>
|
||||
<span className="sf2-icon-plus mr-2"></span>
|
||||
{gettext('Create a new tag')}
|
||||
</a>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={this.props.toggleCancel}>{gettext('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagList.propTypes = TagListPropTypes;
|
||||
|
||||
const propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
repoTags: PropTypes.array.isRequired,
|
||||
filePath: PropTypes.string.isRequired,
|
||||
fileTagList: PropTypes.array.isRequired,
|
||||
toggleCancel: PropTypes.func.isRequired,
|
||||
onFileTagChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
class EditFileTagDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCreateRepoTagShow: false,
|
||||
isListRepoTagShow: true,
|
||||
};
|
||||
}
|
||||
|
||||
createNewTag = () => {
|
||||
this.setState({
|
||||
isCreateRepoTagShow: !this.state.isCreateRepoTagShow,
|
||||
isListRepoTagShow: !this.state.isListRepoTagShow,
|
||||
});
|
||||
};
|
||||
|
||||
onRepoTagCreated = (repoTagID) => {
|
||||
let { repoID, filePath } = this.props;
|
||||
seafileAPI.addFileTag(repoID, filePath, repoTagID).then(() => {
|
||||
this.props.onFileTagChanged();
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.props.toggleCancel} autoFocus={false}>
|
||||
{this.state.isListRepoTagShow &&
|
||||
<TagList
|
||||
repoID={this.props.repoID}
|
||||
repoTags={this.props.repoTags}
|
||||
filePath={this.props.filePath}
|
||||
fileTagList={this.props.fileTagList}
|
||||
onFileTagChanged={this.props.onFileTagChanged}
|
||||
toggleCancel={this.props.toggleCancel}
|
||||
createNewTag={this.createNewTag}
|
||||
/>
|
||||
}
|
||||
{this.state.isCreateRepoTagShow &&
|
||||
<CreateTagDialog
|
||||
repoID={this.props.repoID}
|
||||
onClose={this.props.toggleCancel}
|
||||
toggleCancel={this.createNewTag}
|
||||
onRepoTagCreated={this.onRepoTagCreated}
|
||||
/>
|
||||
}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditFileTagDialog.propTypes = propTypes;
|
||||
|
||||
export default EditFileTagDialog;
|
@ -11,7 +11,7 @@ import { Utils } from '../../utils/utils';
|
||||
import '../../css/group-invite-members-dialog.css';
|
||||
|
||||
const propTypes = {
|
||||
groupID: PropTypes.string.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
toggleInviteMembersDialog: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
@ -9,7 +9,7 @@ import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
import Loading from '../loading';
|
||||
|
||||
const propTypes = {
|
||||
groupID: PropTypes.string.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
@ -88,7 +88,7 @@
|
||||
}
|
||||
|
||||
.lightbox-side-panel .file-details-collapse .file-details-collapse-header .file-details-collapse-header-operation:hover,
|
||||
.lightbox-side-panel .dirent-detail-item .dirent-detail-item-value:hover {
|
||||
.lightbox-side-panel .dirent-detail-item .dirent-detail-item-value.editable:hover {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
|
@ -38,11 +38,11 @@ const ImageDialog = ({ repoID, repoInfo, enableRotate: oldEnableRotate = true, i
|
||||
const nextImg = imageItems[(imageIndex + 1) % imageItemsLength];
|
||||
const prevImg = imageItems[(imageIndex + imageItemsLength - 1) % imageItemsLength];
|
||||
|
||||
// The backend server does not support rotating HEIC images
|
||||
// The backend server does not support rotating HEIC, GIF, SVG images
|
||||
let enableRotate = oldEnableRotate;
|
||||
const urlParts = mainImg.src.split('?')[0].split('.');
|
||||
const suffix = urlParts[urlParts.length - 1];
|
||||
if (suffix === 'heic') {
|
||||
const suffix = urlParts[urlParts.length - 1].toLowerCase();
|
||||
if (suffix === 'heic' || suffix === 'svg' || suffix === 'gif') {
|
||||
enableRotate = false;
|
||||
}
|
||||
|
||||
@ -53,9 +53,6 @@ const ImageDialog = ({ repoID, repoInfo, enableRotate: oldEnableRotate = true, i
|
||||
}
|
||||
|
||||
const renderSidePanel = () => {
|
||||
const dirent = { id, name, type: 'file' };
|
||||
const path = mainImg.parentDir;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lightbox-side-panel"
|
||||
@ -65,9 +62,15 @@ const ImageDialog = ({ repoID, repoInfo, enableRotate: oldEnableRotate = true, i
|
||||
<div className="side-panel-controller" onClick={onToggleSidePanel}>
|
||||
<Icon className="expand-button" symbol={expanded ? 'right_arrow' : 'left_arrow'} />
|
||||
</div>
|
||||
{expanded && (<EmbeddedFileDetails repoID={repoID} repoInfo={repoInfo} path={path} dirent={dirent} />)}
|
||||
{expanded &&
|
||||
<EmbeddedFileDetails
|
||||
repoID={repoID}
|
||||
repoInfo={repoInfo}
|
||||
path={mainImg.parentDir}
|
||||
dirent={{ id, name, type: 'file' }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { gettext, siteRoot, groupImportMembersExtraMsg } from '../../utils/const
|
||||
import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
const propTypes = {
|
||||
toggleImportMembersDialog: PropTypes.func.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired,
|
||||
importMembersInBatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@ -19,7 +19,7 @@ class ImportMembersDialog extends React.Component {
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
this.props.toggleImportMembersDialog();
|
||||
this.props.toggleDialog();
|
||||
};
|
||||
|
||||
openFileInput = () => {
|
||||
@ -49,9 +49,8 @@ class ImportMembersDialog extends React.Component {
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.toggle}>
|
||||
<SeahubModalHeader toggle={this.toggle}>{gettext('Import members from a .xlsx file')}</SeahubModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<p>{groupImportMembersExtraMsg}</p>
|
||||
{groupImportMembersExtraMsg && <p>{groupImportMembersExtraMsg}</p>}
|
||||
<p><a className="text-secondary small" href={`${siteRoot}api/v2.1/group-members-import-example/`}>{gettext('Download an example file')}</a></p>
|
||||
<button className="btn btn-outline-primary" onClick={this.openFileInput}>{gettext('Upload file')}</button>
|
||||
<input className="d-none" type="file" onChange={this.uploadFile} ref={this.fileInputRef} />
|
||||
|
@ -9,13 +9,10 @@ import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
class LeaveGroupDialog extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
leaveGroup = () => {
|
||||
seafileAPI.quitGroup(this.props.groupID, username).then((res) => {
|
||||
this.props.onGroupChanged();
|
||||
const { groupID } = this.props;
|
||||
seafileAPI.quitGroup(groupID, username).then((res) => {
|
||||
this.props.onLeavingGroup();
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
@ -24,13 +21,13 @@ class LeaveGroupDialog extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.props.toggleLeaveGroupDialog}>
|
||||
<SeahubModalHeader toggle={this.props.toggleLeaveGroupDialog}>{gettext('Leave Group')}</SeahubModalHeader>
|
||||
<Modal isOpen={true} toggle={this.props.toggleDialog}>
|
||||
<SeahubModalHeader toggle={this.props.toggleDialog}>{gettext('Leave Group')}</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<p>{gettext('Really want to leave this group?')}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.props.toggleLeaveGroupDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="secondary" onClick={this.props.toggleDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" onClick={this.leaveGroup}>{gettext('Leave')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@ -39,9 +36,9 @@ class LeaveGroupDialog extends React.Component {
|
||||
}
|
||||
|
||||
const LeaveGroupDialogPropTypes = {
|
||||
toggleLeaveGroupDialog: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.string,
|
||||
onGroupChanged: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
onLeavingGroup: PropTypes.func.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
LeaveGroupDialog.propTypes = LeaveGroupDialogPropTypes;
|
||||
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, Form } from 'reactstrap';
|
||||
import { gettext, siteRoot, mediaUrl } from '../../utils/constants';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import SeahubModalCloseIcon from '../../components/common/seahub-modal-close';
|
||||
|
||||
import '../../css/lib-decrypt.css';
|
||||
|
||||
@ -56,7 +57,7 @@ class LibDecryptDialog extends React.Component {
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.toggle}>
|
||||
<ModalBody>
|
||||
<button type="button" className="close" onClick={this.toggle}><span aria-hidden="true">×</span></button>
|
||||
<SeahubModalCloseIcon className="position-absolute top-0 end-0 m-0" toggle={this.toggle} />
|
||||
<Form className="lib-decrypt-form text-center">
|
||||
<img src={`${mediaUrl}img/lock.png`} alt="" aria-hidden="true" />
|
||||
<p className="intro">{gettext('This library is password protected')}</p>
|
||||
|
@ -2,6 +2,7 @@ import React, { Fragment, useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, TabContent, TabPane, Nav, NavItem, NavLink } from 'reactstrap';
|
||||
import { gettext, enableRepoAutoDel } from '../../utils/constants';
|
||||
import { TAB } from '../../constants/repo-setting-tabs';
|
||||
import LibHistorySettingPanel from './lib-settings/lib-history-setting-panel';
|
||||
import LibAutoDelSettingPanel from './lib-settings/lib-old-files-auto-del-setting-panel';
|
||||
import {
|
||||
@ -16,14 +17,7 @@ import { useMetadataStatus } from '../../hooks';
|
||||
|
||||
import '../../css/lib-settings.css';
|
||||
|
||||
const TAB = {
|
||||
HISTORY_SETTING: 'history_setting',
|
||||
AUTO_DEL_SETTING: 'auto_delete_setting',
|
||||
EXTENDED_PROPERTIES_SETTING: 'extended_properties_setting',
|
||||
FACE_RECOGNITION_SETTING: 'face_recognition_setting',
|
||||
TAGS_SETTING: 'tags_setting',
|
||||
OCR_SETTING: 'ocr_setting',
|
||||
};
|
||||
const { enableSeafileAI, enableSeafileOCR } = window.app.config;
|
||||
|
||||
const propTypes = {
|
||||
toggleDialog: PropTypes.func.isRequired,
|
||||
@ -31,7 +25,7 @@ const propTypes = {
|
||||
currentRepoInfo: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
|
||||
const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab, showMigrateTip }) => {
|
||||
const [activeTab, setActiveTab] = useState(tab || TAB.HISTORY_SETTING);
|
||||
|
||||
const toggleTab = useCallback((tab) => {
|
||||
@ -110,20 +104,22 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
|
||||
{gettext('Extended properties')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem
|
||||
role="tab"
|
||||
aria-selected={activeTab === TAB.FACE_RECOGNITION_SETTING}
|
||||
aria-controls="face-recognition-setting-panel"
|
||||
>
|
||||
<NavLink
|
||||
className={activeTab === TAB.FACE_RECOGNITION_SETTING ? 'active' : ''}
|
||||
onClick={toggleTab.bind(this, TAB.FACE_RECOGNITION_SETTING)}
|
||||
tabIndex="0"
|
||||
onKeyDown={onTabKeyDown}
|
||||
{enableSeafileAI &&
|
||||
<NavItem
|
||||
role="tab"
|
||||
aria-selected={activeTab === TAB.FACE_RECOGNITION_SETTING}
|
||||
aria-controls="face-recognition-setting-panel"
|
||||
>
|
||||
{gettext('Face recognition')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavLink
|
||||
className={activeTab === TAB.FACE_RECOGNITION_SETTING ? 'active' : ''}
|
||||
onClick={toggleTab.bind(this, TAB.FACE_RECOGNITION_SETTING)}
|
||||
tabIndex="0"
|
||||
onKeyDown={onTabKeyDown}
|
||||
>
|
||||
{gettext('Face recognition')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
}
|
||||
<NavItem
|
||||
role="tab"
|
||||
aria-selected={activeTab === TAB.TAGS_SETTING}
|
||||
@ -138,20 +134,22 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
|
||||
{gettext('Tags')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem
|
||||
role="tab"
|
||||
aria-selected={activeTab === TAB.OCR_SETTING}
|
||||
aria-controls="ocr-setting-panel"
|
||||
>
|
||||
<NavLink
|
||||
className={activeTab === TAB.OCR_SETTING ? 'active' : ''}
|
||||
onClick={toggleTab.bind(this, TAB.OCR_SETTING)}
|
||||
tabIndex="0"
|
||||
onKeyDown={onTabKeyDown}
|
||||
{enableSeafileAI && enableSeafileOCR &&
|
||||
<NavItem
|
||||
role="tab"
|
||||
aria-selected={activeTab === TAB.OCR_SETTING}
|
||||
aria-controls="ocr-setting-panel"
|
||||
>
|
||||
{gettext('OCR')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavLink
|
||||
className={activeTab === TAB.OCR_SETTING ? 'active' : ''}
|
||||
onClick={toggleTab.bind(this, TAB.OCR_SETTING)}
|
||||
tabIndex="0"
|
||||
onKeyDown={onTabKeyDown}
|
||||
>
|
||||
{gettext('OCR')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</Nav>
|
||||
@ -203,6 +201,7 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
|
||||
submit={updateEnableTags}
|
||||
toggleDialog={toggleDialog}
|
||||
enableMetadata={enableMetadata}
|
||||
showMigrateTip={showMigrateTip}
|
||||
/>
|
||||
</TabPane>
|
||||
)}
|
||||
@ -229,5 +228,3 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
|
||||
LibSettingsDialog.propTypes = propTypes;
|
||||
|
||||
export default LibSettingsDialog;
|
||||
|
||||
export { TAB };
|
||||
|
@ -112,6 +112,8 @@ class LibSubFolderSetUserPermissionDialog extends React.Component {
|
||||
} else {
|
||||
this.permissions = ['r', 'rw', 'cloud-edit', 'preview', 'invisible'];
|
||||
}
|
||||
|
||||
this.userSelect = React.createRef();
|
||||
}
|
||||
|
||||
handleUserSelectChange = (option) => {
|
||||
|
@ -8,7 +8,7 @@ import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
import '../../css/manage-members-dialog.css';
|
||||
|
||||
const propTypes = {
|
||||
groupID: PropTypes.string,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
isOwner: PropTypes.bool.isRequired,
|
||||
toggleManageMembersDialog: PropTypes.func,
|
||||
toggleDepartmentDetailDialog: PropTypes.func,
|
||||
|
@ -9,7 +9,7 @@ import toaster from '../toast';
|
||||
|
||||
const propTypes = {
|
||||
toggle: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
group: PropTypes.object.isRequired,
|
||||
onSetQuota: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ class SetGroupQuotaDialog extends React.Component {
|
||||
if ((quota.length && myReg.test(quota)) || quota == -2) {
|
||||
this.setState({ errMessage: '' });
|
||||
let newQuota = this.state.quota == -2 ? this.state.quota : this.state.quota * 1000000;
|
||||
orgAdminAPI.orgAdminSetGroupQuota(orgID, this.props.groupID, newQuota).then((res) => {
|
||||
orgAdminAPI.orgAdminSetGroupQuota(orgID, this.props.group.id, newQuota).then((res) => {
|
||||
this.props.toggle();
|
||||
this.props.onSetQuota(res.data);
|
||||
}).catch(error => {
|
||||
@ -55,10 +55,15 @@ class SetGroupQuotaDialog extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const group = this.props.group;
|
||||
const oldQuota = Utils.bytesToSize(group.quota);
|
||||
const message = gettext('The current quota for {group_name} is {quota}').replace('{group_name}', group.name).replace('{quota}', oldQuota);
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.props.toggle} autoFocus={false}>
|
||||
<SeahubModalHeader toggle={this.props.toggle}>{gettext('Set Quota')}</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<p>{message}</p>
|
||||
<p>{gettext('Please enter a new quota')}</p>
|
||||
<InputGroup>
|
||||
<Input
|
||||
onKeyDown={this.handleKeyDown}
|
||||
|
@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import { Modal, ModalBody, ModalFooter, Input, Button } from 'reactstrap';
|
||||
import { Modal, ModalBody, ModalFooter, Input, Label, Button } from 'reactstrap';
|
||||
import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
import toaster from '../toast';
|
||||
|
||||
@ -12,7 +12,7 @@ class RenameGroupDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
newGroupName: this.props.currentGroupName,
|
||||
newGroupName: this.props.groupName,
|
||||
isSubmitBtnActive: false,
|
||||
};
|
||||
}
|
||||
@ -30,48 +30,42 @@ class RenameGroupDialog extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
renameGroup = () => {
|
||||
let name = this.state.newGroupName.trim();
|
||||
if (name) {
|
||||
let that = this;
|
||||
seafileAPI.renameGroup(this.props.groupID, name).then((res) => {
|
||||
that.props.loadGroup(this.props.groupID);
|
||||
that.props.onGroupChanged(res.data.id);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
newGroupName: '',
|
||||
handleSubmit = () => {
|
||||
const { groupID } = this.props;
|
||||
const { newGroupName } = this.state;
|
||||
seafileAPI.renameGroup(groupID, newGroupName.trim()).then((res) => {
|
||||
const { name } = res.data;
|
||||
this.props.onGroupNameChanged(name);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
this.props.toggleRenameGroupDialog();
|
||||
this.props.toggleDialog();
|
||||
};
|
||||
|
||||
handleKeyDown = (event) => {
|
||||
if (event.keyCode === 13) {
|
||||
this.renameGroup();
|
||||
this.handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal isOpen={this.props.showRenameGroupDialog} toggle={this.props.toggleRenameGroupDialog}>
|
||||
<SeahubModalHeader>{gettext('Rename Group')}</SeahubModalHeader>
|
||||
<Modal isOpen={true} toggle={this.props.toggleDialog}>
|
||||
<SeahubModalHeader toggle={this.props.toggleDialog}>{gettext('Rename Group')}</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<label htmlFor="newGroupName">{gettext('Rename group to')}</label>
|
||||
<Label for="group-name">{gettext('Rename group to')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="newGroupName"
|
||||
name="new-group-name"
|
||||
id="group-name"
|
||||
value={this.state.newGroupName}
|
||||
onChange={this.handleGroupNameChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.props.toggleRenameGroupDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" onClick={this.renameGroup} disabled={!this.state.isSubmitBtnActive}>{gettext('Submit')}</Button>
|
||||
<Button color="secondary" onClick={this.props.toggleDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" onClick={this.handleSubmit} disabled={!this.state.isSubmitBtnActive}>{gettext('Submit')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
@ -79,12 +73,10 @@ class RenameGroupDialog extends React.Component {
|
||||
}
|
||||
|
||||
const RenameGroupDialogPropTypes = {
|
||||
showRenameGroupDialog: PropTypes.bool.isRequired,
|
||||
toggleRenameGroupDialog: PropTypes.func.isRequired,
|
||||
loadGroup: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.string,
|
||||
onGroupChanged: PropTypes.func.isRequired,
|
||||
currentGroupName: PropTypes.string.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.number,
|
||||
onGroupNameChanged: PropTypes.func.isRequired,
|
||||
groupName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
RenameGroupDialog.propTypes = RenameGroupDialogPropTypes;
|
||||
|
@ -9,7 +9,7 @@ import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
const propTypes = {
|
||||
toggle: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
group: PropTypes.object.isRequired,
|
||||
onSetQuota: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ class SetGroupQuotaDialog extends React.Component {
|
||||
if ((quota.length && numberReg.test(quota)) || quota == -2) {
|
||||
this.setState({ errMessage: '' });
|
||||
let newQuota = this.state.quota == -2 ? this.state.quota : this.state.quota * 1000000;
|
||||
systemAdminAPI.sysAdminUpdateDepartmentQuota(this.props.groupID, newQuota).then((res) => {
|
||||
systemAdminAPI.sysAdminUpdateDepartmentQuota(this.props.group.id, newQuota).then((res) => {
|
||||
this.props.toggle();
|
||||
this.props.onSetQuota(res.data);
|
||||
}).catch(error => {
|
||||
@ -55,10 +55,15 @@ class SetGroupQuotaDialog extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const group = this.props.group;
|
||||
const oldQuota = Utils.bytesToSize(group.quota);
|
||||
const message = gettext('The current quota for {group_name} is {quota}').replace('{group_name}', group.name).replace('{quota}', oldQuota);
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.props.toggle} autoFocus={false}>
|
||||
<SeahubModalHeader toggle={this.props.toggle}>{gettext('Set Quota')}</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<p>{message}</p>
|
||||
<p>{gettext('Please enter a new quota')}</p>
|
||||
<InputGroup>
|
||||
<Input
|
||||
onKeyDown={this.handleKeyDown}
|
||||
|
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Modal, ModalBody, ModalFooter, Form, FormGroup, Input, Label } from 'reactstrap';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import SeahubModalHeader from '@/components/common/seahub-modal-header';
|
||||
|
||||
const propTypes = {
|
||||
toggleDialog: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class SysAdminUserDeactivateDialog extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
keepSharing: true
|
||||
};
|
||||
}
|
||||
|
||||
handleOptionChange = (e) => {
|
||||
this.setState({ keepSharing: e.target.value === 'true' });
|
||||
};
|
||||
|
||||
submit = () => {
|
||||
this.props.onSubmit(this.state.keepSharing);
|
||||
this.props.toggleDialog();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal isOpen={true} toggle={this.props.toggleDialog}>
|
||||
<SeahubModalHeader toggle={this.props.toggleDialog}>
|
||||
{gettext('Set user inactive')}
|
||||
</SeahubModalHeader>
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup tag="fieldset">
|
||||
<p>{gettext('Do you want to keep the sharing relationships?')}</p>
|
||||
<FormGroup check>
|
||||
<Label check>
|
||||
<Input
|
||||
type="radio"
|
||||
name="keepSharing"
|
||||
value="true"
|
||||
checked={this.state.keepSharing === true}
|
||||
onChange={this.handleOptionChange}
|
||||
className="mr-2"
|
||||
/>
|
||||
{gettext('Keep sharing')}
|
||||
</Label>
|
||||
</FormGroup>
|
||||
<FormGroup check>
|
||||
<Label check>
|
||||
<Input
|
||||
type="radio"
|
||||
name="keepSharing"
|
||||
value="false"
|
||||
checked={this.state.keepSharing === false}
|
||||
onChange={this.handleOptionChange}
|
||||
className="mr-2"
|
||||
/>
|
||||
{gettext('Do not keep sharing')}
|
||||
</Label>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.props.toggleDialog}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" onClick={this.submit}>{gettext('Submit')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SysAdminUserDeactivateDialog.propTypes = propTypes;
|
||||
|
||||
export default SysAdminUserDeactivateDialog;
|
@ -1,108 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Popover, PopoverBody } from 'reactstrap';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import { TAG_COLORS } from '../../constants';
|
||||
import toaster from '../toast';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
|
||||
const tagColorPropTypes = {
|
||||
tag: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
class TagColor extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tagColor: this.props.tag.color,
|
||||
isPopoverOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.tag.color !== this.props.tag.color) {
|
||||
this.setState({
|
||||
tagColor: nextProps.tag.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
togglePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: !this.state.isPopoverOpen
|
||||
});
|
||||
};
|
||||
|
||||
selectTagColor = (e) => {
|
||||
const newColor = e.target.value;
|
||||
const { repoID, tag } = this.props;
|
||||
const { id, name } = tag;
|
||||
seafileAPI.updateRepoTag(repoID, id, name, newColor).then(() => {
|
||||
this.setState({
|
||||
tagColor: newColor,
|
||||
isPopoverOpen: !this.state.isPopoverOpen
|
||||
});
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isPopoverOpen, tagColor } = this.state;
|
||||
const { tag } = this.props;
|
||||
const { id, color } = tag;
|
||||
|
||||
let colorList = [...TAG_COLORS];
|
||||
// for color from previous color options
|
||||
if (colorList.indexOf(color) == -1) {
|
||||
colorList.unshift(color);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span
|
||||
id={`tag-${id}-color`}
|
||||
className="tag-color cursor-pointer rounded-circle d-flex align-items-center justify-content-center"
|
||||
style={{ backgroundColor: tagColor }}
|
||||
onClick={this.togglePopover}
|
||||
>
|
||||
<i className="sf3-font sf3-font-down text-white"></i>
|
||||
</span>
|
||||
<Popover
|
||||
target={`tag-${id}-color`}
|
||||
isOpen={isPopoverOpen}
|
||||
placement="bottom"
|
||||
toggle={this.togglePopover}
|
||||
className="tag-color-popover mw-100"
|
||||
>
|
||||
<PopoverBody className="p-2">
|
||||
<div className="d-flex justify-content-between">
|
||||
{colorList.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className="tag-color-option mx-1">
|
||||
<label className="colorinput">
|
||||
<input name="color" type="radio" value={item} className="colorinput-input" defaultChecked={item == tagColor} onClick={this.selectTagColor} />
|
||||
<span className="colorinput-color rounded-circle d-flex align-items-center justify-content-center" style={{ backgroundColor: item }}>
|
||||
<i className="sf2-icon-tick color-selected"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PopoverBody>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagColor.propTypes = tagColorPropTypes;
|
||||
|
||||
export default TagColor;
|
@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import toaster from '../toast';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
|
||||
const tagNamePropTypes = {
|
||||
tag: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
class TagName extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tagName: this.props.tag.name,
|
||||
isEditing: false
|
||||
};
|
||||
this.input = React.createRef();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.tag.name !== this.props.tag.name) {
|
||||
this.setState({
|
||||
tagName: nextProps.tag.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleMode = () => {
|
||||
this.setState({
|
||||
isEditing: !this.state.isEditing
|
||||
}, () => {
|
||||
if (this.state.isEditing) {
|
||||
this.input.current.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updateTagName = (e) => {
|
||||
const newName = e.target.value;
|
||||
const { repoID, tag } = this.props;
|
||||
const { id, color } = tag;
|
||||
seafileAPI.updateRepoTag(repoID, id, newName, color).then(() => {
|
||||
this.setState({
|
||||
tagName: newName
|
||||
});
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
onInputKeyDown = (e) => {
|
||||
if (e.key == 'Enter') {
|
||||
this.toggleMode();
|
||||
this.updateTagName(e);
|
||||
}
|
||||
else if (e.key == 'Escape') {
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
this.toggleMode();
|
||||
}
|
||||
};
|
||||
|
||||
onInputBlur = (e) => {
|
||||
this.toggleMode();
|
||||
this.updateTagName(e);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isEditing, tagName } = this.state;
|
||||
return (
|
||||
<div className="mx-2 flex-fill d-flex">
|
||||
{isEditing ?
|
||||
<input
|
||||
type="text"
|
||||
ref={this.input}
|
||||
defaultValue={tagName}
|
||||
onBlur={this.onInputBlur}
|
||||
onKeyDown={this.onInputKeyDown}
|
||||
className="flex-fill form-control-sm form-control"
|
||||
/> :
|
||||
<span
|
||||
onClick={this.toggleMode}
|
||||
className="cursor-pointer flex-fill"
|
||||
>{tagName}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagName.propTypes = tagNamePropTypes;
|
||||
|
||||
export default TagName;
|
@ -11,9 +11,9 @@ import toaster from '../toast';
|
||||
import '../../css/transfer-group-dialog.css';
|
||||
|
||||
const propTypes = {
|
||||
groupID: PropTypes.string,
|
||||
toggleTransferGroupDialog: PropTypes.func.isRequired,
|
||||
onGroupChanged: PropTypes.func.isRequired
|
||||
groupID: PropTypes.number.isRequired,
|
||||
onGroupTransfered: PropTypes.func.isRequired,
|
||||
toggleDialog: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class TransferGroupDialog extends React.Component {
|
||||
@ -21,19 +21,14 @@ class TransferGroupDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedOption: null,
|
||||
errMessage: '',
|
||||
selectedOption: null
|
||||
};
|
||||
this.options = [];
|
||||
this.userSelect = React.createRef();
|
||||
}
|
||||
|
||||
handleSelectChange = (option) => {
|
||||
this.setState({
|
||||
selectedOption: option,
|
||||
errMessage: '',
|
||||
selectedOption: option
|
||||
});
|
||||
this.options = [];
|
||||
};
|
||||
|
||||
transferGroup = () => {
|
||||
@ -42,19 +37,21 @@ class TransferGroupDialog extends React.Component {
|
||||
if (selectedOption && selectedOption[0]) {
|
||||
email = selectedOption[0].email;
|
||||
}
|
||||
if (email) {
|
||||
seafileAPI.transferGroup(this.props.groupID, email).then((res) => {
|
||||
this.props.toggleTransferGroupDialog();
|
||||
toaster.success(gettext('Group has been transfered'));
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
this.setState({ errMessage: errMessage });
|
||||
});
|
||||
if (!email) {
|
||||
return false;
|
||||
}
|
||||
seafileAPI.transferGroup(this.props.groupID, email).then((res) => {
|
||||
toaster.success(gettext('Group has been transfered'));
|
||||
this.props.onGroupTransfered(res.data);
|
||||
this.props.toggleDialog();
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
this.props.toggleTransferGroupDialog();
|
||||
this.props.toggleDialog();
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -69,7 +66,6 @@ class TransferGroupDialog extends React.Component {
|
||||
placeholder={gettext('Please enter 1 or more character')}
|
||||
onSelectChange={this.handleSelectChange}
|
||||
/>
|
||||
<div className="error">{this.state.errMessage}</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={this.toggle}>{gettext('Close')}</Button>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
@ -6,16 +6,34 @@ import TreeSection from '../../tree-section';
|
||||
import TrashDialog from '../../dialog/trash-dialog';
|
||||
import LibSettingsDialog from '../../dialog/lib-settings';
|
||||
import RepoHistoryDialog from '../../dialog/repo-history';
|
||||
import { eventBus } from '../../common/event-bus';
|
||||
import { EVENT_BUS_TYPE } from '../../common/event-bus-type';
|
||||
import { TAB } from '../../../constants/repo-setting-tabs';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const DirOthers = ({ userPerm, repoID, currentRepoInfo }) => {
|
||||
|
||||
const showSettings = currentRepoInfo.is_admin; // repo owner, department admin, shared with 'Admin' permission
|
||||
let [isSettingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
let [activeTab, setActiveTab] = useState(TAB.HISTORY_SETTING);
|
||||
let [showMigrateTip, setShowMigrateTip] = useState(false);
|
||||
|
||||
const toggleSettingsDialog = () => {
|
||||
setSettingsDialogOpen(!isSettingsDialogOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeUnselectFiles = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_LIBRARY_SETTINGS_TAGS, () => {
|
||||
setSettingsDialogOpen(true);
|
||||
setActiveTab(TAB.TAGS_SETTING);
|
||||
setShowMigrateTip(true);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeUnselectFiles();
|
||||
};
|
||||
});
|
||||
|
||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
||||
const toggleTrashDialog = () => {
|
||||
setShowTrashDialog(!showTrashDialog);
|
||||
@ -59,6 +77,8 @@ const DirOthers = ({ userPerm, repoID, currentRepoInfo }) => {
|
||||
repoID={repoID}
|
||||
currentRepoInfo={currentRepoInfo}
|
||||
toggleDialog={toggleSettingsDialog}
|
||||
tab={activeTab}
|
||||
showMigrateTip={showMigrateTip}
|
||||
/>
|
||||
)}
|
||||
{isRepoHistoryDialogOpen && (
|
||||
|
@ -1,12 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import TreeSection from '../../tree-section';
|
||||
import { useMetadataStatus } from '../../../hooks';
|
||||
import { TagsTreeView } from '../../../tag';
|
||||
import { useTags } from '../../../tag/hooks';
|
||||
import EditTagDialog from '../../../tag/components/dialog/edit-tag-dialog';
|
||||
|
||||
const DirTags = ({ userPerm, repoID, currentPath, currentRepoInfo }) => {
|
||||
const [isShowEditTagDialog, setIsShowEditTagDialog] = useState(false);
|
||||
|
||||
const { enableMetadata, enableTags } = useMetadataStatus();
|
||||
const { isLoading, tagsData, addTag } = useTags();
|
||||
|
||||
const enableMetadataManagement = useMemo(() => {
|
||||
if (currentRepoInfo.encrypted) return false;
|
||||
@ -14,15 +19,51 @@ const DirTags = ({ userPerm, repoID, currentPath, currentRepoInfo }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]);
|
||||
|
||||
const { enableMetadata, enableTags } = useMetadataStatus();
|
||||
const { isLoading } = useTags();
|
||||
const tags = useMemo(() => {
|
||||
if (!tagsData) return [];
|
||||
return tagsData.rows;
|
||||
}, [tagsData]);
|
||||
|
||||
const createTag = useCallback((tag, callback) => {
|
||||
addTag(tag, callback);
|
||||
}, [addTag]);
|
||||
|
||||
const openAddTag = useCallback(() => {
|
||||
setIsShowEditTagDialog(true);
|
||||
}, []);
|
||||
|
||||
const closeAddTag = useCallback(() => {
|
||||
setIsShowEditTagDialog(false);
|
||||
}, []);
|
||||
|
||||
const renderTreeSectionHeaderOperations = (menuProps) => {
|
||||
const canAdd = userPerm === 'rw' || userPerm === 'admin';
|
||||
|
||||
let operations = [];
|
||||
if (enableTags && canAdd) {
|
||||
operations.push(
|
||||
<span key="tree-section-create-operation" role="button" className="tree-section-header-operation tree-section-create-operation" onClick={openAddTag}>
|
||||
<i className="sf3-font sf3-font-new"></i>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return operations;
|
||||
};
|
||||
|
||||
if (!enableMetadataManagement) return null;
|
||||
if (!enableMetadata || !enableTags) return null;
|
||||
|
||||
return (
|
||||
<TreeSection repoID={repoID} title={gettext('Tags')} stateStorageKey="tags">
|
||||
<TreeSection
|
||||
repoID={repoID}
|
||||
title={gettext('Tags')}
|
||||
stateStorageKey="tags"
|
||||
renderHeaderOperations={renderTreeSectionHeaderOperations}
|
||||
>
|
||||
{!isLoading && (<TagsTreeView userPerm={userPerm} repoID={repoID} currentPath={currentPath} />)}
|
||||
{isShowEditTagDialog && (
|
||||
<EditTagDialog tags={tags} title={gettext('New tag')} onToggle={closeAddTag} onSubmit={createTag} />
|
||||
)}
|
||||
</TreeSection>
|
||||
);
|
||||
};
|
||||
|
@ -2,11 +2,12 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TreeSection from '../../tree-section';
|
||||
import ExtensionPrompts from '../extension-prompts';
|
||||
import LibSettingsDialog, { TAB } from '../../dialog/lib-settings';
|
||||
import LibSettingsDialog from '../../dialog/lib-settings';
|
||||
import ViewsMoreOperations from './views-more-operations';
|
||||
import { MetadataTreeView, useMetadata } from '../../../metadata';
|
||||
import { useMetadataStatus } from '../../../hooks';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { TAB } from '../../../constants/repo-setting-tabs';
|
||||
|
||||
import './index.css';
|
||||
|
||||
|
@ -39,10 +39,10 @@
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.dirent-detail-item .dirent-detail-item-value:hover {
|
||||
.dirent-detail-item .dirent-detail-item-value.editable:hover {
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dirent-detail-item .dirent-detail-item-value.editable:hover {
|
||||
|
@ -8,7 +8,9 @@ import './index.css';
|
||||
|
||||
const DetailItem = ({ readonly = true, field, className, children }) => {
|
||||
const icon = useMemo(() => {
|
||||
if (field.type === 'size') return COLUMNS_ICON_CONFIG[CellType.NUMBER];
|
||||
if (field.type === 'size') {
|
||||
return COLUMNS_ICON_CONFIG[CellType.NUMBER];
|
||||
}
|
||||
return COLUMNS_ICON_CONFIG[field.type];
|
||||
}, [field]);
|
||||
|
||||
|
@ -3,11 +3,8 @@ import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Icon from '../icon';
|
||||
import { gettext, enableFileTags } from '../../utils/constants';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import EditFileTagPopover from '../popover/edit-filetag-popover';
|
||||
import FileTagList from '../file-tag-list';
|
||||
import ExtraMetadataAttributesDialog from '../dialog/extra-metadata-attributes-dialog';
|
||||
|
||||
const propTypes = {
|
||||
@ -18,7 +15,6 @@ const propTypes = {
|
||||
direntType: PropTypes.string.isRequired,
|
||||
direntDetail: PropTypes.object.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
fileTagList: PropTypes.array.isRequired,
|
||||
onFileTagChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@ -29,7 +25,6 @@ class DetailListView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isEditFileTagShow: false,
|
||||
isShowMetadataExtraProperties: false,
|
||||
};
|
||||
this.tagListTitleID = `detail-list-view-tags-${uuidv4()}`;
|
||||
@ -55,12 +50,6 @@ class DetailListView extends React.Component {
|
||||
return Utils.joinPath(path, dirent.name);
|
||||
};
|
||||
|
||||
onEditFileTagToggle = () => {
|
||||
this.setState({
|
||||
isEditFileTagShow: !this.state.isEditFileTagShow
|
||||
});
|
||||
};
|
||||
|
||||
onFileTagChanged = () => {
|
||||
let direntPath = this.getDirentPath();
|
||||
this.props.onFileTagChanged(this.props.dirent, direntPath);
|
||||
@ -70,8 +59,8 @@ class DetailListView extends React.Component {
|
||||
this.setState({ isShowMetadataExtraProperties: !this.state.isShowMetadataExtraProperties });
|
||||
};
|
||||
|
||||
renderTags = () => {
|
||||
const { direntType, direntDetail, fileTagList = [] } = this.props;
|
||||
renderInfos = () => {
|
||||
const { direntType, direntDetail } = this.props;
|
||||
const position = this.getFileParent();
|
||||
if (direntType === 'dir') {
|
||||
return (
|
||||
@ -106,15 +95,6 @@ class DetailListView extends React.Component {
|
||||
<tr><th>{gettext('Size')}</th><td>{Utils.bytesToSize(direntDetail.size)}</td></tr>
|
||||
<tr><th>{gettext('Location')}</th><td>{position}</td></tr>
|
||||
<tr><th>{gettext('Last Update')}</th><td>{dayjs(direntDetail.last_modified).fromNow()}</td></tr>
|
||||
<tr className="file-tag-container">
|
||||
<th>{gettext('Tags')}</th>
|
||||
<td>
|
||||
<FileTagList fileTagList={fileTagList} />
|
||||
{enableFileTags &&
|
||||
<span onClick={this.onEditFileTagToggle} id={this.tagListTitleID}><Icon symbol='tag' /></span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
{direntDetail.permission === 'rw' && window.app.pageOptions.enableMetadataManagement && (
|
||||
<tr className="file-extra-attributes">
|
||||
<th colSpan={2}>
|
||||
@ -130,24 +110,11 @@ class DetailListView extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { direntType, direntDetail, fileTagList = [] } = this.props;
|
||||
const { direntType, direntDetail } = this.props;
|
||||
const direntPath = this.getDirentPath();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{this.renderTags()}
|
||||
{this.state.isEditFileTagShow &&
|
||||
<EditFileTagPopover
|
||||
repoID={this.props.repoID}
|
||||
repoTags={this.props.repoTags}
|
||||
filePath={direntPath}
|
||||
fileTagList={fileTagList}
|
||||
toggleCancel={this.onEditFileTagToggle}
|
||||
onFileTagChanged={this.onFileTagChanged}
|
||||
target={this.tagListTitleID}
|
||||
isEditFileTagShow={this.state.isEditFileTagShow}
|
||||
/>
|
||||
}
|
||||
{this.renderInfos()}
|
||||
{this.state.isShowMetadataExtraProperties && (
|
||||
<ExtraMetadataAttributesDialog
|
||||
repoID={this.props.repoID}
|
||||
|
@ -25,7 +25,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-header .detail-control .detail-control-close {
|
||||
.detail-header .detail-control .detail-control-icon {
|
||||
font-size: 16px;
|
||||
fill: #666;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ const Header = ({ title, icon, iconSize = 32, onClose, children, component = {}
|
||||
{children}
|
||||
{onClose && (
|
||||
<div className="detail-control" onClick={onClose}>
|
||||
{closeIcon ? closeIcon : <Icon symbol="close" className="detail-control-close" />}
|
||||
{closeIcon ? closeIcon : <Icon symbol="close" className="detail-control-icon" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -6,6 +6,8 @@ import { CellType } from '../../../metadata/constants';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { MetadataDetails } from '../../../metadata';
|
||||
import { useMetadataStatus } from '../../../hooks';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import { SYSTEM_FOLDERS } from '../../../constants';
|
||||
|
||||
const DirDetails = ({ direntDetail }) => {
|
||||
const { enableMetadata, enableMetadataManagement } = useMetadataStatus();
|
||||
@ -13,14 +15,36 @@ const DirDetails = ({ direntDetail }) => {
|
||||
return { type: CellType.MTIME, name: gettext('Last modified time') };
|
||||
}, []);
|
||||
|
||||
const sizeField = useMemo(() => ({ type: 'size', name: gettext('Size') }), []);
|
||||
const filesField = useMemo(() => ({ type: CellType.NUMBER, name: gettext('Files') }), []);
|
||||
let file_count = direntDetail.file_count || 0;
|
||||
let size = Utils.bytesToSize(direntDetail.size);
|
||||
let special_folder = false;
|
||||
if (direntDetail.path !== undefined) {
|
||||
const path = direntDetail.path;
|
||||
special_folder = SYSTEM_FOLDERS.some(prefix => path === prefix || path.startsWith(prefix + '/'));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableMetadataManagement && enableMetadata && (
|
||||
<>
|
||||
<DetailItem field={filesField} value={file_count} className="sf-metadata-property-detail-formatter">
|
||||
{special_folder ?
|
||||
<Formatter field={CellType.TEXT} value={'--'} /> :
|
||||
<Formatter field={filesField} value={file_count} />}
|
||||
</DetailItem>
|
||||
<DetailItem field={sizeField} value={size} className="sf-metadata-property-detail-formatter">
|
||||
{special_folder ?
|
||||
<Formatter field={CellType.TEXT} value={'--'} /> :
|
||||
<Formatter field={sizeField} value={size} />}
|
||||
</DetailItem>
|
||||
<MetadataDetails />
|
||||
</>
|
||||
)}
|
||||
<DetailItem field={lastModifiedTimeField} className="sf-metadata-property-detail-formatter">
|
||||
<Formatter field={lastModifiedTimeField} value={direntDetail.mtime} />
|
||||
</DetailItem>
|
||||
{enableMetadataManagement && enableMetadata && (
|
||||
<MetadataDetails />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -92,12 +92,17 @@ const FileDetails = React.memo(({ repoID, dirent, path, direntDetail, isShowRepo
|
||||
</DetailItem>
|
||||
{isShowRepoTags && window.app.pageOptions.enableFileTags && !enableMetadata && (
|
||||
<DetailItem field={tagsField} className="sf-metadata-property-detail-formatter">
|
||||
<FileTag repoID={repoID} dirent={dirent} path={path} repoTags={repoTags} fileTagList={fileTagList} onFileTagChanged={onFileTagChanged} />
|
||||
<FileTag
|
||||
repoID={repoID}
|
||||
dirent={dirent}
|
||||
path={path}
|
||||
repoTags={repoTags}
|
||||
fileTagList={fileTagList}
|
||||
onFileTagChanged={onFileTagChanged}
|
||||
/>
|
||||
</DetailItem>
|
||||
)}
|
||||
{enableMetadata && (
|
||||
<MetadataDetails />
|
||||
)}
|
||||
{enableMetadata && <MetadataDetails />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
@ -9,10 +9,13 @@ import DirDetails from './dir-details';
|
||||
import FileDetails from './file-details';
|
||||
import ObjectUtils from '../../../utils/object';
|
||||
import { MetadataDetailsProvider } from '../../../metadata/hooks';
|
||||
import { Settings, AI } from '../../../metadata/components/metadata-details';
|
||||
import AIIcon from '../../../metadata/components/metadata-details/ai-icon';
|
||||
import SettingsIcon from '../../../metadata/components/metadata-details/settings-icon';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const { enableSeafileAI } = window.app.config;
|
||||
|
||||
class DirentDetails extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
@ -94,8 +97,8 @@ class DirentDetails extends React.Component {
|
||||
>
|
||||
<Detail>
|
||||
<Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={this.props.onClose} >
|
||||
<AI />
|
||||
<Settings />
|
||||
{enableSeafileAI && <AIIcon />}
|
||||
<SettingsIcon />
|
||||
</Header>
|
||||
<Body>
|
||||
{this.renderImage()}
|
||||
|
@ -8,10 +8,13 @@ import { Header, Body } from '../detail';
|
||||
import FileDetails from '../dirent-details/file-details';
|
||||
import { MetadataContext } from '../../../metadata';
|
||||
import { MetadataDetailsProvider } from '../../../metadata/hooks';
|
||||
import { AI, Settings } from '../../../metadata/components/metadata-details';
|
||||
import AIIcon from '../../../metadata/components/metadata-details/ai-icon';
|
||||
import SettingsIcon from '../../../metadata/components/metadata-details/settings-icon';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const { enableSeafileAI } = window.app.config;
|
||||
|
||||
const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width = 300, className, component = {} }) => {
|
||||
const { headerComponent } = component;
|
||||
const [direntDetail, setDirentDetail] = useState('');
|
||||
@ -21,11 +24,6 @@ const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width =
|
||||
return urlParams.has('view');
|
||||
}, []);
|
||||
|
||||
const isTag = useMemo(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.has('tag');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fullPath = path.split('/').pop() === dirent?.name ? path : Utils.joinPath(path, dirent?.name || '');
|
||||
seafileAPI.getFileInfo(repoID, fullPath).then(res => {
|
||||
@ -37,7 +35,7 @@ const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width =
|
||||
}, [repoID, path, dirent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isView || isTag) return;
|
||||
if (isView) return;
|
||||
|
||||
let isExistContext = true;
|
||||
if (!window.sfMetadataContext) {
|
||||
@ -75,8 +73,8 @@ const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width =
|
||||
<Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={onClose} component={headerComponent}>
|
||||
{onClose && (
|
||||
<>
|
||||
<AI />
|
||||
<Settings />
|
||||
{enableSeafileAI && <AIIcon />}
|
||||
<SettingsIcon />
|
||||
</>
|
||||
)}
|
||||
</Header>
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import MD5 from 'MD5';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { gettext, siteRoot, mediaUrl, enableVideoThumbnail, enablePDFThumbnail } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import { imageThumbnailCenter, videoThumbnailCenter } from '../../utils/thumbnail-center';
|
||||
@ -74,6 +72,23 @@ class DirentGridItem extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.dirent !== this.props.dirent) {
|
||||
this.setState({ dirent: this.props.dirent });
|
||||
}
|
||||
|
||||
const { repoID, path } = this.props;
|
||||
const { dirent } = this.state;
|
||||
if (this.checkGenerateThumbnail(dirent) && !this.isGeneratingThumbnail) {
|
||||
this.isGeneratingThumbnail = true;
|
||||
this.thumbnailCenter.createThumbnail({
|
||||
repoID,
|
||||
path: [path, dirent.name].join('/'),
|
||||
callback: this.updateDirentThumbnail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.clickTimeout) {
|
||||
clearTimeout(this.clickTimeout);
|
||||
@ -297,13 +312,7 @@ class DirentGridItem extends React.Component {
|
||||
|
||||
render() {
|
||||
let { dirent, isGridDropTipShow } = this.state;
|
||||
let { is_freezed, is_locked, lock_owner_name, file_tags, isSelected } = dirent;
|
||||
let toolTipID = '';
|
||||
let tagTitle = '';
|
||||
if (file_tags && file_tags.length > 0) {
|
||||
toolTipID = MD5(dirent.name).slice(0, 7);
|
||||
tagTitle = file_tags.map(item => item.name).join(' ');
|
||||
}
|
||||
let { is_freezed, is_locked, lock_owner_name, isSelected } = dirent;
|
||||
const showName = this.getRenderedText(dirent);
|
||||
return (
|
||||
<>
|
||||
@ -341,21 +350,6 @@ class DirentGridItem extends React.Component {
|
||||
}
|
||||
</div>
|
||||
<div className="grid-file-name" onDragStart={this.onGridItemDragStart} draggable={this.canDrag} >
|
||||
{(dirent.type !== 'dir' && file_tags && file_tags.length > 0) && (
|
||||
<>
|
||||
<div id={`tag-list-title-${toolTipID}`} className="dirent-item tag-list tag-list-stacked d-inline-block align-middle">
|
||||
{file_tags.map((fileTag, index) => {
|
||||
let length = file_tags.length;
|
||||
return (
|
||||
<span className="file-tag" key={fileTag.id} style={{ zIndex: length - index, backgroundColor: fileTag.color }}></span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<UncontrolledTooltip target={`tag-list-title-${toolTipID}`} placement="bottom">
|
||||
{tagTitle}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
{(!dirent.isDir() && !this.canPreview) ?
|
||||
<a
|
||||
className="sf-link grid-file-name-link"
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { siteRoot, username, enableSeadoc, thumbnailDefaultSize, thumbnailSizeForOriginal, gettext, fileServerRoot, enableWhiteboard, useGoFileserver } from '../../utils/constants';
|
||||
import { siteRoot, username, enableSeadoc, thumbnailDefaultSize, thumbnailSizeForOriginal, gettext, fileServerRoot, enableWhiteboard, useGoFileserver, enableExcalidraw } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import URLDecorator from '../../utils/url-decorator';
|
||||
@ -15,7 +15,6 @@ import MoveDirentDialog from '../dialog/move-dirent-dialog';
|
||||
import CopyDirentDialog from '../dialog/copy-dirent-dialog';
|
||||
import ShareDialog from '../dialog/share-dialog';
|
||||
import ZipDownloadDialog from '../dialog/zip-download-dialog';
|
||||
import EditFileTagDialog from '../dialog/edit-filetag-dialog';
|
||||
import Rename from '../../components/dialog/rename-dirent';
|
||||
import CreateFile from '../dialog/create-file-dialog';
|
||||
import CreateFolder from '../dialog/create-folder-dialog';
|
||||
@ -53,7 +52,6 @@ const propTypes = {
|
||||
updateDirent: PropTypes.func.isRequired,
|
||||
onGridItemClick: PropTypes.func,
|
||||
repoTags: PropTypes.array.isRequired,
|
||||
onFileTagChanged: PropTypes.func,
|
||||
onAddFolder: PropTypes.func.isRequired,
|
||||
showDirentDetail: PropTypes.func.isRequired,
|
||||
onItemRename: PropTypes.func.isRequired,
|
||||
@ -80,7 +78,6 @@ class DirentGridView extends React.Component {
|
||||
isShareDialogShow: false,
|
||||
isMoveDialogShow: false,
|
||||
isCopyDialogShow: false,
|
||||
isEditFileTagShow: false,
|
||||
isZipDialogOpen: false,
|
||||
isRenameDialogShow: false,
|
||||
isCreateFolderDialogShow: false,
|
||||
@ -355,6 +352,12 @@ class DirentGridView extends React.Component {
|
||||
case 'Copy':
|
||||
this.onItemCopyToggle();
|
||||
break;
|
||||
case 'Star':
|
||||
this.onToggleStarItem();
|
||||
break;
|
||||
case 'Unstar':
|
||||
this.onToggleStarItem();
|
||||
break;
|
||||
case 'Unfreeze Document':
|
||||
this.onUnlockItem(currentObject);
|
||||
break;
|
||||
@ -376,9 +379,6 @@ class DirentGridView extends React.Component {
|
||||
case 'Convert to sdoc':
|
||||
this.onItemConvert(currentObject, event, 'sdoc');
|
||||
break;
|
||||
case 'Tags':
|
||||
this.onEditFileTagToggle();
|
||||
break;
|
||||
case 'Permission':
|
||||
this.onPermissionItem();
|
||||
break;
|
||||
@ -412,6 +412,9 @@ class DirentGridView extends React.Component {
|
||||
case 'New Whiteboard File':
|
||||
this.onCreateFileToggle('.draw');
|
||||
break;
|
||||
case 'New Excalidraw File':
|
||||
this.onCreateFileToggle('.exdraw');
|
||||
break;
|
||||
case 'New SeaDoc File':
|
||||
this.onCreateFileToggle('.sdoc');
|
||||
break;
|
||||
@ -450,18 +453,6 @@ class DirentGridView extends React.Component {
|
||||
hideMenu();
|
||||
};
|
||||
|
||||
onEditFileTagToggle = () => {
|
||||
this.setState({
|
||||
isEditFileTagShow: !this.state.isEditFileTagShow
|
||||
});
|
||||
};
|
||||
|
||||
onFileTagChanged = () => {
|
||||
let dirent = this.state.activeDirent ? this.state.activeDirent : '';
|
||||
let direntPath = Utils.joinPath(this.props.path, dirent.name);
|
||||
this.props.onFileTagChanged(dirent, direntPath);
|
||||
};
|
||||
|
||||
getDirentPath = (dirent) => {
|
||||
let path = this.props.path;
|
||||
return path === '/' ? path + dirent.name : path + '/' + dirent.name;
|
||||
@ -522,6 +513,35 @@ class DirentGridView extends React.Component {
|
||||
this.setState({ isCopyDialogShow: !this.state.isCopyDialogShow });
|
||||
};
|
||||
|
||||
onToggleStarItem = () => {
|
||||
const { activeDirent: dirent } = this.state;
|
||||
const { repoID } = this.props;
|
||||
const filePath = this.getDirentPath(dirent);
|
||||
const itemName = dirent.name;
|
||||
|
||||
if (dirent.starred) {
|
||||
seafileAPI.unstarItem(repoID, filePath).then(() => {
|
||||
this.props.updateDirent(dirent, 'starred', false);
|
||||
const msg = gettext('Successfully unstarred {name_placeholder}.')
|
||||
.replace('{name_placeholder}', itemName);
|
||||
toaster.success(msg);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
} else {
|
||||
seafileAPI.starItem(repoID, filePath).then(() => {
|
||||
this.props.updateDirent(dirent, 'starred', true);
|
||||
const msg = gettext('Successfully starred {name_placeholder}.')
|
||||
.replace('{name_placeholder}', itemName);
|
||||
toaster.success(msg);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onPermissionItem = () => {
|
||||
this.setState({ isPermissionDialogOpen: !this.state.isPermissionDialogOpen });
|
||||
};
|
||||
@ -734,13 +754,15 @@ class DirentGridView extends React.Component {
|
||||
if (!['admin', 'rw'].includes(this.props.userPerm)) return;
|
||||
|
||||
const {
|
||||
NEW_FOLDER, NEW_FILE,
|
||||
NEW_FOLDER,
|
||||
NEW_FILE,
|
||||
NEW_MARKDOWN_FILE,
|
||||
NEW_EXCEL_FILE,
|
||||
NEW_POWERPOINT_FILE,
|
||||
NEW_WORD_FILE,
|
||||
NEW_SEADOC_FILE,
|
||||
NEW_TLDRAW_FILE
|
||||
NEW_TLDRAW_FILE,
|
||||
NEW_EXCALIDRAW_FILE
|
||||
} = TextTranslation;
|
||||
|
||||
let direntsContainerMenuList = [
|
||||
@ -763,6 +785,10 @@ class DirentGridView extends React.Component {
|
||||
direntsContainerMenuList.push(NEW_TLDRAW_FILE);
|
||||
}
|
||||
|
||||
if (enableExcalidraw) {
|
||||
direntsContainerMenuList.push(NEW_EXCALIDRAW_FILE);
|
||||
}
|
||||
|
||||
if (selectedDirentList.length === 0) {
|
||||
if (!hasCustomPermission('create')) return;
|
||||
this.handleContextClick(event, DIRENT_GRID_CONTAINER_MENU_ID, direntsContainerMenuList);
|
||||
@ -995,16 +1021,6 @@ class DirentGridView extends React.Component {
|
||||
onAddFolder={this.props.onAddFolder}
|
||||
/>
|
||||
}
|
||||
{this.state.isEditFileTagShow &&
|
||||
<EditFileTagDialog
|
||||
repoID={this.props.repoID}
|
||||
fileTagList={dirent.file_tags}
|
||||
filePath={direntPath}
|
||||
toggleCancel={this.onEditFileTagToggle}
|
||||
repoTags={this.props.repoTags}
|
||||
onFileTagChanged={this.onFileTagChanged}
|
||||
/>
|
||||
}
|
||||
{this.state.isShareDialogShow &&
|
||||
<ModalPortal>
|
||||
<ShareDialog
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import MediaQuery from 'react-responsive';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import dayjs from 'dayjs';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { gettext, siteRoot, mediaUrl, username, useGoFileserver, fileServerRoot, enableVideoThumbnail, enablePDFThumbnail } from '../../utils/constants';
|
||||
@ -17,13 +15,10 @@ import MoveDirentDialog from '../dialog/move-dirent-dialog';
|
||||
import CopyDirentDialog from '../dialog/copy-dirent-dialog';
|
||||
import ShareDialog from '../dialog/share-dialog';
|
||||
import ZipDownloadDialog from '../dialog/zip-download-dialog';
|
||||
import EditFileTagDialog from '../dialog/edit-filetag-dialog';
|
||||
import EditFileTagPopover from '../popover/edit-filetag-popover';
|
||||
import LibSubFolderPermissionDialog from '../dialog/lib-sub-folder-permission-dialog';
|
||||
import FileAccessLog from '../dialog/file-access-log';
|
||||
import toaster from '../toast';
|
||||
import MobileItemMenu from '../../components/mobile-item-menu';
|
||||
import FileTag from './file-tag';
|
||||
|
||||
import '../../css/dirent-list-item.css';
|
||||
|
||||
@ -95,10 +90,8 @@ class DirentListItem extends React.Component {
|
||||
isShowTagTooltip: false,
|
||||
isDragTipShow: false,
|
||||
isDropTipshow: false,
|
||||
isEditFileTagShow: false,
|
||||
isPermissionDialogOpen: false
|
||||
};
|
||||
this.tagListTitleID = `tag-list-title-${uuidv4()}`;
|
||||
this.isGeneratingThumbnail = false;
|
||||
this.thumbnailCenter = null;
|
||||
this.dragIconRef = null;
|
||||
@ -241,21 +234,28 @@ class DirentListItem extends React.Component {
|
||||
this.props.onItemSelected(this.state.dirent, event);
|
||||
};
|
||||
|
||||
onItemStarred = (e) => {
|
||||
let dirent = this.state.dirent;
|
||||
let repoID = this.props.repoID;
|
||||
let filePath = this.getDirentPath(dirent);
|
||||
onItemStarred = () => {
|
||||
const { dirent } = this.state;
|
||||
const { repoID } = this.props;
|
||||
const filePath = this.getDirentPath(dirent);
|
||||
const itemName = dirent.name;
|
||||
|
||||
if (dirent.starred) {
|
||||
seafileAPI.unstarItem(repoID, filePath).then(() => {
|
||||
this.props.updateDirent(this.state.dirent, 'starred', false);
|
||||
this.props.updateDirent(dirent, 'starred', false);
|
||||
const msg = gettext('Successfully unstarred {name_placeholder}.')
|
||||
.replace('{name_placeholder}', itemName);
|
||||
toaster.success(msg);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
} else {
|
||||
seafileAPI.starItem(repoID, filePath).then(() => {
|
||||
this.props.updateDirent(this.state.dirent, 'starred', true);
|
||||
this.props.updateDirent(dirent, 'starred', true);
|
||||
const msg = gettext('Successfully starred {name_placeholder}.')
|
||||
.replace('{name_placeholder}', itemName);
|
||||
toaster.success(msg);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
@ -350,9 +350,6 @@ class DirentListItem extends React.Component {
|
||||
case 'Copy':
|
||||
this.onItemCopyToggle();
|
||||
break;
|
||||
case 'Tags':
|
||||
this.onEditFileTagToggle();
|
||||
break;
|
||||
case 'Permission':
|
||||
this.onPermissionItem();
|
||||
break;
|
||||
@ -410,12 +407,6 @@ class DirentListItem extends React.Component {
|
||||
this.props.onItemConvert(this.state.dirent, dstType);
|
||||
};
|
||||
|
||||
onEditFileTagToggle = () => {
|
||||
this.setState({
|
||||
isEditFileTagShow: !this.state.isEditFileTagShow
|
||||
});
|
||||
};
|
||||
|
||||
onFileTagChanged = () => {
|
||||
let direntPath = this.getDirentPath(this.state.dirent);
|
||||
this.props.onFileTagChanged(this.state.dirent, direntPath);
|
||||
@ -891,18 +882,6 @@ class DirentListItem extends React.Component {
|
||||
)}
|
||||
</td>
|
||||
<td className="tag-list-title">
|
||||
{(dirent.type !== 'dir' && dirent.file_tags && dirent.file_tags.length > 0) && (
|
||||
<div id={this.tagListTitleID} className="dirent-item tag-list tag-list-stacked">
|
||||
{dirent.file_tags.map((fileTag, index) => {
|
||||
return (
|
||||
<FileTag fileTag={fileTag} length={dirent.file_tags.length} key={index} index={index}/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(dirent.type !== 'dir' && (!dirent.file_tags || dirent.file_tags.length == 0)) &&
|
||||
<div id={this.tagListTitleID} className="dirent-item tag-list tag-list-stacked"></div>
|
||||
}
|
||||
</td>
|
||||
<td className="operation">{this.renderItemOperation()}</td>
|
||||
<td className="file-size">{dirent.size || ''}</td>
|
||||
@ -993,32 +972,6 @@ class DirentListItem extends React.Component {
|
||||
/>
|
||||
</ModalPortal>
|
||||
}
|
||||
<MediaQuery query="(min-width: 768px)">
|
||||
{this.state.isEditFileTagShow &&
|
||||
<EditFileTagPopover
|
||||
repoID={this.props.repoID}
|
||||
repoTags={this.props.repoTags}
|
||||
fileTagList={dirent.file_tags}
|
||||
filePath={direntPath}
|
||||
toggleCancel={this.onEditFileTagToggle}
|
||||
onFileTagChanged={this.onFileTagChanged}
|
||||
target={this.tagListTitleID}
|
||||
isEditFileTagShow={this.state.isEditFileTagShow}
|
||||
/>
|
||||
}
|
||||
</MediaQuery>
|
||||
<MediaQuery query="(max-width: 767.8px)">
|
||||
{this.state.isEditFileTagShow &&
|
||||
<EditFileTagDialog
|
||||
repoID={this.props.repoID}
|
||||
repoTags={this.props.repoTags}
|
||||
fileTagList={dirent.file_tags}
|
||||
filePath={direntPath}
|
||||
toggleCancel={this.onEditFileTagToggle}
|
||||
onFileTagChanged={this.onFileTagChanged}
|
||||
/>
|
||||
}
|
||||
</MediaQuery>
|
||||
{this.state.isZipDialogOpen &&
|
||||
<ModalPortal>
|
||||
<ZipDownloadDialog
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { siteRoot, gettext, username, enableSeadoc, thumbnailSizeForOriginal, thumbnailDefaultSize, fileServerRoot, enableWhiteboard, useGoFileserver } from '../../utils/constants';
|
||||
import { siteRoot, gettext, username, enableSeadoc, thumbnailSizeForOriginal, thumbnailDefaultSize, fileServerRoot, enableWhiteboard, useGoFileserver, enableExcalidraw } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import TextTranslation from '../../utils/text-translation';
|
||||
import URLDecorator from '../../utils/url-decorator';
|
||||
@ -431,7 +431,8 @@ class DirentListView extends React.Component {
|
||||
NEW_POWERPOINT_FILE,
|
||||
NEW_WORD_FILE,
|
||||
NEW_SEADOC_FILE,
|
||||
NEW_TLDRAW_FILE
|
||||
NEW_TLDRAW_FILE,
|
||||
NEW_EXCALIDRAW_FILE,
|
||||
} = TextTranslation;
|
||||
|
||||
const direntsContainerMenuList = [
|
||||
@ -452,6 +453,10 @@ class DirentListView extends React.Component {
|
||||
direntsContainerMenuList.push(NEW_TLDRAW_FILE);
|
||||
}
|
||||
|
||||
if (enableExcalidraw) {
|
||||
direntsContainerMenuList.push(NEW_EXCALIDRAW_FILE);
|
||||
}
|
||||
|
||||
if (this.props.selectedDirentList.length === 0) {
|
||||
let id = 'dirent-container-menu';
|
||||
|
||||
@ -529,6 +534,9 @@ class DirentListView extends React.Component {
|
||||
case 'New Whiteboard File':
|
||||
this.onCreateFileToggle('.draw');
|
||||
break;
|
||||
case 'New Excalidraw File':
|
||||
this.onCreateFileToggle('.exdraw');
|
||||
break;
|
||||
case 'New SeaDoc File':
|
||||
this.onCreateFileToggle('.sdoc');
|
||||
break;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { enableSeadoc, gettext, enableWhiteboard } from '../../utils/constants';
|
||||
import { enableSeadoc, gettext, enableWhiteboard, enableExcalidraw } from '../../utils/constants';
|
||||
import Loading from '../loading';
|
||||
import ModalPortal from '../modal-portal';
|
||||
import CreateFile from '../../components/dialog/create-file-dialog';
|
||||
@ -74,7 +74,8 @@ class DirentNoneView extends React.Component {
|
||||
NEW_POWERPOINT_FILE,
|
||||
NEW_WORD_FILE,
|
||||
NEW_SEADOC_FILE,
|
||||
NEW_TLDRAW_FILE
|
||||
NEW_TLDRAW_FILE,
|
||||
NEW_EXCALIDRAW_FILE
|
||||
} = TextTranslation;
|
||||
const direntsContainerMenuList = [
|
||||
NEW_FOLDER, NEW_FILE, 'Divider',
|
||||
@ -92,6 +93,9 @@ class DirentNoneView extends React.Component {
|
||||
if (enableWhiteboard) {
|
||||
direntsContainerMenuList.push(NEW_TLDRAW_FILE);
|
||||
}
|
||||
if (enableExcalidraw) {
|
||||
direntsContainerMenuList.push(NEW_EXCALIDRAW_FILE);
|
||||
}
|
||||
let id = 'dirent-container-menu';
|
||||
if (isCustomPermission) {
|
||||
const { create: canCreate } = customPermission.permission;
|
||||
|
@ -34,6 +34,7 @@ class ItemDropdownMenu extends React.Component {
|
||||
currentItem: ''
|
||||
};
|
||||
this.dropdownRef = React.createRef();
|
||||
this.mainMenuDirection = 'down';
|
||||
this.subMenuDirection = 'right';
|
||||
}
|
||||
|
||||
@ -47,6 +48,24 @@ class ItemDropdownMenu extends React.Component {
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (this.dropdownRef.current) {
|
||||
const rect = this.dropdownRef.current.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
const spaceBelow = windowHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const menuHeightThreshold = 400;
|
||||
|
||||
let mainMenuDirection;
|
||||
if (spaceBelow < menuHeightThreshold && spaceAbove < menuHeightThreshold) {
|
||||
const spaceRight = window.innerWidth - rect.right;
|
||||
mainMenuDirection = spaceRight >= 200 ? 'right' : 'left';
|
||||
} else if (spaceBelow < menuHeightThreshold) {
|
||||
mainMenuDirection = 'up';
|
||||
} else {
|
||||
mainMenuDirection = 'down';
|
||||
}
|
||||
|
||||
this.mainMenuDirection = mainMenuDirection;
|
||||
this.subMenuDirection = (window.innerWidth - this.dropdownRef.current.getBoundingClientRect().right < 400) ? 'left' : 'right';
|
||||
}
|
||||
}, 1);
|
||||
@ -196,7 +215,12 @@ class ItemDropdownMenu extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown isOpen={this.state.isItemMenuShow} toggle={this.onDropdownToggleClick} className="vam">
|
||||
<Dropdown
|
||||
isOpen={this.state.isItemMenuShow}
|
||||
toggle={this.onDropdownToggleClick}
|
||||
className="vam"
|
||||
direction={this.mainMenuDirection}
|
||||
>
|
||||
<DropdownToggle
|
||||
tag={tagName || 'i'}
|
||||
role="button"
|
||||
|
@ -11,7 +11,6 @@ const {
|
||||
thumbnailSizeForOriginal,
|
||||
previousImage, nextImage, rawPath,
|
||||
lastModificationTime,
|
||||
xmindImageSrc // for xmind file
|
||||
} = window.app.pageOptions;
|
||||
|
||||
let previousImageUrl;
|
||||
@ -62,9 +61,6 @@ class FileContent extends React.Component {
|
||||
thumbnailURL = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${Utils.encodePath(filePath)}?mtime=${lastModificationTime}`;
|
||||
}
|
||||
|
||||
// for xmind file
|
||||
const xmindSrc = xmindImageSrc ? `${siteRoot}${xmindImageSrc}` : '';
|
||||
|
||||
const { scale, angle } = this.props;
|
||||
let style = {};
|
||||
if (scale && angle != undefined) {
|
||||
@ -84,7 +80,7 @@ class FileContent extends React.Component {
|
||||
{nextImage && (
|
||||
<a href={nextImageUrl} id="img-next" title={gettext('you can also press →')}><span className="sf3-font sf3-font-down rotate-270 d-inline-block"></span></a>
|
||||
)}
|
||||
<img src={xmindSrc || thumbnailURL || rawPath} alt={fileName} id="image-view" onError={this.handleLoadFailure} style={ style } />
|
||||
<img src={thumbnailURL || rawPath} alt={fileName} id="image-view" onError={this.handleLoadFailure} style={ style } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -630,16 +630,21 @@ class FileUploader extends React.Component {
|
||||
};
|
||||
|
||||
replaceRepetitionFile = () => {
|
||||
let resumableFile = this.resumable.files[this.resumable.files.length - 1];
|
||||
let { repoID, path } = this.props;
|
||||
seafileAPI.getUpdateLink(repoID, path).then(res => {
|
||||
this.resumable.opts.target = res.data;
|
||||
|
||||
let resumableFile = this.resumable.files[this.resumable.files.length - 1];
|
||||
resumableFile.formData['replace'] = 1;
|
||||
resumableFile.formData['target_file'] = resumableFile.formData.parent_dir + resumableFile.fileName;
|
||||
this.setState({ isUploadRemindDialogShow: false });
|
||||
this.setUploadFileList(this.resumable.files);
|
||||
this.resumable.upload();
|
||||
this.setState({
|
||||
isUploadRemindDialogShow: false,
|
||||
isUploadProgressDialogShow: true,
|
||||
uploadFileList: [...this.state.uploadFileList, resumableFile]
|
||||
}, () => {
|
||||
this.resumable.upload();
|
||||
});
|
||||
Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', true);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
|
213
frontend/src/components/file-view/comment-panel.js
Normal file
213
frontend/src/components/file-view/comment-panel.js
Normal 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;
|
@ -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;
|
||||
}
|
@ -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;
|
||||
|
@ -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%;
|
||||
}
|
@ -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;
|
@ -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;
|
210
frontend/src/components/file-view/comment-widget/comment-item.js
Normal file
210
frontend/src/components/file-view/comment-widget/comment-item.js
Normal 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;
|
251
frontend/src/components/file-view/comment-widget/comment-list.js
Normal file
251
frontend/src/components/file-view/comment-widget/comment-list.js
Normal 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;
|
175
frontend/src/components/file-view/comment-widget/reply-item.js
Normal file
175
frontend/src/components/file-view/comment-widget/reply-item.js
Normal 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;
|
234
frontend/src/components/file-view/comment-widget/reply-list.js
Normal file
234
frontend/src/components/file-view/comment-widget/reply-list.js
Normal 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;
|
@ -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>
|
||||
|
@ -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}>
|
||||
|
@ -10,7 +10,7 @@ import OpIcon from './op-icon';
|
||||
|
||||
const propTypes = {
|
||||
groupMembers: PropTypes.array.isRequired,
|
||||
groupID: PropTypes.string,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
isOwner: PropTypes.bool.isRequired,
|
||||
isItemFreezed: PropTypes.bool.isRequired,
|
||||
toggleItemFreezed: PropTypes.func.isRequired,
|
||||
@ -61,7 +61,7 @@ const MemberPropTypes = {
|
||||
changeMember: PropTypes.func.isRequired,
|
||||
deleteMember: PropTypes.func.isRequired,
|
||||
toggleItemFreezed: PropTypes.func.isRequired,
|
||||
groupID: PropTypes.string,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
isOwner: PropTypes.bool.isRequired,
|
||||
isItemFreezed: PropTypes.bool.isRequired
|
||||
};
|
||||
|
@ -1,27 +1,313 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import AISearchIcon from '../assets/icons/AI-search.svg';
|
||||
import AddTableIcon from '../assets/icons/add-table.svg';
|
||||
import AiIcon from '../assets/icons/ai.svg';
|
||||
import ArrowIcon from '../assets/icons/arrow.svg';
|
||||
import BellIcon from '../assets/icons/bell.svg';
|
||||
import CheckCircleIcon from '../assets/icons/check-circle.svg';
|
||||
import CheckMarkIcon from '../assets/icons/check-mark.svg';
|
||||
import CheckSquareSolidIcon from '../assets/icons/check-square-solid.svg';
|
||||
import CheckboxIcon from '../assets/icons/checkbox.svg';
|
||||
import ClientIcon from '../assets/icons/client.svg';
|
||||
import CloseIcon from '../assets/icons/close.svg';
|
||||
import CollaboratorIcon from '../assets/icons/collaborator.svg';
|
||||
import CopyIcon from '../assets/icons/copy.svg';
|
||||
import CreationTimeIcon from '../assets/icons/creation-time.svg';
|
||||
import CreatorIcon from '../assets/icons/creator.svg';
|
||||
import CurrencyIcon from '../assets/icons/currency.svg';
|
||||
import DateIcon from '../assets/icons/date.svg';
|
||||
import DeleteIcon from '../assets/icons/delete.svg';
|
||||
import DescriptionIcon from '../assets/icons/description.svg';
|
||||
import DoubleArrowDownIcon from '../assets/icons/double-arrow-down.svg';
|
||||
import DoubleArrowUpIcon from '../assets/icons/double-arrow-up.svg';
|
||||
import DownloadIcon from '../assets/icons/download.svg';
|
||||
import DragIcon from '../assets/icons/drag.svg';
|
||||
import DropDownIcon from '../assets/icons/drop-down.svg';
|
||||
import EditIcon from '../assets/icons/edit.svg';
|
||||
import ExclamationCircleIcon from '../assets/icons/exclamation-circle.svg';
|
||||
import ExclamationTriangleIcon from '../assets/icons/exclamation-triangle.svg';
|
||||
import ExpandIcon from '../assets/icons/expand.svg';
|
||||
import EyeSlashIcon from '../assets/icons/eye-slash.svg';
|
||||
import FaceRecognitionViewIcon from '../assets/icons/face-recognition-view.svg';
|
||||
import FileIcon from '../assets/icons/file.svg';
|
||||
import FilesIcon from '../assets/icons/files.svg';
|
||||
import FilterCircledIcon from '../assets/icons/filter-circled.svg';
|
||||
import FilterIcon from '../assets/icons/filter.svg';
|
||||
import FlagIcon from '../assets/icons/flag.svg';
|
||||
import FolderIcon from '../assets/icons/folder.svg';
|
||||
import FoldersIcon from '../assets/icons/folders.svg';
|
||||
import ForkNumberIcon from '../assets/icons/fork-number.svg';
|
||||
import GroupIcon from '../assets/icons/group.svg';
|
||||
import HelpfulSelectedIcon from '../assets/icons/helpful-selected.svg';
|
||||
import HelpfulIcon from '../assets/icons/helpful.svg';
|
||||
import HelplessSelectedIcon from '../assets/icons/helpless-selected.svg';
|
||||
import HelplessIcon from '../assets/icons/helpless.svg';
|
||||
import HideIcon from '../assets/icons/hide.svg';
|
||||
import ImageIcon from '../assets/icons/image.svg';
|
||||
import InfoIcon from '../assets/icons/info.svg';
|
||||
import KanbanIcon from '../assets/icons/kanban.svg';
|
||||
import LeftArrowIcon from '../assets/icons/left_arrow.svg';
|
||||
import LikeIcon from '../assets/icons/like.svg';
|
||||
import LinkIcon from '../assets/icons/link.svg';
|
||||
import LinkageIcon from '../assets/icons/linkage.svg';
|
||||
import LocationIcon from '../assets/icons/location.svg';
|
||||
import LockIcon from '../assets/icons/lock.svg';
|
||||
import LongTextIcon from '../assets/icons/long-text.svg';
|
||||
import MainViewIcon from '../assets/icons/main-view.svg';
|
||||
import MapIcon from '../assets/icons/map.svg';
|
||||
import MarkdownIcon from '../assets/icons/markdown.svg';
|
||||
import MinusSignIcon from '../assets/icons/minus_sign.svg';
|
||||
import MonitorIcon from '../assets/icons/monitor.svg';
|
||||
import MoreLevelIcon from '../assets/icons/more-level.svg';
|
||||
import MoreVerticalIcon from '../assets/icons/more-vertical.svg';
|
||||
import MoveToIcon from '../assets/icons/move-to.svg';
|
||||
import MultipleSelectIcon from '../assets/icons/multiple-select.svg';
|
||||
import NumberIcon from '../assets/icons/number.svg';
|
||||
import OpenFileIcon from '../assets/icons/open-file.svg';
|
||||
import OpenFolderIcon from '../assets/icons/open-folder.svg';
|
||||
import PartiallySelectedIcon from '../assets/icons/partially-selected.svg';
|
||||
import PlusSignIcon from '../assets/icons/plus_sign.svg';
|
||||
import PraiseIcon from '../assets/icons/praise.svg';
|
||||
import PrintIcon from '../assets/icons/print.svg';
|
||||
import RateIcon from '../assets/icons/rate.svg';
|
||||
import RemoveFromFolderIcon from '../assets/icons/remove-from-folder.svg';
|
||||
import RenameIcon from '../assets/icons/rename.svg';
|
||||
import RightArrowIcon from '../assets/icons/right_arrow.svg';
|
||||
import RotateIcon from '../assets/icons/rotate.svg';
|
||||
import RowHeightDefaultIcon from '../assets/icons/row-height-default.svg';
|
||||
import RowHeightDoubleIcon from '../assets/icons/row-height-double.svg';
|
||||
import RowHeightQuadrupleIcon from '../assets/icons/row-height-quadruple.svg';
|
||||
import RowHeightTripleIcon from '../assets/icons/row-height-triple.svg';
|
||||
import SaveIcon from '../assets/icons/save.svg';
|
||||
import SearchIcon from '../assets/icons/search.svg';
|
||||
import SendIcon from '../assets/icons/send.svg';
|
||||
import SetUpIcon from '../assets/icons/set-up.svg';
|
||||
import ShareIcon from '../assets/icons/share.svg';
|
||||
import SingleSelectIcon from '../assets/icons/single-select.svg';
|
||||
import SortAscendingIcon from '../assets/icons/sort-ascending.svg';
|
||||
import SortDescendingIcon from '../assets/icons/sort-descending.svg';
|
||||
import SortIcon from '../assets/icons/sort.svg';
|
||||
import SpinnerIcon from '../assets/icons/spinner.svg';
|
||||
import TableIcon from '../assets/icons/table.svg';
|
||||
import TagIcon from '../assets/icons/tag.svg';
|
||||
import TextIcon from '../assets/icons/text.svg';
|
||||
import TimeIcon from '../assets/icons/time.svg';
|
||||
import UnlockIcon from '../assets/icons/unlock.svg';
|
||||
import UrlIcon from '../assets/icons/url.svg';
|
||||
import WikiPreviewIcon from '../assets/icons/wiki-preview.svg';
|
||||
import WikiSettingsIcon from '../assets/icons/wiki-settings.svg';
|
||||
import X01Icon from '../assets/icons/x-01.svg';
|
||||
|
||||
import '../css/icon.css';
|
||||
|
||||
const importAll = (requireContext) => {
|
||||
requireContext.keys().forEach(requireContext);
|
||||
};
|
||||
try {
|
||||
importAll(require.context('../assets/icons', true, /\.svg$/));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const Icon = (props) => {
|
||||
const { className, symbol, style } = props;
|
||||
const iconClass = classnames('seafile-multicolor-icon', className, `seafile-multicolor-icon-${symbol}`);
|
||||
return (
|
||||
<svg className={iconClass} style={style}>
|
||||
<use xlinkHref={`#${symbol}`} />
|
||||
</svg>
|
||||
);
|
||||
const commonProps = { className: iconClass, style: style, ariaHidden: 'true' };
|
||||
|
||||
switch (symbol) {
|
||||
case 'ai-search':
|
||||
return <AISearchIcon {...commonProps} />;
|
||||
case 'add-table':
|
||||
return <AddTableIcon {...commonProps} />;
|
||||
case 'ai':
|
||||
return <AiIcon {...commonProps} />;
|
||||
case 'arrow':
|
||||
return <ArrowIcon {...commonProps} />;
|
||||
case 'bell':
|
||||
return <BellIcon {...commonProps} />;
|
||||
case 'check-circle':
|
||||
return <CheckCircleIcon {...commonProps} />;
|
||||
case 'check-mark':
|
||||
return <CheckMarkIcon {...commonProps} />;
|
||||
case 'check-square-solid':
|
||||
return <CheckSquareSolidIcon {...commonProps} />;
|
||||
case 'checkbox':
|
||||
return <CheckboxIcon {...commonProps} />;
|
||||
case 'client':
|
||||
return <ClientIcon {...commonProps} />;
|
||||
case 'close':
|
||||
return <CloseIcon {...commonProps} />;
|
||||
case 'collaborator':
|
||||
return <CollaboratorIcon {...commonProps} />;
|
||||
case 'copy':
|
||||
return <CopyIcon {...commonProps} />;
|
||||
case 'creation-time':
|
||||
return <CreationTimeIcon {...commonProps} />;
|
||||
case 'creator':
|
||||
return <CreatorIcon {...commonProps} />;
|
||||
case 'currency':
|
||||
return <CurrencyIcon {...commonProps} />;
|
||||
case 'date':
|
||||
return <DateIcon {...commonProps} />;
|
||||
case 'delete':
|
||||
return <DeleteIcon {...commonProps} />;
|
||||
case 'description':
|
||||
return <DescriptionIcon {...commonProps} />;
|
||||
case 'double-arrow-down':
|
||||
return <DoubleArrowDownIcon {...commonProps} />;
|
||||
case 'double-arrow-up':
|
||||
return <DoubleArrowUpIcon {...commonProps} />;
|
||||
case 'download':
|
||||
return <DownloadIcon {...commonProps} />;
|
||||
case 'drag':
|
||||
return <DragIcon {...commonProps} />;
|
||||
case 'drop-down':
|
||||
return <DropDownIcon {...commonProps} />;
|
||||
case 'edit':
|
||||
return <EditIcon {...commonProps} />;
|
||||
case 'exclamation-circle':
|
||||
return <ExclamationCircleIcon {...commonProps} />;
|
||||
case 'exclamation-triangle':
|
||||
return <ExclamationTriangleIcon {...commonProps} />;
|
||||
case 'expand':
|
||||
return <ExpandIcon {...commonProps} />;
|
||||
case 'eye-slash':
|
||||
return <EyeSlashIcon {...commonProps} />;
|
||||
case 'face-recognition-view':
|
||||
return <FaceRecognitionViewIcon {...commonProps} />;
|
||||
case 'file':
|
||||
return <FileIcon {...commonProps} />;
|
||||
case 'files':
|
||||
return <FilesIcon {...commonProps} />;
|
||||
case 'filter-circled':
|
||||
return <FilterCircledIcon {...commonProps} />;
|
||||
case 'filter':
|
||||
return <FilterIcon {...commonProps} />;
|
||||
case 'flag':
|
||||
return <FlagIcon {...commonProps} />;
|
||||
case 'folder':
|
||||
return <FolderIcon {...commonProps} />;
|
||||
case 'folders':
|
||||
return <FoldersIcon {...commonProps} />;
|
||||
case 'fork-number':
|
||||
return <ForkNumberIcon {...commonProps} />;
|
||||
case 'group':
|
||||
return <GroupIcon {...commonProps} />;
|
||||
case 'helpful-selected':
|
||||
return <HelpfulSelectedIcon {...commonProps} />;
|
||||
case 'helpful':
|
||||
return <HelpfulIcon {...commonProps} />;
|
||||
case 'helpless-selected':
|
||||
return <HelplessSelectedIcon {...commonProps} />;
|
||||
case 'helpless':
|
||||
return <HelplessIcon {...commonProps} />;
|
||||
case 'hide':
|
||||
return <HideIcon {...commonProps} />;
|
||||
case 'image':
|
||||
return <ImageIcon {...commonProps} />;
|
||||
case 'info':
|
||||
return <InfoIcon {...commonProps} />;
|
||||
case 'kanban':
|
||||
return <KanbanIcon {...commonProps} />;
|
||||
case 'left-arrow':
|
||||
return <LeftArrowIcon {...commonProps} />;
|
||||
case 'like':
|
||||
return <LikeIcon {...commonProps} />;
|
||||
case 'link':
|
||||
return <LinkIcon {...commonProps} />;
|
||||
case 'linkage':
|
||||
return <LinkageIcon {...commonProps} />;
|
||||
case 'location':
|
||||
return <LocationIcon {...commonProps} />;
|
||||
case 'lock':
|
||||
return <LockIcon {...commonProps} />;
|
||||
case 'long-text':
|
||||
return <LongTextIcon {...commonProps} />;
|
||||
case 'main-view':
|
||||
return <MainViewIcon {...commonProps} />;
|
||||
case 'map':
|
||||
return <MapIcon {...commonProps} />;
|
||||
case 'markdown':
|
||||
return <MarkdownIcon {...commonProps} />;
|
||||
case 'minus_sign':
|
||||
return <MinusSignIcon {...commonProps} />;
|
||||
case 'monitor':
|
||||
return <MonitorIcon {...commonProps} />;
|
||||
case 'more-level':
|
||||
return <MoreLevelIcon {...commonProps} />;
|
||||
case 'more-vertical':
|
||||
return <MoreVerticalIcon {...commonProps} />;
|
||||
case 'move-to':
|
||||
return <MoveToIcon {...commonProps} />;
|
||||
case 'multiple-select':
|
||||
return <MultipleSelectIcon {...commonProps} />;
|
||||
case 'number':
|
||||
return <NumberIcon {...commonProps} />;
|
||||
case 'open-file':
|
||||
return <OpenFileIcon {...commonProps} />;
|
||||
case 'open-folder':
|
||||
return <OpenFolderIcon {...commonProps} />;
|
||||
case 'partially-selected':
|
||||
return <PartiallySelectedIcon {...commonProps} />;
|
||||
case 'plus_sign':
|
||||
return <PlusSignIcon {...commonProps} />;
|
||||
case 'praise':
|
||||
return <PraiseIcon {...commonProps} />;
|
||||
case 'print':
|
||||
return <PrintIcon {...commonProps} />;
|
||||
case 'rate':
|
||||
return <RateIcon {...commonProps} />;
|
||||
case 'remove-from-folder':
|
||||
return <RemoveFromFolderIcon {...commonProps} />;
|
||||
case 'rename':
|
||||
return <RenameIcon {...commonProps} />;
|
||||
case 'right_arrow':
|
||||
return <RightArrowIcon {...commonProps} />;
|
||||
case 'rotate':
|
||||
return <RotateIcon {...commonProps} />;
|
||||
case 'row-height-default':
|
||||
return <RowHeightDefaultIcon {...commonProps} />;
|
||||
case 'row-height-double':
|
||||
return <RowHeightDoubleIcon {...commonProps} />;
|
||||
case 'row-height-quadruple':
|
||||
return <RowHeightQuadrupleIcon {...commonProps} />;
|
||||
case 'row-height-triple':
|
||||
return <RowHeightTripleIcon {...commonProps} />;
|
||||
case 'save':
|
||||
return <SaveIcon {...commonProps} />;
|
||||
case 'search':
|
||||
return <SearchIcon {...commonProps} />;
|
||||
case 'send':
|
||||
return <SendIcon {...commonProps} />;
|
||||
case 'set-up':
|
||||
return <SetUpIcon {...commonProps} />;
|
||||
case 'share':
|
||||
return <ShareIcon {...commonProps} />;
|
||||
case 'single-select':
|
||||
return <SingleSelectIcon {...commonProps} />;
|
||||
case 'sort-ascending':
|
||||
return <SortAscendingIcon {...commonProps} />;
|
||||
case 'sort-descending':
|
||||
return <SortDescendingIcon {...commonProps} />;
|
||||
case 'sort':
|
||||
return <SortIcon {...commonProps} />;
|
||||
case 'spinner':
|
||||
return <SpinnerIcon {...commonProps} />;
|
||||
case 'table':
|
||||
return <TableIcon {...commonProps} />;
|
||||
case 'tag':
|
||||
return <TagIcon {...commonProps} />;
|
||||
case 'text':
|
||||
return <TextIcon {...commonProps} />;
|
||||
case 'time':
|
||||
return <TimeIcon {...commonProps} />;
|
||||
case 'unlock':
|
||||
return <UnlockIcon {...commonProps} />;
|
||||
case 'url':
|
||||
return <UrlIcon {...commonProps} />;
|
||||
case 'wiki-preview':
|
||||
return <WikiPreviewIcon {...commonProps} />;
|
||||
case 'wiki-settings':
|
||||
return <WikiSettingsIcon {...commonProps} />;
|
||||
case 'x-01':
|
||||
return <X01Icon {...commonProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Icon.propTypes = {
|
||||
|
@ -12,7 +12,7 @@ import GroupMembers from './group-members';
|
||||
const propTypes = {
|
||||
toggleManageMembersDialog: PropTypes.func,
|
||||
toggleDepartmentDetailDialog: PropTypes.func,
|
||||
groupID: PropTypes.string,
|
||||
groupID: PropTypes.number.isRequired,
|
||||
isOwner: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
|
@ -1,31 +0,0 @@
|
||||
.list-tag-popover .popover {
|
||||
width: 500px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.list-tag-popover .add-tag-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-tag-popover .tag-list-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #dedede;
|
||||
}
|
||||
|
||||
.list-tag-popover .tag-list-footer .item-text {
|
||||
color: #ff9800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-tag-popover .tag-list-footer a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.list-tag-popover .tag-color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
@ -1,160 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import toaster from '../toast';
|
||||
import RepoTag from '../../models/repo-tag';
|
||||
import TagListItem from './tag-list-item';
|
||||
import VirtualTagListItem from './virtual-tag-list-item';
|
||||
import TagListFooter from './tag-list-footer';
|
||||
import { TAG_COLORS } from '../../constants/';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
import './list-tag-popover.css';
|
||||
|
||||
export default class ListTagPopover extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
onListTagCancel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
repotagList: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadTags();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.setState = () => {};
|
||||
}
|
||||
|
||||
loadTags = () => {
|
||||
seafileAPI.listRepoTags(this.props.repoID).then(res => {
|
||||
let repotagList = [];
|
||||
res.data.repo_tags.forEach(item => {
|
||||
let repo_tag = new RepoTag(item);
|
||||
repotagList.push(repo_tag);
|
||||
});
|
||||
this.setState({ repotagList });
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
updateTags = (newRepotagList) => {
|
||||
this.setState({
|
||||
repotagList: [...this.state.repotagList, ...newRepotagList],
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteTag = (tag) => {
|
||||
const { repoID } = this.props;
|
||||
const { id: targetTagID } = tag;
|
||||
seafileAPI.deleteRepoTag(repoID, targetTagID).then((res) => {
|
||||
this.setState({
|
||||
repotagList: this.state.repotagList.filter(tag => tag.id != targetTagID)
|
||||
});
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
};
|
||||
|
||||
createVirtualTag = (e) => {
|
||||
e.preventDefault();
|
||||
let { repotagList } = this.state;
|
||||
let virtual_repo_tag = {
|
||||
name: '',
|
||||
color: TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)], // generate random tag color for virtual tag
|
||||
id: `virtual-tag-${uuidv4()}`,
|
||||
is_virtual: true,
|
||||
};
|
||||
repotagList.push(virtual_repo_tag);
|
||||
this.setState({ repotagList });
|
||||
};
|
||||
|
||||
deleteVirtualTag = (virtualTag) => {
|
||||
let { repotagList } = this.state;
|
||||
let index = repotagList.findIndex(item => item.id === virtualTag.id);
|
||||
repotagList.splice(index, 1);
|
||||
this.setState({ repotagList });
|
||||
};
|
||||
|
||||
updateVirtualTag = (virtualTag, data) => {
|
||||
const repoID = this.props.repoID;
|
||||
const { repotagList } = this.state;
|
||||
const index = repotagList.findIndex(item => item.id === virtualTag.id);
|
||||
if (index < 0) return null;
|
||||
|
||||
// If virtual tag color is updated and virtual tag name is empty, it will be saved to local state, don't save it to the server
|
||||
if (data.color) {
|
||||
virtualTag.color = data.color;
|
||||
repotagList[index] = virtualTag;
|
||||
this.setState({ repotagList });
|
||||
return;
|
||||
}
|
||||
|
||||
// If virtual tag name is updated and name is not empty, virtual tag color use default, save it to the server
|
||||
if (data.name && data.name.length > 0) {
|
||||
let color = virtualTag.color;
|
||||
let name = data.name;
|
||||
seafileAPI.createRepoTag(repoID, name, color).then((res) => {
|
||||
// After saving sag to the server, replace the virtual tag with newly created tag
|
||||
repotagList[index] = new RepoTag(res.data.repo_tag);
|
||||
this.setState({ repotagList });
|
||||
}).catch((error) => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<ul className="tag-list tag-list-container my-2">
|
||||
{this.state.repotagList.map((repoTag, index) => {
|
||||
if (repoTag.is_virtual) {
|
||||
return (
|
||||
<VirtualTagListItem
|
||||
key={index}
|
||||
item={repoTag}
|
||||
repoID={this.props.repoID}
|
||||
deleteVirtualTag={this.deleteVirtualTag}
|
||||
updateVirtualTag={this.updateVirtualTag}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TagListItem
|
||||
key={index}
|
||||
item={repoTag}
|
||||
repoID={this.props.repoID}
|
||||
onDeleteTag={this.onDeleteTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
<div className="add-tag-link px-4 py-2 d-flex align-items-center" onClick={this.createVirtualTag}>
|
||||
<span className="sf2-icon-plus mr-2"></span>{gettext('Create a new tag')}
|
||||
</div>
|
||||
<TagListFooter
|
||||
toggle={this.props.onListTagCancel}
|
||||
repotagList={this.state.repotagList}
|
||||
updateTags={this.updateTags}
|
||||
repoID={this.props.repoID}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Tooltip } from 'reactstrap';
|
||||
import { seafileAPI } from '../../utils/seafile-api';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import RepoTag from '../../models/repo-tag';
|
||||
import toaster from '../toast';
|
||||
|
||||
export default class TagListFooter extends Component {
|
||||
|
||||
static propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
repotagList: PropTypes.array.isRequired,
|
||||
updateTags: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showTooltip: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggleTooltip = () => {
|
||||
this.setState({ showTooltip: !this.state.showTooltip });
|
||||
};
|
||||
|
||||
onClickImport = () => {
|
||||
this.importOptionsInput.click();
|
||||
};
|
||||
|
||||
importTagsInputChange = () => {
|
||||
if (!this.importOptionsInput.files || !this.importOptionsInput.files.length) {
|
||||
toaster.warning(gettext('Please select a file'));
|
||||
return;
|
||||
}
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = this.onImportTags.bind(this);
|
||||
fileReader.onerror = this.onImportTagsError.bind(this);
|
||||
fileReader.readAsText(this.importOptionsInput.files[0]);
|
||||
};
|
||||
|
||||
getValidTags = (tags) => {
|
||||
let validTags = [];
|
||||
let tagNameMap = {};
|
||||
this.props.repotagList.forEach(tag => tagNameMap[tag.name] = true);
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (!tags[i] || typeof tags[i] !== 'object' || !tags[i].name || !tags[i].color) {
|
||||
continue;
|
||||
}
|
||||
if (!tagNameMap[tags[i].name]) {
|
||||
validTags.push(
|
||||
{
|
||||
name: tags[i].name,
|
||||
color: tags[i].color,
|
||||
}
|
||||
);
|
||||
tagNameMap[tags[i].name] = true;
|
||||
}
|
||||
}
|
||||
return validTags;
|
||||
};
|
||||
|
||||
onImportTags = (event) => {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(event.target.result); // handle JSON file format is error
|
||||
} catch (error) {
|
||||
toaster.danger(gettext('The imported tags are invalid'));
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
toaster.danger(gettext('The imported tags are invalid'));
|
||||
return;
|
||||
}
|
||||
let validTags = this.getValidTags(tags);
|
||||
if (validTags.length === 0) {
|
||||
toaster.warning(gettext('The imported tag already exists'));
|
||||
return;
|
||||
}
|
||||
seafileAPI.createRepoTags(this.props.repoID, validTags).then((res) => {
|
||||
toaster.success(gettext('Tags imported'));
|
||||
let repotagList = [];
|
||||
res.data.repo_tags.forEach(item => {
|
||||
let repo_tag = new RepoTag(item);
|
||||
repotagList.push(repo_tag);
|
||||
});
|
||||
this.props.updateTags(repotagList);
|
||||
}).catch(error => {
|
||||
let errMessage = Utils.getErrorMsg(error);
|
||||
toaster.danger(errMessage);
|
||||
});
|
||||
this.importOptionsInput.value = null;
|
||||
};
|
||||
|
||||
onImportTagsError = () => {
|
||||
toaster.success(gettext('Failed to import tags. Please reupload.'));
|
||||
};
|
||||
|
||||
getDownloadUrl = () => {
|
||||
const tags = this.props.repotagList.map(item => {
|
||||
return { name: item.name, color: item.color };
|
||||
});
|
||||
return `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(tags))}`;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="tag-list-footer">
|
||||
<span className="sf3-font sf3-font-tips mr-2" style={{ color: '#999' }} id="import-export-tags-tip"></span>
|
||||
<Tooltip
|
||||
toggle={this.toggleTooltip}
|
||||
delay={{ show: 0, hide: 0 }}
|
||||
target='import-export-tags-tip'
|
||||
placement='bottom'
|
||||
isOpen={this.state.showTooltip}
|
||||
>
|
||||
{gettext('Use the import/export function to transfer tags quickly to another library. (The export is in JSON format.)')}
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
ref={ref => this.importOptionsInput = ref}
|
||||
accept='.json'
|
||||
className="d-none"
|
||||
onChange={this.importTagsInputChange}
|
||||
/>
|
||||
<span className="item-text" onClick={this.onClickImport}>{gettext('Import tags')}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<a href={this.getDownloadUrl()} download='tags.json' onClick={this.props.toggle}>
|
||||
<span className="item-text">{gettext('Export tags')}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import TagColor from '../dialog/tag-color';
|
||||
import TagName from '../dialog/tag-name';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
import './list-tag-popover.css';
|
||||
|
||||
const tagListItemPropTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired,
|
||||
onDeleteTag: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class TagListItem extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isTagHighlighted: false
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
isTagHighlighted: true
|
||||
});
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
this.setState({
|
||||
isTagHighlighted: false
|
||||
});
|
||||
};
|
||||
|
||||
deleteTag = () => {
|
||||
this.props.onDeleteTag(this.props.item);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isTagHighlighted } = this.state;
|
||||
const { item, repoID } = this.props;
|
||||
return (
|
||||
<li
|
||||
className={`tag-list-item px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
>
|
||||
<TagColor repoID={repoID} tag={item} />
|
||||
<TagName repoID={repoID} tag={item} />
|
||||
<button
|
||||
className={`tag-delete-icon sf3-font-delete1 sf3-font border-0 px-0 bg-transparent cursor-pointer ${isTagHighlighted ? '' : 'invisible'}`}
|
||||
onClick={this.deleteTag}
|
||||
aria-label={gettext('Delete')}
|
||||
title={gettext('Delete')}
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TagListItem.propTypes = tagListItemPropTypes;
|
||||
|
||||
export default TagListItem;
|
@ -1,96 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Popover, PopoverBody } from 'reactstrap';
|
||||
import { TAG_COLORS } from '../../constants';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
|
||||
export default class VirtualTagColor extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
updateVirtualTag: PropTypes.func.isRequired,
|
||||
tag: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tagColor: this.props.tag.color,
|
||||
isPopoverOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.tag.color !== this.props.tag.color) {
|
||||
this.setState({
|
||||
tagColor: nextProps.tag.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
togglePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: !this.state.isPopoverOpen
|
||||
});
|
||||
};
|
||||
|
||||
selectTagColor = (e) => {
|
||||
const newColor = e.target.value;
|
||||
this.props.updateVirtualTag(this.props.tag, { color: newColor });
|
||||
this.setState({
|
||||
tagColor: newColor,
|
||||
isPopoverOpen: !this.state.isPopoverOpen,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isPopoverOpen, tagColor } = this.state;
|
||||
const { tag } = this.props;
|
||||
const { id, color } = tag;
|
||||
|
||||
let colorList = [...TAG_COLORS];
|
||||
// for color from previous color options
|
||||
if (colorList.indexOf(color) == -1) {
|
||||
colorList.unshift(color);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span
|
||||
id={`tag-${id}-color`}
|
||||
className="tag-color cursor-pointer rounded-circle d-flex align-items-center justify-content-center"
|
||||
style={{ backgroundColor: tagColor }}
|
||||
onClick={this.togglePopover}
|
||||
>
|
||||
<i className="sf3-font sf3-font-down text-white"></i>
|
||||
</span>
|
||||
<Popover
|
||||
target={`tag-${id}-color`}
|
||||
isOpen={isPopoverOpen}
|
||||
placement="bottom"
|
||||
toggle={this.togglePopover}
|
||||
className="tag-color-popover mw-100"
|
||||
>
|
||||
<PopoverBody className="p-2">
|
||||
<div className="d-flex justify-content-between">
|
||||
{colorList.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className="tag-color-option mx-1">
|
||||
<label className="colorinput">
|
||||
<input name="color" type="radio" value={item} className="colorinput-input" defaultChecked={item == tagColor} onClick={this.selectTagColor} />
|
||||
<span className="colorinput-color rounded-circle d-flex align-items-center justify-content-center" style={{ backgroundColor: item }}>
|
||||
<i className="sf2-icon-tick color-selected"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PopoverBody>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import VirtualTagColor from './virtual-tag-color';
|
||||
import VirtualTagName from './virtual-tag-name';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
import './list-tag-popover.css';
|
||||
|
||||
export default class VirtualTagListItem extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired,
|
||||
deleteVirtualTag: PropTypes.func.isRequired,
|
||||
updateVirtualTag: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isTagHighlighted: false
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({ isTagHighlighted: true });
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
this.setState({ isTagHighlighted: false });
|
||||
};
|
||||
|
||||
deleteVirtualTag = () => {
|
||||
this.props.deleteVirtualTag(this.props.item);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isTagHighlighted } = this.state;
|
||||
const { item, repoID } = this.props;
|
||||
return (
|
||||
<li
|
||||
className={`tag-list-item px-4 d-flex justify-content-between align-items-center ${isTagHighlighted ? 'hl' : ''}`}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
>
|
||||
<VirtualTagColor repoID={repoID} tag={item} updateVirtualTag={this.props.updateVirtualTag} />
|
||||
<VirtualTagName repoID={repoID} tag={item} updateVirtualTag={this.props.updateVirtualTag} />
|
||||
<button
|
||||
className={`tag-delete-icon sf3-font-delete1 sf3-font border-0 px-0 bg-transparent cursor-pointer ${isTagHighlighted ? '' : 'invisible'}`}
|
||||
onClick={this.deleteVirtualTag}
|
||||
aria-label={gettext('Delete')}
|
||||
title={gettext('Delete')}
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import '../../css/repo-tag.css';
|
||||
|
||||
export default class VirtualTagName extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
updateVirtualTag: PropTypes.func.isRequired,
|
||||
tag: PropTypes.object.isRequired,
|
||||
repoID: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tagName: this.props.tag.name,
|
||||
isEditing: true,
|
||||
};
|
||||
this.input = React.createRef();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.tag.name !== this.props.tag.name) {
|
||||
this.setState({
|
||||
tagName: nextProps.tag.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.input.current.focus();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
toggleMode = () => {
|
||||
this.setState({
|
||||
isEditing: !this.state.isEditing
|
||||
});
|
||||
};
|
||||
|
||||
updateTagName = (e) => {
|
||||
const newName = e.target.value;
|
||||
this.props.updateVirtualTag(this.props.tag, { name: newName });
|
||||
this.setState({
|
||||
tagName: newName
|
||||
});
|
||||
};
|
||||
|
||||
onInputKeyDown = (e) => {
|
||||
if (e.key == 'Enter') {
|
||||
this.toggleMode();
|
||||
this.updateTagName(e);
|
||||
}
|
||||
else if (e.key == 'Escape') {
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
this.toggleMode();
|
||||
}
|
||||
};
|
||||
|
||||
onInputBlur = (e) => {
|
||||
this.toggleMode();
|
||||
this.updateTagName(e);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isEditing, tagName } = this.state;
|
||||
return (
|
||||
<div className="mx-2 flex-fill d-flex">
|
||||
{isEditing ?
|
||||
<input
|
||||
type="text"
|
||||
ref={this.input}
|
||||
defaultValue={tagName}
|
||||
onBlur={this.onInputBlur}
|
||||
onKeyDown={this.onInputKeyDown}
|
||||
className="flex-fill form-control-sm form-control"
|
||||
/> :
|
||||
<span
|
||||
onClick={this.toggleMode}
|
||||
className="cursor-pointer flex-fill"
|
||||
style={{ width: 100, height: 20 }}
|
||||
>{tagName}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
36
frontend/src/components/repo-info-bar-migrate.js
Normal file
36
frontend/src/components/repo-info-bar-migrate.js
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { gettext } from '../utils/constants';
|
||||
import { useMetadataStatus } from '../hooks';
|
||||
import { eventBus } from '../components/common/event-bus';
|
||||
import { EVENT_BUS_TYPE } from '../components/common/event-bus-type';
|
||||
|
||||
const RepoInfoBarMigrate = () => {
|
||||
|
||||
const { enableMetadata } = useMetadataStatus();
|
||||
|
||||
const openMigrate = () => {
|
||||
eventBus.dispatch(EVENT_BUS_TYPE.OPEN_TREE_PANEL, () => eventBus.dispatch(EVENT_BUS_TYPE.OPEN_LIBRARY_SETTINGS_TAGS));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="repo-info-bar-migrate mt-2">
|
||||
{enableMetadata ?
|
||||
(
|
||||
<>
|
||||
{gettext('Tips: There are tags of old version. Please migrate tags to new version.')}
|
||||
<Button color="link" size="sm" tag="a" onClick={openMigrate}>{gettext('Migrate')}</Button>
|
||||
</>
|
||||
) :
|
||||
(
|
||||
<>{gettext('Tips: These are tags of old version. The feature is deprecated and can no longer be used.')}</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RepoInfoBarMigrate.propTypes = {
|
||||
};
|
||||
|
||||
export default RepoInfoBarMigrate;
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ModalPortal from './modal-portal';
|
||||
import ListTaggedFilesDialog from './dialog/list-taggedfiles-dialog';
|
||||
import RepoInfoBarMigrate from './repo-info-bar-migrate';
|
||||
|
||||
import '../css/repo-info-bar.css';
|
||||
|
||||
@ -39,7 +40,7 @@ class RepoInfoBar extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
let { repoID, usedRepoTags, className } = this.props;
|
||||
let { repoID, usedRepoTags, className, shareLinkToken } = this.props;
|
||||
|
||||
return (
|
||||
<div className={`repo-info-bar ${className ? className : ''}`}>
|
||||
@ -67,11 +68,12 @@ class RepoInfoBar extends React.Component {
|
||||
toggleCancel={this.onListTaggedFiles}
|
||||
updateUsedRepoTags={this.props.updateUsedRepoTags}
|
||||
onFileTagChanged={this.props.onFileTagChanged}
|
||||
shareLinkToken={this.props.shareLinkToken}
|
||||
shareLinkToken={shareLinkToken}
|
||||
enableFileDownload={this.props.enableFileDownload}
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{!shareLinkToken && <RepoInfoBarMigrate />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
|
||||
.wiki-page-container .article .ml-2:hover {
|
||||
text-decoration: underline;
|
||||
color: #eb8205;
|
||||
color: #EC8000;
|
||||
}
|
||||
|
||||
#wiki-page-last-modified {
|
||||
|
@ -0,0 +1,152 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import UserItem from './user-item';
|
||||
import { seafileAPI } from '../../../utils/seafile-api';
|
||||
import ModalPortal from '../../modal-portal';
|
||||
import toaster from '../../toast';
|
||||
import { SEARCH_FILTERS_KEY } from '../../../constants';
|
||||
|
||||
const FilterByCreator = ({ creatorList, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState([]);
|
||||
const [selectedOptions, setSelectedOptions] = useState(creatorList || []);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [inputFocus, setInputFocus] = useState(false);
|
||||
|
||||
const toggle = useCallback((e) => {
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const displayOptions = useMemo(() => {
|
||||
if (!searchValue) return null;
|
||||
return options.filter((option) => {
|
||||
return option.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [options, searchValue]);
|
||||
|
||||
const onChangeOption = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const name = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
|
||||
let updated = [...selectedOptions];
|
||||
if (!updated.some((item) => item.name === name)) {
|
||||
const newOption = options.find((option) => option.name === name);
|
||||
updated = [...updated, newOption];
|
||||
} else {
|
||||
updated = updated.filter((option) => option.name !== name);
|
||||
}
|
||||
setSelectedOptions(updated);
|
||||
onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated);
|
||||
if (displayOptions.length === 1) {
|
||||
setSearchValue('');
|
||||
}
|
||||
}, [selectedOptions, displayOptions, options, onChange]);
|
||||
|
||||
const handleCancel = useCallback((e, name) => {
|
||||
const updated = selectedOptions.filter((option) => option.name !== name);
|
||||
setSelectedOptions(updated);
|
||||
onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated);
|
||||
}, [selectedOptions, onChange]);
|
||||
|
||||
const handleInputChange = useCallback((e) => {
|
||||
const v = e.target.value;
|
||||
setSearchValue(v);
|
||||
if (!selectedOptions) {
|
||||
setOptions([]);
|
||||
}
|
||||
}, [selectedOptions]);
|
||||
|
||||
const handleInputKeyDown = useCallback((e) => {
|
||||
if (isHotkey('enter')(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSearchValue('');
|
||||
toggle();
|
||||
}
|
||||
}, [toggle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchValue) return;
|
||||
|
||||
const getUsers = async () => {
|
||||
try {
|
||||
const res = await seafileAPI.searchUsers(searchValue);
|
||||
const userList = res.data.users
|
||||
.filter(user => user.name.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
|
||||
setOptions(userList);
|
||||
} catch (err) {
|
||||
toaster.danger(Utils.getErrorMsg(err));
|
||||
}
|
||||
};
|
||||
|
||||
getUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<div className="search-filter filter-by-creator-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && selectedOptions.length > 0,
|
||||
'highlighted': selectedOptions.length > 0,
|
||||
})}>
|
||||
<div className="filter-label" title={gettext('Creator')}>{gettext('Creator')}</div>
|
||||
<i className="sf3-font sf3-font-down sf3-font pl-1" />
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-creator-menu">
|
||||
<div className={classNames('input-container', { 'focus': inputFocus })}>
|
||||
{selectedOptions.map((option) => (
|
||||
<UserItem
|
||||
key={option.name}
|
||||
user={option}
|
||||
isCancellable={true}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedOptions.length ? '' : gettext('Search user')}
|
||||
value={searchValue}
|
||||
autoFocus
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{displayOptions && displayOptions.map((option) => (
|
||||
<DropdownItem
|
||||
key={option.name}
|
||||
tag="div"
|
||||
tabIndex="-1"
|
||||
data-toggle={option.name}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onChangeOption}
|
||||
toggle={false}
|
||||
>
|
||||
{isOpen && <UserItem user={option} />}
|
||||
{selectedOptions.includes(option.name) && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterByCreator.propTypes = {
|
||||
creatorList: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterByCreator;
|
272
frontend/src/components/search/search-filters/filter-by-date.js
Normal file
272
frontend/src/components/search/search-filters/filter-by-date.js
Normal file
@ -0,0 +1,272 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import dayjs from 'dayjs';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import Picker from '../../date-and-time-picker';
|
||||
import ModalPortal from '../../modal-portal';
|
||||
import { SEARCH_FILTERS_KEY, SEARCH_FILTER_BY_DATE_OPTION_KEY, SEARCH_FILTER_BY_DATE_TYPE_KEY } from '../../../constants';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const DATE_INPUT_WIDTH = 118;
|
||||
|
||||
const FilterByDate = ({ date, onChange }) => {
|
||||
const [value, setValue] = useState(date.value);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isTypeOpen, setIsTypeOpen] = useState(false);
|
||||
const [isCustomDate, setIsCustomDate] = useState(date.value === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM);
|
||||
const [time, setTime] = useState({
|
||||
from: date.from,
|
||||
to: date.to,
|
||||
});
|
||||
const [type, setType] = useState(date.type);
|
||||
|
||||
const typeLabel = useMemo(() => {
|
||||
switch (type) {
|
||||
case SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME:
|
||||
return gettext('Create time');
|
||||
case SEARCH_FILTER_BY_DATE_TYPE_KEY.LAST_MODIFIED_TIME:
|
||||
return gettext('Last modified time');
|
||||
default:
|
||||
return gettext('Create time');
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (!value || value.length === 0) return gettext('Date');
|
||||
return typeLabel;
|
||||
}, [typeLabel, value]);
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME,
|
||||
label: gettext('Create time'),
|
||||
}, {
|
||||
key: SEARCH_FILTER_BY_DATE_TYPE_KEY.LAST_MODIFIED_TIME,
|
||||
label: gettext('Last modified time'),
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY,
|
||||
label: gettext('Today'),
|
||||
}, {
|
||||
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS,
|
||||
label: gettext('Last 7 days'),
|
||||
}, {
|
||||
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS,
|
||||
label: gettext('Last 30 days'),
|
||||
},
|
||||
'Divider',
|
||||
{
|
||||
key: SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM,
|
||||
label: gettext('Custom time'),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
|
||||
|
||||
const toggleType = useCallback(() => setIsTypeOpen(!isTypeOpen), [isTypeOpen]);
|
||||
|
||||
const onChangeType = useCallback((e) => {
|
||||
const option = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
|
||||
if (option === type) return;
|
||||
setType(option);
|
||||
}, [type]);
|
||||
|
||||
const onClearDate = useCallback(() => {
|
||||
setValue('');
|
||||
setIsCustomDate(false);
|
||||
setTime({
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const onOptionClick = useCallback((e) => {
|
||||
const option = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
|
||||
if (option === value) return;
|
||||
const today = dayjs().endOf('day');
|
||||
const isCustomOption = option === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM;
|
||||
setIsCustomDate(isCustomOption);
|
||||
setValue(option);
|
||||
setIsOpen(isCustomOption);
|
||||
switch (option) {
|
||||
case SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY: {
|
||||
setTime({
|
||||
from: dayjs().startOf('day').unix(),
|
||||
to: today.unix()
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS: {
|
||||
setTime({
|
||||
from: dayjs().subtract(6, 'day').startOf('day').unix(),
|
||||
to: today.unix()
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS: {
|
||||
setTime({
|
||||
from: dayjs().subtract(30, 'day').startOf('day').unix(),
|
||||
to: today.unix()
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM: {
|
||||
setTime({
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const disabledStartDate = useCallback((startDate) => {
|
||||
if (!startDate) return false;
|
||||
const today = dayjs();
|
||||
const endValue = time.to;
|
||||
|
||||
if (!endValue) {
|
||||
return startDate.isAfter(today);
|
||||
}
|
||||
return endValue.isBefore(startDate) || startDate.isAfter(today);
|
||||
}, [time]);
|
||||
|
||||
const disabledEndDate = useCallback((endDate) => {
|
||||
if (!endDate) return false;
|
||||
const today = dayjs();
|
||||
const startValue = time.from;
|
||||
if (!startValue) {
|
||||
return endDate.isAfter(today);
|
||||
}
|
||||
return endDate.isBefore(startValue) || endDate.isAfter(today);
|
||||
}, [time]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (type !== date.type || time.from !== date.from || time.to !== date.to) {
|
||||
onChange(SEARCH_FILTERS_KEY.DATE, {
|
||||
type,
|
||||
value,
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, date, time, type, value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="search-filter filter-by-date-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && value,
|
||||
'highlighted': value,
|
||||
})} onClick={toggle}>
|
||||
<div className="filter-label" style={{ maxWidth: 300 }} title={label}>{label}</div>
|
||||
<i
|
||||
className="sf3-font sf3-font-down pl-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-date-menu">
|
||||
<div className="filter-by-date-menu-toolbar">
|
||||
<Dropdown isOpen={isTypeOpen} toggle={toggleType}>
|
||||
<DropdownToggle tag="div" className="search-filter-toggle filter-by-date-type-toggle">
|
||||
<div className="filter-label">{typeLabel}</div>
|
||||
<i
|
||||
className="sf3-font sf3-font-down pl-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleType();
|
||||
}}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
{typeOptions.map((option) => {
|
||||
const isSelected = option.key === type;
|
||||
return (
|
||||
<DropdownItem key={option.key} data-toggle={option.key} onClick={onChangeType}>
|
||||
{option.label}
|
||||
{isSelected && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<div className="delete-btn" onClick={onClearDate}>
|
||||
<i className="op-icon sf3-font-delete1 sf3-font"></i>
|
||||
</div>
|
||||
</div>
|
||||
{options.map((option, i) => {
|
||||
const isSelected = option.key === value;
|
||||
if (option === 'Divider') return <div key={i} className="seafile-divider dropdown-divider"></div>;
|
||||
return (
|
||||
<DropdownItem
|
||||
key={option.key}
|
||||
tag="div"
|
||||
tabIndex="-1"
|
||||
data-toggle={option.key}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onOptionClick}
|
||||
toggle={false}
|
||||
>
|
||||
{option.label}
|
||||
{isSelected && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
{isCustomDate && (
|
||||
<div className="filter-by-date-custom-date-container">
|
||||
<div className="custom-date-container">
|
||||
<div className="custom-date-label">{gettext('Start date')}</div>
|
||||
<Picker
|
||||
showHourAndMinute={false}
|
||||
disabledDate={disabledStartDate}
|
||||
value={time.from}
|
||||
onChange={(value) => setTime({ ...time, from: value })}
|
||||
inputWidth={DATE_INPUT_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
<div className="custom-date-container">
|
||||
<div className="custom-date-label">{gettext('End date')}</div>
|
||||
<Picker
|
||||
showHourAndMinute={false}
|
||||
disabledDate={disabledEndDate}
|
||||
value={time.to}
|
||||
onChange={(value) => setTime({ ...time, to: value })}
|
||||
inputWidth={DATE_INPUT_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterByDate.propTypes = {
|
||||
date: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
start: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
|
||||
end: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
|
||||
}),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterByDate;
|
@ -0,0 +1,81 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import ModalPortal from '../../modal-portal';
|
||||
import { SEARCH_FILTERS_KEY } from '../../../constants';
|
||||
|
||||
const FilterBySuffix = ({ suffixes, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(suffixes);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
|
||||
|
||||
const handleInput = useCallback((e) => {
|
||||
setInputValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearInput = useCallback(() => {
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && inputValue !== suffixes) {
|
||||
onChange(SEARCH_FILTERS_KEY.SUFFIXES, inputValue.replace(/\./g, ''));
|
||||
}
|
||||
}, [isOpen, inputValue, suffixes, onChange]);
|
||||
|
||||
return (
|
||||
<div className="search-filter filter-by-suffix-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && inputValue.length > 0,
|
||||
'highlighted': inputValue.length > 0,
|
||||
})} onClick={toggle}>
|
||||
<div className="filter-label" title={gettext('File suffix')}>{gettext('File suffix')}</div>
|
||||
<i className="sf3-font sf3-font-down pl-1" />
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-suffix-menu p-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={gettext('Seperate multiple suffixes by ","(like sdoc, pdf)')}
|
||||
value={inputValue}
|
||||
autoFocus
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{inputValue.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-icon-right sf3-font sf3-font-x-01"
|
||||
onClick={handleClearInput}
|
||||
aria-label={gettext('Clear')}
|
||||
>
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterBySuffix.propTypes = {
|
||||
suffixes: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterBySuffix;
|
@ -0,0 +1,65 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import ModalPortal from '../../../components/modal-portal';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { SEARCH_FILTERS_KEY } from '../../../constants';
|
||||
|
||||
const FilterByText = ({ searchFilenameOnly, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState(searchFilenameOnly ? SEARCH_FILTERS_KEY.SEARCH_FILENAME_ONLY : SEARCH_FILTERS_KEY.SEARCH_FILENAME_AND_CONTENT);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: SEARCH_FILTERS_KEY.SEARCH_FILENAME_AND_CONTENT,
|
||||
label: gettext('File name and content'),
|
||||
}, {
|
||||
key: SEARCH_FILTERS_KEY.SEARCH_FILENAME_ONLY,
|
||||
label: gettext('File name only'),
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
|
||||
|
||||
const onOptionClick = useCallback((e) => {
|
||||
const option = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle');
|
||||
setValue(option);
|
||||
const isSearchFilenameOnly = option === SEARCH_FILTERS_KEY.SEARCH_FILENAME_ONLY;
|
||||
onChange(SEARCH_FILTERS_KEY.SEARCH_FILENAME_ONLY, isSearchFilenameOnly);
|
||||
}, [onChange]);
|
||||
|
||||
const label = options.find((option) => option.key === value).label;
|
||||
|
||||
return (
|
||||
<div className="search-filter filter-by-text-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className="search-filter-toggle">
|
||||
<div className="filter-label" title={label}>{label}</div>
|
||||
<i className="sf3-font sf3-font-down sf3-font pl-1" />
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-text-menu">
|
||||
{options.map((option) => {
|
||||
const isSelected = option.key === value;
|
||||
return (
|
||||
<DropdownItem key={option.key} data-toggle={option.key} onClick={onOptionClick}>
|
||||
{option.label}
|
||||
{isSelected && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterByText.propTypes = {
|
||||
searchFilenameOnly: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterByText;
|
233
frontend/src/components/search/search-filters/index.css
Normal file
233
frontend/src/components/search/search-filters/index.css
Normal file
@ -0,0 +1,233 @@
|
||||
.search-filters-container {
|
||||
min-height: 32px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding: 0 16px 10px;
|
||||
overflow: auto hidden;
|
||||
scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0);
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter .search-filter-toggle,
|
||||
.search-filter-menu .search-filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter .search-filter-toggle:hover,
|
||||
.search-filter-menu .search-filter-toggle:hover {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.search-filter-toggle .sf3-font-down {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter .filter-label {
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter.filter-by-suffix-container .filter-label,
|
||||
.search-filters-container .search-filter.filter-by-creator-container .filter-label {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.search-filters-container .dropdown-menu {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.search-filter-menu.filter-by-text-menu,
|
||||
.search-filter-menu.filter-by-date-menu,
|
||||
.search-filter-menu.filter-by-creator-menu {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.search-filter-menu.filter-by-suffix-menu {
|
||||
width: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-filter-menu.filter-by-suffix-menu .clear-icon-right {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
margin: 9px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-filter-menu.filter-by-suffix-menu .clear-icon-right:hover {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filters-dropdown-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-filter-menu {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.search-filter-menu .dropdown-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-filter-menu .dropdown-item .dropdown-item-tick {
|
||||
width: 1rem;
|
||||
left: auto;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.search-filter-menu .input-container {
|
||||
position: relative;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
margin: 8px 16px;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-filter-menu .input-container.focus {
|
||||
background-color: #fff;
|
||||
border-color: #1991eb;
|
||||
box-shadow: 0 0 0 2px rgba(70, 127, 207, .25);
|
||||
color: #495057;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.search-filter-menu .input-container .search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
line-height: 20px;
|
||||
flex: 1 1 60px;
|
||||
min-width: 60px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-filter-menu .input-container input {
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
display: block;
|
||||
resize: none;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.search-filter-menu .user-item {
|
||||
height: 20px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.search-filter-menu .user-item .user-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.search-filter-menu .input-container .user-item {
|
||||
background-color: #eaeaea;
|
||||
border-radius: 10px;
|
||||
padding: 0 4px 0 2px;
|
||||
margin-right: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-menu-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle .filter-label {
|
||||
color: #7d7d7d;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-menu-toolbar .delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-custom-date-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .custom-date-label {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .form-control {
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter-toggle.active:hover,
|
||||
.search-filters-container .search-filter-toggle.highlighted:hover {
|
||||
background-color: rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
|
||||
.search-filters-container .search-filter-toggle.highlighted,
|
||||
.search-filter-toggle.highlighted .sf3-font-down {
|
||||
color: #ff9800;
|
||||
}
|
26
frontend/src/components/search/search-filters/index.js
Normal file
26
frontend/src/components/search/search-filters/index.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FilterByText from './filter-by-text';
|
||||
import FilterByCreator from './filter-by-creator';
|
||||
import FilterByDate from './filter-by-date';
|
||||
import FilterBySuffix from './filter-by-suffix';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const SearchFilters = ({ filters, onChange }) => {
|
||||
return (
|
||||
<div className="search-filters-container">
|
||||
<FilterBySuffix suffixes={filters.suffixes} onChange={onChange} />
|
||||
<FilterByText searchFilenameOnly={filters.search_filename_only} onChange={onChange} />
|
||||
<FilterByCreator creatorList={filters.creator_list} onChange={onChange} />
|
||||
<FilterByDate date={filters.date} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SearchFilters.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SearchFilters;
|
22
frontend/src/components/search/search-filters/user-item.js
Normal file
22
frontend/src/components/search/search-filters/user-item.js
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { mediaUrl } from '../../../utils/constants';
|
||||
import IconBtn from '../../icon-btn';
|
||||
|
||||
const UserItem = ({ user, isCancellable, onCancel }) => {
|
||||
return (
|
||||
<div className="user-item">
|
||||
<img src={user.avatar_url || `${mediaUrl}avatars/default.png`} alt={user.name} className="user-avatar" />
|
||||
<span className="user-name">{user.name}</span>
|
||||
{isCancellable && <IconBtn className="user-remove" onClick={(e) => onCancel(e, user.name)} symbol="x-01" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UserItem.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
isCancellable: PropTypes.bool,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
|
||||
export default UserItem;
|
58
frontend/src/components/search/search-tags/index.css
Normal file
58
frontend/src/components/search/search-tags/index.css
Normal file
@ -0,0 +1,58 @@
|
||||
.search-tags-container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.search-tags-container .tags-title {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: 0 16px;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.search-tags-container .tags-content {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0);
|
||||
padding: 0 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-tags-container .tags-content .tag-item {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-tags-container .tags-content .tag-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-tags-container .tags-content .tag-item .tag-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-tags-container .search-tags-divider {
|
||||
height: 0;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 0 16px;
|
||||
}
|
70
frontend/src/components/search/search-tags/index.js
Normal file
70
frontend/src/components/search/search-tags/index.js
Normal file
@ -0,0 +1,70 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../../utils/constants';
|
||||
import { getTagColor, getTagId, getTagName } from '../../../tag/utils/cell';
|
||||
import { PRIVATE_FILE_TYPE } from '../../../constants';
|
||||
import { EVENT_BUS_TYPE } from '../../common/event-bus-type';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const SearchTags = ({ repoID, tagsData, keyword, onSelectTag }) => {
|
||||
const [displayTags, setDisplayTags] = useState([]);
|
||||
|
||||
const handleClick = useCallback((e, tagId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const node = {
|
||||
children: [],
|
||||
path: '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + tagId,
|
||||
isExpanded: false,
|
||||
isLoaded: true,
|
||||
isPreload: true,
|
||||
object: {
|
||||
file_tags: [],
|
||||
id: tagId,
|
||||
type: PRIVATE_FILE_TYPE.TAGS_PROPERTIES,
|
||||
isDir: () => false,
|
||||
},
|
||||
parentNode: {},
|
||||
key: repoID,
|
||||
tag_id: tagId,
|
||||
};
|
||||
onSelectTag(node);
|
||||
window.sfTagsDataContext?.eventBus?.dispatch(EVENT_BUS_TYPE.UPDATE_SELECTED_TAG, tagId);
|
||||
}, [repoID, onSelectTag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagsData || tagsData.length === 0 || !keyword) return;
|
||||
const tags = tagsData?.filter((tag) => getTagName(tag).toLowerCase().includes(keyword.toLowerCase()));
|
||||
setDisplayTags(tags);
|
||||
}, [tagsData, keyword]);
|
||||
|
||||
if (!tagsData || tagsData.length === 0 || !keyword || displayTags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="search-tags-container">
|
||||
<div className="tags-title">{gettext('Tags')}</div>
|
||||
<div className="tags-content">
|
||||
{displayTags.map((tag) => {
|
||||
const tagId = getTagId(tag);
|
||||
const tagName = getTagName(tag);
|
||||
const tagColor = getTagColor(tag);
|
||||
return (
|
||||
<div className="tag-item" key={tagId} onClick={(e) => handleClick(e, tagId)}>
|
||||
<div className="tag-color" style={{ backgroundColor: tagColor }} />
|
||||
<div className="tag-name">{tagName}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="search-tags-divider" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SearchTags.propTypes = {
|
||||
tagsData: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default SearchTags;
|
@ -12,7 +12,10 @@ import { Utils } from '../../utils/utils';
|
||||
import toaster from '../toast';
|
||||
import Loading from '../loading';
|
||||
import { SEARCH_MASK, SEARCH_CONTAINER } from '../../constants/zIndexes';
|
||||
import { PRIVATE_FILE_TYPE } from '../../constants';
|
||||
import { PRIVATE_FILE_TYPE, SEARCH_FILTER_BY_DATE_OPTION_KEY, SEARCH_FILTER_BY_DATE_TYPE_KEY, SEARCH_FILTERS_KEY, SEARCH_FILTERS_SHOW_KEY } from '../../constants';
|
||||
import SearchFilters from './search-filters';
|
||||
import SearchTags from './search-tags';
|
||||
import IconBtn from '../icon-btn';
|
||||
|
||||
const propTypes = {
|
||||
repoID: PropTypes.string,
|
||||
@ -21,6 +24,7 @@ const propTypes = {
|
||||
onSearchedClick: PropTypes.func.isRequired,
|
||||
isPublic: PropTypes.bool,
|
||||
isViewFile: PropTypes.bool,
|
||||
onSelectTag: PropTypes.func,
|
||||
};
|
||||
|
||||
const PER_PAGE = 20;
|
||||
@ -49,6 +53,19 @@ class Search extends Component {
|
||||
isSearchInputShow: false, // for mobile
|
||||
searchTypesMax: 0,
|
||||
highlightSearchTypesIndex: 0,
|
||||
isFiltersShow: true,
|
||||
isFilterControllerActive: false,
|
||||
filters: {
|
||||
search_filename_only: false,
|
||||
creator_list: [],
|
||||
date: {
|
||||
type: SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME,
|
||||
value: '',
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
suffixes: '',
|
||||
},
|
||||
};
|
||||
this.highlightRef = null;
|
||||
this.source = null; // used to cancel request;
|
||||
@ -64,6 +81,8 @@ class Search extends Component {
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
document.addEventListener('compositionstart', this.onCompositionStart);
|
||||
document.addEventListener('compositionend', this.onCompositionEnd);
|
||||
const isFiltersShow = localStorage.getItem(SEARCH_FILTERS_SHOW_KEY) === 'true';
|
||||
this.setState({ isFiltersShow });
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
@ -119,7 +138,7 @@ class Search extends Component {
|
||||
};
|
||||
|
||||
onFocusHandler = () => {
|
||||
this.setState({ width: '570px', isMaskShow: true, isCloseShow: true });
|
||||
this.setState({ width: '570px', isMaskShow: true });
|
||||
this.calculateHighlightType();
|
||||
};
|
||||
|
||||
@ -355,7 +374,7 @@ class Search extends Component {
|
||||
if (this.state.showRecent) {
|
||||
this.setState({ showRecent: false });
|
||||
}
|
||||
this.setState({ value: newValue });
|
||||
this.setState({ value: newValue, isCloseShow: newValue.length > 0 });
|
||||
setTimeout(() => {
|
||||
const trimmedValue = newValue.trim();
|
||||
const isInRepo = this.props.repoID;
|
||||
@ -415,7 +434,7 @@ class Search extends Component {
|
||||
this.queryData = queryData;
|
||||
|
||||
if (isPublic) {
|
||||
seafileAPI.searchFilesInPublishedRepo(queryData.search_repo, queryData.q, page, PER_PAGE).then(res => {
|
||||
seafileAPI.searchFilesInPublishedRepo(queryData.search_repo, queryData.q, page, PER_PAGE, queryData.search_filename_only).then(res => {
|
||||
this.source = null;
|
||||
if (res.data.total > 0) {
|
||||
this.setState({
|
||||
@ -483,6 +502,8 @@ class Search extends Component {
|
||||
items[i]['link_content'] = decodeURI(data[i].fullpath).substring(1);
|
||||
items[i]['content'] = data[i].content_highlight;
|
||||
items[i]['thumbnail_url'] = data[i].thumbnail_url;
|
||||
items[i]['mtime'] = data[i].mtime || '';
|
||||
items[i]['repo_owner_email'] = data[i].repo_owner_email || '';
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@ -499,6 +520,18 @@ class Search extends Component {
|
||||
highlightIndex: 0,
|
||||
isSearchInputShow: false,
|
||||
showRecent: true,
|
||||
isFilterControllerActive: false,
|
||||
filters: {
|
||||
search_filename_only: false,
|
||||
creator_list: [],
|
||||
date: {
|
||||
type: SEARCH_FILTER_BY_DATE_TYPE_KEY.CREATE_TIME,
|
||||
value: '',
|
||||
start: null,
|
||||
end: null,
|
||||
},
|
||||
suffixes: '',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -510,6 +543,7 @@ class Search extends Component {
|
||||
resultItems: [],
|
||||
highlightIndex: 0,
|
||||
isSearchInputShow: false,
|
||||
isCloseShow: false,
|
||||
});
|
||||
}
|
||||
|
||||
@ -524,6 +558,7 @@ class Search extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = this.filterByCreator(resultItems);
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@ -533,8 +568,8 @@ class Search extends Component {
|
||||
else if (!isResultGotten) {
|
||||
return this.renderSearchTypes(this.state.inputValue.trim());
|
||||
}
|
||||
else if (resultItems.length > 0) {
|
||||
return this.renderResults(resultItems);
|
||||
else if (filteredItems.length > 0) {
|
||||
return this.renderResults(filteredItems);
|
||||
}
|
||||
else {
|
||||
return <div className="search-result-none">{gettext('No results matching')}</div>;
|
||||
@ -587,7 +622,8 @@ class Search extends Component {
|
||||
if (this.props.repoID) {
|
||||
const { path } = this.props;
|
||||
const isMetadataView = path && path.startsWith('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES);
|
||||
if (path && path !== '/' && !this.props.isViewFile && !isMetadataView) {
|
||||
const isTagView = path && path.startsWith('/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES);
|
||||
if (path && path !== '/' && !this.props.isViewFile && !isMetadataView && !isTagView) {
|
||||
return (
|
||||
<div className="search-types">
|
||||
<div className={`search-types-repo ${highlightIndex === 0 ? 'search-types-highlight' : ''}`} onClick={this.searchRepo} tabIndex={0}>
|
||||
@ -638,7 +674,7 @@ class Search extends Component {
|
||||
search_repo: this.props.repoID,
|
||||
search_ftypes: 'all',
|
||||
};
|
||||
this.getSearchResult(queryData);
|
||||
this.getSearchResult(this.buildSearchParams(queryData));
|
||||
};
|
||||
|
||||
searchFolder = () => {
|
||||
@ -649,7 +685,7 @@ class Search extends Component {
|
||||
search_ftypes: 'all',
|
||||
search_path: this.props.path,
|
||||
};
|
||||
this.getSearchResult(queryData);
|
||||
this.getSearchResult(this.buildSearchParams(queryData));
|
||||
};
|
||||
|
||||
searchAllRepos = () => {
|
||||
@ -659,7 +695,7 @@ class Search extends Component {
|
||||
search_repo: 'all',
|
||||
search_ftypes: 'all',
|
||||
};
|
||||
this.getSearchResult(queryData);
|
||||
this.getSearchResult(this.buildSearchParams(queryData));
|
||||
};
|
||||
|
||||
renderResults = (resultItems, isVisited) => {
|
||||
@ -688,6 +724,7 @@ class Search extends Component {
|
||||
return (
|
||||
<>
|
||||
<MediaQuery query="(min-width: 768px)">
|
||||
{!isVisited && <h4 className="search-results-title">{gettext('Files')}</h4>}
|
||||
<div className="search-result-list-container" ref={this.searchResultListContainerRef}>{results}</div>
|
||||
</MediaQuery>
|
||||
<MediaQuery query="(max-width: 767.8px)">
|
||||
@ -704,11 +741,97 @@ class Search extends Component {
|
||||
});
|
||||
};
|
||||
|
||||
handleFiltersShow = () => {
|
||||
const { isFiltersShow } = this.state;
|
||||
localStorage.setItem(SEARCH_FILTERS_SHOW_KEY, !isFiltersShow);
|
||||
this.setState({ isFiltersShow: !isFiltersShow });
|
||||
}
|
||||
|
||||
buildSearchParams = (baseParams) => {
|
||||
const { filters } = this.state;
|
||||
const params = { ...baseParams };
|
||||
|
||||
if (filters.search_filename_only) {
|
||||
params.search_filename_only = filters.search_filename_only;
|
||||
}
|
||||
|
||||
if (filters.date.value) {
|
||||
const isCustom = filters.date.value === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM;
|
||||
params.time_from = isCustom ? filters.date.start?.unix() : filters.date.from;
|
||||
params.time_to = isCustom ? filters.date.end?.unix() : filters.date.to;
|
||||
}
|
||||
|
||||
if (filters.suffixes) {
|
||||
params.input_fexts = filters.suffixes;
|
||||
params.search_ftypes = 'custom';
|
||||
}
|
||||
|
||||
if (filters.creator_list.length > 0) {
|
||||
params.creator_emails = filters.creator_list.map(c => c.email).join(',');
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
handleFiltersChange = (key, value) => {
|
||||
const newFilters = { ...this.state.filters, [key]: value };
|
||||
const hasActiveFilter = newFilters.suffixes || newFilters.creator_list.length > 0 || newFilters.date.value;
|
||||
this.setState({ filters: newFilters, isFilterControllerActive: hasActiveFilter });
|
||||
|
||||
// build query data
|
||||
if (!this.state.value || !this.state.isResultGotten) return;
|
||||
const queryUpdates = {};
|
||||
|
||||
if (key === SEARCH_FILTERS_KEY.CREATOR_LIST) return;
|
||||
if (key === SEARCH_FILTERS_KEY.SEARCH_FILENAME_ONLY) {
|
||||
queryUpdates.search_filename_only = value;
|
||||
}
|
||||
if (key === SEARCH_FILTERS_KEY.SUFFIXES) {
|
||||
queryUpdates.search_ftypes = 'custom';
|
||||
queryUpdates.input_fexts = value;
|
||||
if (!value) {
|
||||
queryUpdates.search_ftypes = 'all';
|
||||
}
|
||||
}
|
||||
if (key === SEARCH_FILTERS_KEY.DATE) {
|
||||
const date = value;
|
||||
const isCustom = date.value === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM;
|
||||
queryUpdates.time_from = isCustom ? value.from?.unix() : value.from;
|
||||
queryUpdates.time_to = isCustom ? value.to?.unix() : value.to;
|
||||
}
|
||||
|
||||
const newQueryData = {
|
||||
...this.queryData,
|
||||
...queryUpdates,
|
||||
};
|
||||
|
||||
this.getSearchResult(newQueryData);
|
||||
}
|
||||
|
||||
filterByCreator = (results) => {
|
||||
const { filters } = this.state;
|
||||
return results.filter(item => {
|
||||
if (filters.creator_list && filters.creator_list.length > 0) {
|
||||
if (!filters.creator_list.some(creator => creator.email === item.repo_owner_email)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
handleSelectTag = (tag) => {
|
||||
this.props.onSelectTag(tag);
|
||||
this.resetToDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
let width = this.state.width !== 'default' ? this.state.width : '';
|
||||
let style = {'width': width};
|
||||
const { isMaskShow } = this.state;
|
||||
const { repoID, isTagEnabled, tagsData } = this.props;
|
||||
const { isMaskShow, isResultGotten, isCloseShow, isFiltersShow, isFilterControllerActive, filters } = this.state;
|
||||
const placeholder = `${this.props.placeholder}${isMaskShow ? '' : ` (${controlKey} + k)`}`;
|
||||
const isTagsShow = this.props.repoID && isTagEnabled && isMaskShow && isResultGotten;
|
||||
return (
|
||||
<Fragment>
|
||||
<MediaQuery query="(min-width: 768px)">
|
||||
@ -729,15 +852,33 @@ class Search extends Component {
|
||||
autoComplete="off"
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
{this.state.isCloseShow &&
|
||||
{isCloseShow &&
|
||||
<button
|
||||
type="button"
|
||||
className="search-icon-right input-icon-addon sf3-font sf3-font-x-01"
|
||||
className="search-icon-right sf3-font sf3-font-x-01"
|
||||
onClick={this.onClearSearch}
|
||||
aria-label={gettext('Clear search')}
|
||||
></button>
|
||||
}
|
||||
{isMaskShow && (
|
||||
<IconBtn
|
||||
symbol="filter-circled"
|
||||
size={20}
|
||||
className={classnames('search-icon-right input-icon-addon search-filter-controller', { 'active': isFilterControllerActive })}
|
||||
onClick={this.handleFiltersShow}
|
||||
title={isFiltersShow ? gettext('Hide advanced search') : gettext('Show advanced search')}
|
||||
aria-label={isFiltersShow ? gettext('Hide advanced search') : gettext('Show advanced search')}
|
||||
tabIndex={0}
|
||||
id="search-filter-controller"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isMaskShow && isFiltersShow &&
|
||||
<SearchFilters filters={filters} onChange={this.handleFiltersChange} />
|
||||
}
|
||||
{isTagsShow &&
|
||||
<SearchTags repoID={repoID} tagsData={tagsData} keyword={this.state.value} onSelectTag={this.handleSelectTag} />
|
||||
}
|
||||
<div
|
||||
className="search-result-container dropdown-search-result-container"
|
||||
ref={this.searchContainer}
|
||||
|
@ -8,7 +8,7 @@ import DeleteTags from './delete-tags';
|
||||
import { Utils } from '../../../../utils/utils';
|
||||
import { KeyCodes } from '../../../../constants';
|
||||
import { gettext } from '../../../../utils/constants';
|
||||
import { getTagColor, getTagId, getTagName, getTagsByNameOrColor } from '../../../../tag/utils/cell';
|
||||
import { getTagColor, getTagId, getTagName, getTagsByName } from '../../../../tag/utils/cell';
|
||||
import { getRecordIdFromRecord } from '../../../../metadata/utils/cell';
|
||||
import { SELECT_OPTION_COLORS } from '../../../../metadata/constants';
|
||||
import { getRowById } from '../../utils/table';
|
||||
@ -38,7 +38,7 @@ const TagsEditor = ({
|
||||
const editorRef = useRef(null);
|
||||
const selectItemRef = useRef(null);
|
||||
|
||||
const displayTags = useMemo(() => getTagsByNameOrColor(allTagsRef.current, searchValue), [searchValue, allTagsRef]);
|
||||
const displayTags = useMemo(() => getTagsByName(allTagsRef.current, searchValue), [searchValue, allTagsRef]);
|
||||
|
||||
const isShowCreateBtn = useMemo(() => {
|
||||
if (!canAddTag || !searchValue || !Utils.isFunction(addNewTag)) return false;
|
||||
|
@ -38,6 +38,11 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sf-table-result-container.windows-browser::-webkit-scrollbar,
|
||||
.sf-table-canvas::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sf-table-result-container.record-draggable .sf-table-row:hover .drag-handler {
|
||||
background-image: url(../../../../../../../media/img/grippy_large.png);
|
||||
background-repeat: no-repeat;
|
||||
|
@ -185,3 +185,16 @@ export const getTreeChildNodes = (parentNode, tree) => {
|
||||
}
|
||||
return childNodes;
|
||||
};
|
||||
|
||||
export const getNodesWithAncestors = (node, tree) => {
|
||||
const nodeKey = getTreeNodeKey(node);
|
||||
|
||||
let nodesWithAncestors = [];
|
||||
tree.forEach((node, i) => {
|
||||
if (!nodeKey.includes(getTreeNodeKey(node))) {
|
||||
return;
|
||||
}
|
||||
nodesWithAncestors.push({ ...node, node_index: i });
|
||||
});
|
||||
return nodesWithAncestors;
|
||||
};
|
||||
|
@ -56,7 +56,7 @@ class Selector extends Component {
|
||||
selectItem = (e, targetItem) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.operationBeforeSelect) {
|
||||
this.props.operationBeforeSelect();
|
||||
this.props.operationBeforeSelect(targetItem);
|
||||
} else {
|
||||
this.props.selectOption(targetItem);
|
||||
}
|
||||
@ -73,8 +73,8 @@ class Selector extends Component {
|
||||
<div onClick={this.onToggleClick}>
|
||||
{customSelectorToggle ? customSelectorToggle : (
|
||||
<span className="cur-option">
|
||||
{currentSelectedOption.text}
|
||||
{isDropdownToggleShown && <i className="sf3-font sf3-font-down ml-2 toggle-icon"></i>}
|
||||
{currentSelectedOption ? currentSelectedOption.text : ''}
|
||||
{isDropdownToggleShown && <i className="sf3-font sf3-font-down ml-1 toggle-icon"></i>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
@ -8,6 +8,7 @@ import '../css/item-dropdown-menu.css';
|
||||
const propTypes = {
|
||||
sortBy: PropTypes.string,
|
||||
sortOrder: PropTypes.string,
|
||||
sortOptions: PropTypes.array,
|
||||
onSelectSortOption: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@ -15,7 +16,7 @@ class SortMenu extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.sortOptions = [
|
||||
this.sortOptions = this.props.sortOptions || [
|
||||
{ value: 'name-asc', text: gettext('By name ascending') },
|
||||
{ value: 'name-desc', text: gettext('By name descending') },
|
||||
{ value: 'size-asc', text: gettext('By size ascending') },
|
||||
@ -43,6 +44,7 @@ class SortMenu extends React.Component {
|
||||
isSelected: item.value == `${sortBy}-${sortOrder}`
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
isOpen={isDropdownMenuOpen}
|
||||
|
38
frontend/src/components/system-user-notification-item.js
Normal file
38
frontend/src/components/system-user-notification-item.js
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { gettext } from '../utils/constants';
|
||||
import { notificationAPI } from '../utils/notification-api';
|
||||
import '../css/system-notification.css';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class SystemUserNotificationItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isClosed: false
|
||||
};
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.setState({ isClosed: true });
|
||||
notificationAPI.setSysUserNotificationToSeen(this.props.notificationID);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.isClosed) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div id="info-bar" className="d-flex justify-content-between">
|
||||
<span className="mr-3" aria-hidden="true"></span>
|
||||
<p id="info-bar-info" className="m-0" dangerouslySetInnerHTML={{ __html: this.props.msg }}></p>
|
||||
<button className="close sf2-icon-x1" title={gettext('Close')} aria-label={gettext('Close')} onClick={this.close}></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SystemUserNotificationItem.propTypes = {
|
||||
msg: PropTypes.string.isRequired,
|
||||
notificationID: PropTypes.number.isRequired,
|
||||
};
|
||||
export default SystemUserNotificationItem;
|
43
frontend/src/components/system-user-notification.js
Normal file
43
frontend/src/components/system-user-notification.js
Normal file
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import '../css/system-notification.css';
|
||||
import SystemUserNotificationItem from './system-user-notification-item';
|
||||
import { notificationAPI } from '../utils/notification-api';
|
||||
|
||||
|
||||
class SystemUserNotification extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
userNoteMsgs: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
notificationAPI.listSysUserUnseenNotifications().then((res) => {
|
||||
this.setState({
|
||||
userNoteMsgs: res.data.notifications
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let { userNoteMsgs } = this.state;
|
||||
if (!userNoteMsgs) {
|
||||
return null;
|
||||
}
|
||||
const userNoteMsgItem = userNoteMsgs.map((item, index) => {
|
||||
return (
|
||||
<SystemUserNotificationItem
|
||||
key={index}
|
||||
notificationItem={item}
|
||||
msg={item.msg_format}
|
||||
notificationID={item.id}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return userNoteMsgItem;
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemUserNotification;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user