diff --git a/frontend/src/assets/icons/face-recognition-view.svg b/frontend/src/assets/icons/face-recognition-view.svg
new file mode 100644
index 0000000000..6c49f84c9a
--- /dev/null
+++ b/frontend/src/assets/icons/face-recognition-view.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/frontend/src/components/cur-dir-path/dir-tool.js b/frontend/src/components/cur-dir-path/dir-tool.js
index e0584ff3d7..e4c2822a40 100644
--- a/frontend/src/components/cur-dir-path/dir-tool.js
+++ b/frontend/src/components/cur-dir-path/dir-tool.js
@@ -11,7 +11,6 @@ import ReposSortMenu from '../../components/repos-sort-menu';
import MetadataViewToolBar from '../../metadata/components/view-toolbar';
import { PRIVATE_FILE_TYPE } from '../../constants';
import { DIRENT_DETAIL_MODE } from '../dir-view-mode/constants';
-import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants';
const propTypes = {
repoID: PropTypes.string.isRequired,
@@ -118,7 +117,6 @@ class DirTool extends React.Component {
});
if (isFileExtended) {
- if (viewId === FACE_RECOGNITION_VIEW_ID) return null;
return (
diff --git a/frontend/src/components/dir-view-mode/constants.js b/frontend/src/components/dir-view-mode/constants.js
index b3e41123ec..96c85d5843 100644
--- a/frontend/src/components/dir-view-mode/constants.js
+++ b/frontend/src/components/dir-view-mode/constants.js
@@ -2,4 +2,3 @@ export const LIST_MODE = 'list';
export const GRID_MODE = 'grid';
export const DIRENT_DETAIL_MODE = 'detail';
export const METADATA_MODE = 'metadata';
-export const FACE_RECOGNITION_MODE = 'person_image';
diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js
index 0559d211f2..0687c0f771 100644
--- a/frontend/src/components/dir-view-mode/dir-column-view.js
+++ b/frontend/src/components/dir-view-mode/dir-column-view.js
@@ -9,8 +9,7 @@ import ResizeBar from '../resize-bar';
import { DRAG_HANDLER_HEIGHT, MAX_SIDE_PANEL_RATE, MIN_SIDE_PANEL_RATE } from '../resize-bar/constants';
import { SeafileMetadata } from '../../metadata';
import { mediaUrl } from '../../utils/constants';
-import { GRID_MODE, LIST_MODE, METADATA_MODE, FACE_RECOGNITION_MODE } from './constants';
-import FaceRecognition from '../../metadata/views/face-recognition';
+import { GRID_MODE, LIST_MODE, METADATA_MODE } from './constants';
const propTypes = {
isSidePanelFolded: PropTypes.bool,
@@ -81,6 +80,7 @@ const propTypes = {
onItemsScroll: PropTypes.func.isRequired,
eventBus: PropTypes.object,
updateCurrentDirent: PropTypes.func.isRequired,
+ closeDirentDetail: PropTypes.func.isRequired,
};
class DirColumnView extends React.Component {
@@ -195,7 +195,7 @@ class DirColumnView extends React.Component {
onScroll={this.props.isViewFile ? () => {} : this.props.onItemsScroll}
ref={this.dirContentMain}
>
- {currentMode === METADATA_MODE &&
+ {currentMode === METADATA_MODE && (
- }
- {currentMode === FACE_RECOGNITION_MODE &&
-
- }
+ )}
{currentMode === LIST_MODE &&
{
+ renamePeople = (repoID, recordId, name) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-record/';
const params = {
- record_id: recordID,
+ record_id: recordId,
name: name,
};
return this.req.put(url, params);
};
+ getPeoplePhotos = (repoID, peopleId, start = 0, limit = 1000) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/people-photos/' + peopleId + '/?start=' + start + '&limit=' + limit;
+ return this.req.get(url);
+ };
+
}
const metadataAPI = new MetadataManagerAPI();
diff --git a/frontend/src/metadata/components/view-details/index.js b/frontend/src/metadata/components/view-details/index.js
index 0f60dc5426..ea1f4cc5e1 100644
--- a/frontend/src/metadata/components/view-details/index.js
+++ b/frontend/src/metadata/components/view-details/index.js
@@ -16,6 +16,7 @@ const ViewDetails = ({ viewId, onClose }) => {
const type = view.type;
if (type === VIEW_TYPE.GALLERY) return `${mediaUrl}favicons/gallery.png`;
if (type === VIEW_TYPE.TABLE) return `${mediaUrl}favicons/table.png`;
+ if (type === VIEW_TYPE.FACE_RECOGNITION) return `${mediaUrl}favicons/face-recognition-view.png`;
return `${mediaUrl}img/file/256/file.png`;
}, [view]);
diff --git a/frontend/src/metadata/components/view-toolbar/face-recognition/index.js b/frontend/src/metadata/components/view-toolbar/face-recognition/index.js
new file mode 100644
index 0000000000..9cef59d51d
--- /dev/null
+++ b/frontend/src/metadata/components/view-toolbar/face-recognition/index.js
@@ -0,0 +1,46 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { GalleryGroupBySetter, GallerySliderSetter } from '../../data-process-setter';
+import { gettext } from '../../../../utils/constants';
+import { EVENT_BUS_TYPE } from '../../../constants';
+
+const FaceRecognitionViewToolbar = ({ isCustomPermission, view, showDetail }) => {
+ const [isShow, setShow] = useState(false);
+
+ const onToggle = useCallback((isShow) => {
+ setShow(isShow);
+ }, []);
+
+ useEffect(() => {
+ const unsubscribeToggle = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, onToggle);
+ return () => {
+ unsubscribeToggle && unsubscribeToggle();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (!isShow) return null;
+
+ return (
+ <>
+
+
+
+ {!isCustomPermission && (
+
+
+
+ )}
+
+
+ >
+ );
+};
+
+FaceRecognitionViewToolbar.propTypes = {
+ isCustomPermission: PropTypes.bool,
+ view: PropTypes.object.isRequired,
+ showDetail: PropTypes.func,
+};
+
+export default FaceRecognitionViewToolbar;
diff --git a/frontend/src/metadata/components/view-toolbar/index.js b/frontend/src/metadata/components/view-toolbar/index.js
index 1404e14291..fc3ffcf607 100644
--- a/frontend/src/metadata/components/view-toolbar/index.js
+++ b/frontend/src/metadata/components/view-toolbar/index.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { EVENT_BUS_TYPE, VIEW_TYPE } from '../../constants';
import TableViewToolbar from './table-view-toolbar';
import GalleryViewToolbar from './gallery-view-toolbar';
+import FaceRecognitionViewToolbar from './face-recognition';
import './index.css';
@@ -89,6 +90,13 @@ const ViewToolBar = ({ viewId, isCustomPermission, showDetail }) => {
showDetail={showDetail}
/>
)}
+ {viewType === VIEW_TYPE.FACE_RECOGNITION && (
+
+ )}
);
};
diff --git a/frontend/src/metadata/constants/event-bus-type.js b/frontend/src/metadata/constants/event-bus-type.js
index 71863a3f75..083005bd4b 100644
--- a/frontend/src/metadata/constants/event-bus-type.js
+++ b/frontend/src/metadata/constants/event-bus-type.js
@@ -53,4 +53,7 @@ export const EVENT_BUS_TYPE = {
// gallery
MODIFY_GALLERY_ZOOM_GEAR: 'modify_gallery_zoom_gear',
SWITCH_GALLERY_GROUP_BY: 'switch_gallery_group_by',
+
+ // face recognition
+ TOGGLE_VIEW_TOOLBAR: 'toggle_view_toolbar',
};
diff --git a/frontend/src/metadata/constants/view.js b/frontend/src/metadata/constants/view.js
index 035877d52e..1cc557d067 100644
--- a/frontend/src/metadata/constants/view.js
+++ b/frontend/src/metadata/constants/view.js
@@ -7,6 +7,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO
export const VIEW_TYPE = {
TABLE: 'table',
GALLERY: 'gallery',
+ FACE_RECOGNITION: 'face_recognition',
};
export const FACE_RECOGNITION_VIEW_ID = '_face_recognition';
@@ -14,6 +15,7 @@ export const FACE_RECOGNITION_VIEW_ID = '_face_recognition';
export const VIEW_TYPE_ICON = {
[VIEW_TYPE.TABLE]: 'table',
[VIEW_TYPE.GALLERY]: 'image',
+ [VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view',
'image': 'image'
};
@@ -36,6 +38,7 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = {
filter_term: 'picture'
}
],
+ [VIEW_TYPE.FACE_RECOGNITION]: [],
};
export const VIEW_TYPE_DEFAULT_SORTS = {
diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js
index 48593cb04c..c242c8e182 100644
--- a/frontend/src/metadata/context.js
+++ b/frontend/src/metadata/context.js
@@ -1,6 +1,8 @@
import metadataAPI from './api';
import {
PRIVATE_COLUMN_KEYS, EDITABLE_DATA_PRIVATE_COLUMN_KEYS, EDITABLE_PRIVATE_COLUMN_KEYS, DELETABLE_PRIVATE_COLUMN_KEY,
+ FACE_RECOGNITION_VIEW_ID,
+ VIEW_TYPE,
} from './constants';
import LocalStorage from './utils/local-storage';
import EventBus from '../components/common/event-bus';
@@ -71,8 +73,14 @@ class Context {
// metadata
getMetadata = (params) => {
+ if (!this.metadataAPI) return null;
const repoID = this.settings['repoID'];
- return this.metadataAPI ? this.metadataAPI.getMetadata(repoID, params) : null;
+ const { view_id, start, limit } = params;
+ if (view_id === FACE_RECOGNITION_VIEW_ID) {
+ return this.metadataAPI.getFaceData(repoID, start, limit);
+ }
+
+ return this.metadataAPI.getMetadata(repoID, params);
};
getRecord = (parentDir, fileName) => {
@@ -86,6 +94,17 @@ class Context {
};
getView = (viewId) => {
+ if (viewId === FACE_RECOGNITION_VIEW_ID) {
+ return {
+ data: {
+ view: {
+ _id: FACE_RECOGNITION_VIEW_ID,
+ type: VIEW_TYPE.FACE_RECOGNITION,
+ }
+ }
+ };
+ }
+
const repoID = this.settings['repoID'];
return this.metadataAPI.getView(repoID, viewId);
};
@@ -223,6 +242,18 @@ class Context {
const repoID = this.settings['repoID'];
return this.metadataAPI.extractFileDetails(repoID, objIds);
};
+
+ // face api
+ renamePeople = (recordId, name) => {
+ const repoID = this.settings['repoID'];
+ return this.metadataAPI.renamePeople(repoID, recordId, name);
+ };
+
+ getPeoplePhotos = (recordId, start, limit) => {
+ const repoID = this.settings['repoID'];
+ return this.metadataAPI.getPeoplePhotos(repoID, recordId, start, limit);
+ };
+
}
export default Context;
diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js
index 0fc8f31cf5..1740342ac6 100644
--- a/frontend/src/metadata/hooks/metadata-view.js
+++ b/frontend/src/metadata/hooks/metadata-view.js
@@ -127,6 +127,7 @@ export const MetadataViewProvider = ({
deleteFilesCallback: params.deleteFilesCallback,
renameFileCallback: params.renameFileCallback,
updateCurrentDirent: params.updateCurrentDirent,
+ closeDirentDetail: params.closeDirentDetail,
}}
>
{children}
diff --git a/frontend/src/metadata/hooks/metadata.js b/frontend/src/metadata/hooks/metadata.js
index 2c74897c04..a9dfc1e1a5 100644
--- a/frontend/src/metadata/hooks/metadata.js
+++ b/frontend/src/metadata/hooks/metadata.js
@@ -4,7 +4,7 @@ import { Utils } from '../../utils/utils';
import toaster from '../../components/toast';
import { gettext } from '../../utils/constants';
import { PRIVATE_FILE_TYPE } from '../../constants';
-import { FACE_RECOGNITION_VIEW_ID } from '../constants';
+import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE } from '../constants';
// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
const MetadataContext = React.createContext(null);
@@ -87,7 +87,7 @@ export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, se
viewsMap.current[FACE_RECOGNITION_VIEW_ID] = {
_id: FACE_RECOGNITION_VIEW_ID,
name: gettext('Photos - classfied by people'),
- type: PRIVATE_FILE_TYPE.FACE_RECOGNITION,
+ type: VIEW_TYPE.FACE_RECOGNITION,
};
setNavigation(navigation);
}).catch(error => {
diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js
index e16ee47d25..045d322266 100644
--- a/frontend/src/metadata/metadata-tree-view/index.js
+++ b/frontend/src/metadata/metadata-tree-view/index.js
@@ -15,16 +15,20 @@ import { isEnter } from '../utils/hotkey';
import './index.css';
-const updateFavicon = (iconName) => {
+const updateFavicon = (type) => {
const favicon = document.getElementById('favicon');
if (favicon) {
- switch (iconName) {
+ switch (type) {
+ case VIEW_TYPE.GALLERY:
case 'image':
favicon.href = `${mediaUrl}favicons/gallery.png`;
break;
case VIEW_TYPE.TABLE:
favicon.href = `${mediaUrl}favicons/table.png`;
break;
+ case VIEW_TYPE.FACE_RECOGNITION:
+ favicon.href = `${mediaUrl}favicons/face-recognition-view.png`;
+ break;
default:
favicon.href = `${mediaUrl}favicons/favicon.png`;
}
@@ -70,7 +74,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => {
if (lastOpenedView) {
selectView(lastOpenedView);
document.title = `${lastOpenedView.name} - Seafile`;
- updateFavicon(VIEW_TYPE_ICON[lastOpenedView.type] || VIEW_TYPE.TABLE);
+ updateFavicon(lastOpenedView.type);
return;
}
const url = `${origin}${pathname}`;
@@ -82,7 +86,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => {
if (showFirstView && firstView) {
selectView(firstView);
document.title = `${firstView.name} - Seafile`;
- updateFavicon(VIEW_TYPE_ICON[firstView.type] || VIEW_TYPE.TABLE);
+ updateFavicon(firstView.type);
} else {
document.title = originalTitle;
updateFavicon('default');
@@ -95,7 +99,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => {
const currentView = viewsMap[currentViewId];
if (currentView) {
document.title = `${currentView.name} - Seafile`;
- updateFavicon(VIEW_TYPE_ICON[currentView.type] || VIEW_TYPE.TABLE);
+ updateFavicon(currentView.type);
} else {
document.title = originalTitle;
updateFavicon('default');
diff --git a/frontend/src/metadata/metadata-view/view.js b/frontend/src/metadata/metadata-view/view.js
index 8ef8892a80..dd62d399fb 100644
--- a/frontend/src/metadata/metadata-view/view.js
+++ b/frontend/src/metadata/metadata-view/view.js
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
import Table from '../views/table';
import Gallery from '../views/gallery';
+import FaceRecognition from '../views/face-recognition';
import { useMetadataView } from '../hooks/metadata-view';
import { gettext } from '../../utils/constants';
import { VIEW_TYPE } from '../constants';
@@ -10,7 +11,7 @@ const View = () => {
const { isLoading, metadata, errorMsg } = useMetadataView();
const renderView = useCallback((metadata) => {
- if (!metadata) return false;
+ if (!metadata) return null;
const viewType = metadata.view.type;
switch (viewType) {
case VIEW_TYPE.GALLERY: {
@@ -19,6 +20,9 @@ const View = () => {
case VIEW_TYPE.TABLE: {
return ;
}
+ case VIEW_TYPE.FACE_RECOGNITION: {
+ return ();
+ }
default:
return null;
}
diff --git a/frontend/src/metadata/store/index.js b/frontend/src/metadata/store/index.js
index 2685117f8c..610b28a171 100644
--- a/frontend/src/metadata/store/index.js
+++ b/frontend/src/metadata/store/index.js
@@ -528,6 +528,22 @@ class Store {
return this.data.rows.some((row) => newPath === Utils.joinPath(row._parent_dir, row._name));
};
+ renamePeopleName = (peopleId, newName, oldName) => {
+ const type = OPERATION_TYPE.RENAME_PEOPLE_NAME;
+ const operation = this.createOperation({
+ type, repo_id: this.repoId, people_id: peopleId, new_name: newName, old_name: oldName
+ });
+ this.applyOperation(operation);
+ };
+
+ deletePeoplePhotos = (peopleId, deletedPhotos) => {
+ const type = OPERATION_TYPE.DELETE_PEOPLE_PHOTOS;
+ const operation = this.createOperation({
+ type, repo_id: this.repoId, people_id: peopleId, deleted_photos: deletedPhotos
+ });
+ this.applyOperation(operation);
+ };
+
}
export default Store;
diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js
index 9bd97bc0a2..e3e4528854 100644
--- a/frontend/src/metadata/store/operations/apply.js
+++ b/frontend/src/metadata/store/operations/apply.js
@@ -184,6 +184,44 @@ export default function apply(data, operation) {
data.view = new View({ ...data.view, columns_keys: new_columns_keys }, data.columns);
return data;
}
+ // face table op
+ case OPERATION_TYPE.RENAME_PEOPLE_NAME: {
+ const { people_id, new_name } = operation;
+ const { rows } = data;
+ let updatedRows = [...rows];
+ rows.forEach((row, index) => {
+ const { _id: rowId } = row;
+ if (rowId === people_id) {
+ const updatedRow = Object.assign({}, row, { _name: new_name });
+ updatedRows[index] = updatedRow;
+ data.id_row_map[rowId] = updatedRow;
+ }
+ });
+ data.rows = updatedRows;
+ return data;
+ }
+ case OPERATION_TYPE.DELETE_PEOPLE_PHOTOS: {
+ const { people_id, deleted_photos } = operation;
+ const { rows } = data;
+ const idNeedDeletedMap = deleted_photos.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {});
+ let updatedRows = [...rows];
+ rows.forEach((row, index) => {
+ const { _id: rowId, _photo_links: photoLinks } = row;
+ if (rowId === people_id) {
+ const updatedRow = Object.assign({}, row, { _photo_links: photoLinks.filter(p => !idNeedDeletedMap[p.row_id]) });
+ if (updatedRow._photo_links.length === 0) {
+ updatedRows.splice(index, 1);
+ delete data.id_row_map[rowId];
+ } else {
+ updatedRows[index] = updatedRow;
+ data.id_row_map[rowId] = updatedRow;
+ }
+ }
+ });
+ data.rows = updatedRows;
+ data.recordsCount = updatedRows.length;
+ return data;
+ }
default: {
return data;
}
diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js
index 425425b33a..5115b0f9f1 100644
--- a/frontend/src/metadata/store/operations/constants.js
+++ b/frontend/src/metadata/store/operations/constants.js
@@ -17,6 +17,10 @@ export const OPERATION_TYPE = {
MODIFY_COLUMN_DATA: 'modify_column_data',
MODIFY_COLUMN_WIDTH: 'modify_column_width',
MODIFY_COLUMN_ORDER: 'modify_column_order',
+
+ // face table
+ RENAME_PEOPLE_NAME: 'rename_people_name',
+ DELETE_PEOPLE_PHOTOS: 'delete_people_photos',
};
export const COLUMN_DATA_OPERATION_TYPE = {
@@ -45,6 +49,8 @@ export const OPERATION_ATTRIBUTES = {
[OPERATION_TYPE.DELETE_COLUMN]: ['repo_id', 'column_key', 'column'],
[OPERATION_TYPE.MODIFY_COLUMN_WIDTH]: ['column_key', 'new_width', 'old_width'],
[OPERATION_TYPE.MODIFY_COLUMN_ORDER]: ['repo_id', 'view_id', 'new_columns_keys', 'old_columns_keys'],
+ [OPERATION_TYPE.RENAME_PEOPLE_NAME]: ['repo_id', 'people_id', 'new_name', 'old_name'],
+ [OPERATION_TYPE.DELETE_PEOPLE_PHOTOS]: ['repo_id', 'people_id', 'deleted_photos'],
};
export const UNDO_OPERATION_TYPE = [
diff --git a/frontend/src/metadata/store/server-operator.js b/frontend/src/metadata/store/server-operator.js
index 60b3bfffc8..f8961e1913 100644
--- a/frontend/src/metadata/store/server-operator.js
+++ b/frontend/src/metadata/store/server-operator.js
@@ -174,6 +174,17 @@ class ServerOperator {
});
break;
}
+
+ // face table op
+ case OPERATION_TYPE.RENAME_PEOPLE_NAME: {
+ const { people_id, new_name } = operation;
+ window.sfMetadataContext.renamePeople(people_id, new_name).then(res => {
+ callback({ operation });
+ }).catch(error => {
+ callback({ error: gettext('Failed to modify people name') });
+ });
+ break;
+ }
default: {
break;
}
diff --git a/frontend/src/metadata/views/face-recognition/face-group.js b/frontend/src/metadata/views/face-recognition/face-group.js
deleted file mode 100644
index 46691db18b..0000000000
--- a/frontend/src/metadata/views/face-recognition/face-group.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import React, { useCallback, useRef, useState } from 'react';
-import PropTypes from 'prop-types';
-import dayjs from 'dayjs';
-import relativeTime from 'dayjs/plugin/relativeTime';
-import { Input } from 'reactstrap';
-import toaster from '../../../components/toast';
-import { Utils } from '../../../utils/utils';
-import { gettext, siteRoot } from '../../../utils/constants';
-import metadataAPI from '../../api';
-import isHotkey from 'is-hotkey';
-import { isEnter } from '../../utils/hotkey';
-
-dayjs.extend(relativeTime);
-
-const theadData = [
- { width: '5%', text: '' },
- { width: '39%', text: gettext('Name') },
- { width: '34%', text: gettext('Original path') },
- { width: '11%', text: gettext('Size') },
- { width: '11%', text: gettext('Last Update') },
-];
-
-const FaceGroup = ({ repoID, group, onPhotoClick }) => {
- const [name, setName] = useState(group.name);
- const [isRenaming, setRenaming] = useState(false);
- const serverName = useRef(group.name);
-
- const showPhoto = useCallback((event, photo) => {
- event.preventDefault();
- onPhotoClick(photo);
- }, [onPhotoClick]);
-
- const changeName = useCallback((event) => {
- const value = event.target.value;
- if (name === value) return;
- setName(value);
- }, [name]);
-
- const renameName = useCallback(() => {
- setRenaming(true);
- }, []);
-
- const updateName = useCallback(() => {
- if (name === serverName.current) {
- setRenaming(false);
- return;
- }
- metadataAPI.updateFaceName(repoID, group.record_id, name).then(res => {
- serverName.current = name;
- setRenaming(false);
- }).catch(err => {
- const errorMsg = Utils.getErrorMsg(err);
- toaster.danger(errorMsg);
- setName(serverName.current);
- setRenaming(false);
- });
- }, [repoID, group, name]);
-
- const onRenameKeyDown = useCallback((event) => {
- if (isEnter(event)) {
- updateName();
- } else if (isHotkey('esc', event)) {
- setName(serverName.current);
- setRenaming(false);
- }
- }, [updateName]);
-
- return (
-
- {isRenaming ?
- (
)
- :
- (
{name}
)
- }
-
-
-
- {theadData.map((item, index) => {
- return {item.text} | ;
- })}
-
-
-
- {group.photos.map((photo, index) => {
- return (
- showPhoto(event, photo)}>
-  |
- showPhoto(event, photo)}>{photo.file_name} |
- {photo.parent_dir} |
- {Utils.bytesToSize(photo.size)} |
- {dayjs(photo.mtime).fromNow()} |
-
- );
- })}
-
-
-
- );
-};
-
-FaceGroup.propTypes = {
- repoID: PropTypes.string,
- group: PropTypes.object.isRequired,
- onPhotoClick: PropTypes.func,
-};
-
-export default FaceGroup;
diff --git a/frontend/src/metadata/views/face-recognition/index.css b/frontend/src/metadata/views/face-recognition/index.css
index 2c4fa763d0..d810532f1d 100644
--- a/frontend/src/metadata/views/face-recognition/index.css
+++ b/frontend/src/metadata/views/face-recognition/index.css
@@ -1,29 +1,7 @@
-.sf-metadata-face-recognition {
+.sf-metadata-face-recognition-container {
height: 100%;
width: 100%;
padding: 16px;
overflow-x: hidden;
overflow-y: scroll;
}
-
-.sf-metadata-face-recognition .sf-metadata-face-recognition-item {
- margin-bottom: 16px;
-}
-
-.sf-metadata-face-recognition .sf-metadata-face-recognition-item:last-child {
- margin-bottom: 0;
-}
-
-.sf-metadata-face-recognition .sf-metadata-face-recognition-name {
- border-color: transparent;
- cursor: pointer;
-}
-
-.sf-metadata-face-recognition .sf-metadata-face-recognition-loading-more {
- height: 30px;
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-}
diff --git a/frontend/src/metadata/views/face-recognition/index.js b/frontend/src/metadata/views/face-recognition/index.js
index 15b41c0c9e..ce51022f43 100644
--- a/frontend/src/metadata/views/face-recognition/index.js
+++ b/frontend/src/metadata/views/face-recognition/index.js
@@ -1,161 +1,48 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import PropTypes from 'prop-types';
-import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
-import toaster from '../../../components/toast';
-import { Utils } from '../../../utils/utils';
-import metadataAPI from '../../api';
-import FaceGroup from './face-group';
-import ImageDialog from '../../../components/dialog/image-dialog';
-import ModalPortal from '../../../components/modal-portal';
-import { siteRoot, gettext, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../../utils/constants';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import { useMetadataView } from '../../hooks/metadata-view';
+import Peoples from './peoples';
+import PeoplePhotos from './person-photos';
import './index.css';
-const LIMIT = 1000;
+const FaceRecognition = () => {
+ const [showPeopleFaces, setShowPeopleFaces] = useState(false);
+ const peopleRef = useRef(null);
-const FaceRecognition = ({ repoID }) => {
- const [loading, setLoading] = useState(true);
- const [faceOriginData, setFaceOriginData] = useState([]);
- const [isLoadingMore, setLoadingMore] = useState(false);
- const [isImagePopupOpen, setIsImagePopupOpen] = useState(false);
- const [imageIndex, setImageIndex] = useState(-1);
- const containerRef = useRef(null);
- const hasMore = useRef(true);
+ const { metadata, store } = useMetadataView();
- const faceData = useMemo(() => {
- if (!Array.isArray(faceOriginData) || faceOriginData.length === 0) return [];
- const data = faceOriginData.map(dataItem => {
- const { record_id, link_photos } = dataItem;
- const linkPhotos = link_photos || [];
- const name = dataItem.name || gettext('Person Image');
- return {
- record_id: record_id,
- name: name || gettext('Person Image'),
- photos: linkPhotos.map(photo => {
- const { path } = photo;
- return {
- ...photo,
- name: photo.file_name,
- url: `${siteRoot}lib/${repoID}/file${path}`,
- default_url: `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`,
- src: `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`,
- thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`,
- };
- }),
- };
- });
- return data;
- }, [repoID, faceOriginData]);
+ const peoples = useMemo(() => {
+ if (!Array.isArray(metadata.rows) || metadata.rows.length === 0) return [];
+ return metadata.rows;
+ }, [metadata]);
- const imageItems = useMemo(() => {
- return faceData.map(group => group.photos).flat();
- }, [faceData]);
+ const onDeletePeoplePhotos = useCallback((peopleId, peoplePhotos) => {
+ store.deletePeoplePhotos(peopleId, peoplePhotos);
+ }, [store]);
- useEffect(() => {
- setLoading(true);
- metadataAPI.getFaceData(repoID, 0, LIMIT).then(res => {
- const faceOriginData = res.data.results || [];
- if (faceOriginData.length < LIMIT) {
- hasMore.current = false;
- }
- setFaceOriginData(faceOriginData);
- setLoading(false);
- }).catch(error => {
- const errorMsg = Utils.getErrorMsg(error);
- toaster.danger(errorMsg);
- setLoading(false);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ const openPeople = useCallback((people) => {
+ peopleRef.current = people;
+ setShowPeopleFaces(true);
}, []);
- const loadMore = useCallback(() => {
- if (!hasMore.current) return;
- setLoadingMore(true);
- metadataAPI.getFaceData(repoID, faceOriginData.length, LIMIT).then(res => {
- const newFaceData = res.data.results || [];
- if (newFaceData.length < LIMIT) {
- hasMore.current = false;
- }
- setFaceOriginData([...faceOriginData, ...newFaceData]);
- setLoadingMore(false);
- }).catch(error => {
- const errorMsg = Utils.getErrorMsg(error);
- toaster.danger(errorMsg);
- setLoadingMore(false);
- });
- }, [repoID, faceOriginData]);
-
- const handleScroll = useCallback(() => {
- if (!containerRef.current) return;
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
- if (scrollTop + clientHeight >= scrollHeight - 10) {
- loadMore();
- }
- }, [loadMore]);
-
- const onPhotoClick = useCallback((photo) => {
- let imageIndex = imageItems.findIndex(item => item.url === photo.url);
- if (imageIndex < 0) imageIndex = 0;
- setImageIndex(imageIndex);
- setIsImagePopupOpen(true);
- }, [imageItems]);
-
- const closeImagePopup = useCallback(() => {
- setIsImagePopupOpen(false);
- setImageIndex(-1);
+ const closePeople = useCallback(() => {
+ peopleRef.current = null;
+ setShowPeopleFaces(false);
}, []);
- const moveToPrevImage = useCallback(() => {
- let prevImageIndex = imageIndex - 1;
- if (prevImageIndex < 0) prevImageIndex = imageItems.length - 1;
- setImageIndex(prevImageIndex);
- }, [imageIndex, imageItems]);
-
- const moveToNextImage = useCallback(() => {
- let nextImageIndex = imageIndex + 1;
- if (nextImageIndex > imageItems.length - 1) nextImageIndex = 0;
- setImageIndex(nextImageIndex);
- }, [imageIndex, imageItems]);
-
- if (loading) {
- return ();
- }
+ const onRename = useCallback((id, newName, oldName) => {
+ store.renamePeopleName(id, newName, oldName);
+ }, [store]);
return (
- <>
-
-
-
-
- {faceData.length > 0 && faceData.map((face) => {
- return (
);
- })}
- {isLoadingMore && (
-
-
-
- )}
-
-
-
-
- {isImagePopupOpen && (
-
-
-
+
+ {showPeopleFaces ? (
+
+ ) : (
+
)}
- >
+
);
};
-FaceRecognition.propTypes = {
- repoID: PropTypes.string.isRequired,
-};
-
export default FaceRecognition;
diff --git a/frontend/src/metadata/views/face-recognition/peoples/index.css b/frontend/src/metadata/views/face-recognition/peoples/index.css
new file mode 100644
index 0000000000..e4929feefd
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/peoples/index.css
@@ -0,0 +1,17 @@
+/* loading more */
+.sf-metadata-face-recognition-container .sf-metadata-face-recognition-loading-more {
+ height: 30px;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.sf-metadata-peoples-container {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ height: fit-content !important;
+ max-height: 100%;
+}
diff --git a/frontend/src/metadata/views/face-recognition/peoples/index.js b/frontend/src/metadata/views/face-recognition/peoples/index.js
new file mode 100644
index 0000000000..2c2cb37e5c
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/peoples/index.js
@@ -0,0 +1,103 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import EmptyTip from '../../../../components/empty-tip';
+import { gettext } from '../../../../utils/constants';
+import { useMetadataView } from '../../../hooks/metadata-view';
+import { PER_LOAD_NUMBER } from '../../../constants';
+import toaster from '../../../../components/toast';
+import { Utils } from '../../../../utils/utils';
+import People from './people';
+
+import './index.css';
+
+const Peoples = ({ peoples, onOpenPeople, onRename }) => {
+ const [isLoadingMore, setLoadingMore] = useState(false);
+ const [haveFreezed, setHaveFreezed] = useState(false);
+
+ const containerRef = useRef(null);
+
+ const { metadata, store, closeDirentDetail } = useMetadataView();
+
+ const loadMore = useCallback(async () => {
+ if (isLoadingMore) return;
+ if (!metadata.hasMore) return;
+ setLoadingMore(true);
+
+ try {
+ await store.loadMore(PER_LOAD_NUMBER);
+ setLoadingMore(false);
+ } catch (error) {
+ const errorMsg = Utils.getErrorMsg(error);
+ toaster.danger(errorMsg);
+ setLoadingMore(false);
+ return;
+ }
+
+ }, [isLoadingMore, metadata, store]);
+
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ if (scrollTop + clientHeight >= scrollHeight - 10) {
+ loadMore();
+ }
+ window.sfMetadataContext.localStorage.setItem('scroll_top', scrollTop);
+ }, [loadMore]);
+
+ const onFreezed = useCallback(() => {
+ setHaveFreezed(true);
+ }, []);
+
+ const onUnFreezed = useCallback(() => {
+ setHaveFreezed(false);
+ }, []);
+
+ useEffect(() => {
+ const _localStorage = window.sfMetadataContext.localStorage;
+ if (!containerRef.current) return;
+ const scrollTop = _localStorage.getItem('scroll_top') || 0;
+ if (scrollTop) {
+ containerRef.current.scrollTop = Number(scrollTop);
+ }
+ return () => {};
+ }, []);
+
+ useEffect(() => {
+ closeDirentDetail();
+ return () => {};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (!Array.isArray(peoples) || peoples.length === 0) return ();
+
+ return (
+
+ {peoples.length > 0 && peoples.map((people) => {
+ return (
+
+ );
+ })}
+ {isLoadingMore && (
+
+
+
+ )}
+
+ );
+};
+
+Peoples.propTypes = {
+ peoples: PropTypes.array,
+ onOpenPeople: PropTypes.func,
+};
+
+export default Peoples;
diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/index.css b/frontend/src/metadata/views/face-recognition/peoples/people/index.css
new file mode 100644
index 0000000000..a6050c98d2
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/peoples/people/index.css
@@ -0,0 +1,75 @@
+.sf-metadata-peoples-container .sf-metadata-people-info {
+ height: 56px;
+ width: 48%;
+ flex-shrink: 0;
+ overflow: visible;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info:hover {
+ background: #f5f5f5;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info:not(.readonly) {
+ cursor: pointer;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-img {
+ height: 36px;
+ width: 36px;
+ overflow: hidden;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name-count {
+ flex: 1;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 6px 0;
+ overflow: hidden;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name {
+ width: 100%;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name-display {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-renaming {
+ box-sizing: border-box;
+ padding: 2px 3px;
+ width: 20rem;
+ max-width: 100%;
+ height: 22px;
+ line-height: 16px;
+ border-radius: 2px;
+ word-wrap: break-word;
+ vertical-align: middle;
+ border: 1px solid #ccc;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-renaming:focus {
+ border-color: #1991eb;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-count {
+ font-size: 12px;
+ color: #666;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-op {
+ width: 24px;
+ flex-shrink: 0;
+}
+
+.sf-metadata-peoples-container .sf-metadata-people-info-op .sf-dropdown-toggle {
+ line-height: 1.5;
+}
diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/index.js b/frontend/src/metadata/views/face-recognition/peoples/people/index.js
new file mode 100644
index 0000000000..6b3d921940
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/peoples/people/index.js
@@ -0,0 +1,124 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Input } from 'reactstrap';
+import classNames from 'classnames';
+import isHotkey from 'is-hotkey';
+import { gettext, siteRoot, thumbnailDefaultSize } from '../../../../../utils/constants';
+import OpMenu from './op-menu';
+import { isEnter } from '../../../../utils/hotkey';
+import { Utils } from '../../../../../utils/utils';
+import { getFileNameFromRecord, getParentDirFromRecord } from '../../../../utils/cell';
+
+import './index.css';
+
+const People = ({ haveFreezed, people, onOpenPeople, onRename, onFreezed, onUnFreezed }) => {
+
+ const similarPhotoURL = useMemo(() => {
+ const similarPhoto = people._similar_photo;
+ if (!similarPhoto) return '';
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+ const fileName = getFileNameFromRecord(similarPhoto);
+ const parentDir = getParentDirFromRecord(similarPhoto);
+ const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
+ return `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`;
+ }, [people._similar_photo]);
+
+ const photosCount = useMemo(() => {
+ return Array.isArray(people._photo_links) ? people._photo_links.length : 0;
+ }, [people._photo_links]);
+
+ const [name, setName] = useState(people._name || gettext('Person image'));
+ const [renaming, setRenaming] = useState(false);
+ const [active, setActive] = useState(false);
+ const readonly = !window.sfMetadataContext.canModify();
+
+ const onMouseEnter = useCallback(() => {
+ if (haveFreezed) return;
+ setActive(true);
+ }, [haveFreezed]);
+
+ const onMouseLeave = useCallback(() => {
+ if (haveFreezed) return;
+ setActive(false);
+ }, [haveFreezed]);
+
+ const setRenamingState = useCallback(() => {
+ onFreezed();
+ setRenaming(true);
+ }, [onFreezed]);
+
+ const onBlur = useCallback(() => {
+ if (!name) return;
+ setRenaming(false);
+ if (name !== (people._name || gettext('Person image'))) {
+ onUnFreezed();
+ onRename(people._id, name, people._name);
+ }
+ }, [people, name, onRename, onUnFreezed]);
+
+ const onChange = useCallback((event) => {
+ const value = event.target.value;
+ if (value === name) return;
+ setName(value);
+ }, [name]);
+
+ const onKeyDown = useCallback((event) => {
+ if (isEnter(event)) {
+ onBlur();
+ return;
+ } else if (isHotkey('esc', event)) {
+ setName(people.name);
+ setRenaming(false);
+ return;
+ }
+ }, [people, onBlur]);
+
+ const _onUnFreezed = useCallback(() => {
+ onUnFreezed();
+ setActive(false);
+ }, [onUnFreezed]);
+
+ return (
+ {} : () => onOpenPeople(people)}
+ >
+
+

+
+
+
+ {renaming ? (
+
+ ) : (
+
{name}
+ )}
+
+
+ {photosCount + ' ' + gettext('items')}
+
+
+ {!readonly && people._is_someone && (
+
+ {active && (
+
+ )}
+
+ )}
+
+ );
+};
+
+People.propTypes = {
+ haveFreezed: PropTypes.bool,
+ people: PropTypes.object.isRequired,
+ onOpenPeople: PropTypes.func,
+ onFreezed: PropTypes.func,
+ onUnFreezed: PropTypes.func,
+};
+
+export default People;
diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js b/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js
new file mode 100644
index 0000000000..f7728bf7d1
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js
@@ -0,0 +1,65 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Dropdown, DropdownMenu, DropdownToggle, DropdownItem } from 'reactstrap';
+import { gettext } from '../../../../../../utils/constants';
+
+const OpMenu = ({ onRename, onFreezed, onUnFreezed }) => {
+ let [isShow, setShow] = useState(false);
+
+ const toggle = useCallback((event) => {
+ const dataset = event.target ? event.target.dataset : null;
+ if (dataset && dataset.toggle && dataset.toggle === 'rename') {
+ onRename();
+ setShow(!isShow);
+ return;
+ }
+ if (isShow) {
+ onUnFreezed();
+ } else {
+ onFreezed();
+ }
+ setShow(!isShow);
+ }, [isShow, onRename, onFreezed, onUnFreezed, setShow]);
+
+ const onClick = useCallback((event) => {
+ toggle(event);
+ }, [toggle]);
+
+ const onItemClick = useCallback((event) => {
+ toggle(event);
+ }, [toggle]);
+
+ useEffect(() => {
+ return () => {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ setShow = () => {};
+ };
+ }, []);
+
+ return (
+
+
+
+ {gettext('Rename')}
+
+
+ );
+};
+
+OpMenu.propTypes = {
+ onRename: PropTypes.func,
+ onFreezed: PropTypes.func,
+ onUnFreezed: PropTypes.func,
+};
+
+export default OpMenu;
+
diff --git a/frontend/src/metadata/views/face-recognition/person-photos/index.css b/frontend/src/metadata/views/face-recognition/person-photos/index.css
new file mode 100644
index 0000000000..53c51897cc
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/person-photos/index.css
@@ -0,0 +1,56 @@
+.sf-metadata-people-photos-container {
+ padding: 0 !important;
+ overflow-y: hidden !important;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-people-photos-header {
+ height: 48px;
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ border-bottom: 1px solid #eee;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-icon-btn {
+ margin-left: -4px;
+ border-radius: 3px;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-icon-btn:hover {
+ background-color: #EFEFEF;
+ cursor: pointer;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-photos-header-back {
+ font-size: 14px;
+ height: 24px;
+ min-width: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: -5px;
+ border-radius: 3px;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-photos-header-back:hover {
+ background-color: #DBDBDB;
+ background-color: #EFEFEF;
+ cursor: pointer;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-people-photos-header-back .sf3-font-arrow {
+ color: #666;
+ font-size: 14px !important;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-name {
+ margin-left: 4px;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sf-metadata-people-photos-container .sf-metadata-gallery-container {
+ height: calc(100% - 48px);
+}
diff --git a/frontend/src/metadata/views/face-recognition/person-photos/index.js b/frontend/src/metadata/views/face-recognition/person-photos/index.js
new file mode 100644
index 0000000000..aa648a9f1e
--- /dev/null
+++ b/frontend/src/metadata/views/face-recognition/person-photos/index.js
@@ -0,0 +1,153 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import deepCopy from 'deep-copy';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import metadataAPI from '../../../api';
+import Metadata from '../../../model/metadata';
+import { normalizeColumns } from '../../../utils/column';
+import { gettext } from '../../../../utils/constants';
+import { Utils } from '../../../../utils/utils';
+import toaster from '../../../../components/toast';
+import Gallery from '../../gallery/main';
+import { useMetadataView } from '../../../hooks/metadata-view';
+import { PER_LOAD_NUMBER, VIEW_TYPE, VIEW_TYPE_DEFAULT_SORTS, EVENT_BUS_TYPE } from '../../../constants';
+
+import './index.css';
+import '../../gallery/index.css';
+
+const PeoplePhotos = ({ people, onClose, onDeletePeoplePhotos }) => {
+ const [isLoading, setLoading] = useState(true);
+ const [isLoadingMore, setLoadingMore] = useState(false);
+ const [metadata, setMetadata] = useState({ rows: [] });
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+
+ const { deleteFilesCallback } = useMetadataView();
+
+ const onLoadMore = useCallback(async () => {
+ if (isLoadingMore) return;
+ if (!metadata.hasMore) return;
+ setLoadingMore(true);
+
+ metadataAPI.getPeoplePhotos(repoID, people._id, metadata.recordsCount, PER_LOAD_NUMBER).then(res => {
+ const rows = res?.data?.results || [];
+ let newMetadata = deepCopy(metadata);
+ if (Array.isArray(rows) && rows.length > 0) {
+ newMetadata.rows.push(...rows);
+ rows.forEach(record => {
+ newMetadata.row_ids.push(record._id);
+ newMetadata.id_row_map[record._id] = record;
+ });
+ const loadedCount = rows.length;
+ newMetadata.hasMore = loadedCount === PER_LOAD_NUMBER;
+ newMetadata.recordsCount = newMetadata.row_ids.length;
+ } else {
+ newMetadata.hasMore = false;
+ }
+ setMetadata(newMetadata);
+ setLoadingMore(false);
+ }).catch(error => {
+ const errorMessage = Utils.getErrorMsg(error);
+ toaster.danger(errorMessage);
+ setLoadingMore(false);
+ });
+
+ }, [isLoadingMore, metadata, people, repoID]);
+
+ const deletedByIds = useCallback((ids) => {
+ if (!Array.isArray(ids) || ids.length === 0) return;
+ const newMetadata = deepCopy(metadata);
+ const idNeedDeletedMap = ids.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {});
+ newMetadata.rows = newMetadata.rows.filter((row) => !idNeedDeletedMap[row._id]);
+ newMetadata.row_ids = newMetadata.row_ids.filter((id) => !idNeedDeletedMap[id]);
+
+ // delete rows in id_row_map
+ ids.forEach(rowId => {
+ delete newMetadata.id_row_map[rowId];
+ });
+ newMetadata.recordsCount = newMetadata.row_ids.length;
+ setMetadata(newMetadata);
+
+ if (newMetadata.rows.length === 0) {
+ onClose && onClose();
+ }
+ onDeletePeoplePhotos && onDeletePeoplePhotos(people._id, ids);
+ }, [metadata, onClose, people, onDeletePeoplePhotos]);
+
+ const handelDelete = useCallback((deletedImages, callback) => {
+ if (!deletedImages.length) return;
+ let recordIds = [];
+ let paths = [];
+ let fileNames = [];
+ deletedImages.forEach((record) => {
+ const { id, path: parentDir, name } = record || {};
+ if (parentDir && name) {
+ const path = Utils.joinPath(parentDir, name);
+ paths.push(path);
+ fileNames.push(name);
+ recordIds.push(id);
+ }
+ });
+ window.sfMetadataContext.batchDeleteFiles(repoID, paths).then(res => {
+ callback && callback();
+ deletedByIds(recordIds);
+ deleteFilesCallback(paths, fileNames);
+ let msg = fileNames.length > 1
+ ? gettext('Successfully deleted {name} and {n} other items')
+ : gettext('Successfully deleted {name}');
+ msg = msg.replace('{name}', fileNames[0])
+ .replace('{n}', fileNames.length - 1);
+ toaster.success(msg);
+ }).catch(error => {
+ toaster.danger(gettext('Failed to delete records'));
+ });
+ }, [deleteFilesCallback, repoID, deletedByIds]);
+
+ useEffect(() => {
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+ metadataAPI.getPeoplePhotos(repoID, people._id, 0, PER_LOAD_NUMBER).then(res => {
+ const rows = res?.data?.results || [];
+ const columns = normalizeColumns(res?.data?.metadata);
+ let metadata = new Metadata({ rows, columns, view: { sorts: VIEW_TYPE_DEFAULT_SORTS[VIEW_TYPE.GALLERY] } });
+ if (rows.length < PER_LOAD_NUMBER) {
+ metadata.hasMore = false;
+ }
+ setMetadata(metadata);
+ setLoading(false);
+ }).catch(error => {
+ const errorMessage = Utils.getErrorMsg(error);
+ toaster.danger(errorMessage);
+ setLoading(false);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, true);
+ return () => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, false);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (isLoading) return ();
+
+ return (
+
+
+
+
+
+
{people._name || gettext('Person image')}
+
+
+
+ );
+};
+
+PeoplePhotos.propTypes = {
+ people: PropTypes.object,
+ onClose: PropTypes.func,
+ onDelete: PropTypes.func,
+};
+
+export default PeoplePhotos;
diff --git a/frontend/src/metadata/views/gallery/content.js b/frontend/src/metadata/views/gallery/content.js
new file mode 100644
index 0000000000..05dd419247
--- /dev/null
+++ b/frontend/src/metadata/views/gallery/content.js
@@ -0,0 +1,197 @@
+import React, { useState, useCallback, useMemo, useRef } from 'react';
+import PropTypes from 'prop-types';
+import EmptyTip from '../../../components/empty-tip';
+import { gettext } from '../../../utils/constants';
+import { GALLERY_DATE_MODE } from '../../constants';
+import Image from './image';
+
+const Content = ({
+ groups,
+ overScan,
+ columns,
+ size,
+ gap,
+ mode,
+ selectedImages,
+ onImageSelect,
+ onImageClick,
+ onImageDoubleClick,
+ onImageRightClick
+}) => {
+ const containerRef = useRef(null);
+ const imageRef = useRef(null);
+ const animationFrameRef = useRef(null);
+
+ const [isSelecting, setIsSelecting] = useState(false);
+ const [selectionStart, setSelectionStart] = useState(null);
+
+ const imageHeight = useMemo(() => size + gap, [size, gap]);
+ const selectedImageIds = useMemo(() => selectedImages.map(img => img.id), [selectedImages]);
+
+ const handleMouseDown = useCallback((e) => {
+ if (e.button !== 0) return;
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return;
+
+ setIsSelecting(true);
+ setSelectionStart({ x: e.clientX, y: e.clientY });
+ }, []);
+
+ const handleMouseMove = useCallback((e) => {
+ if (!isSelecting) return;
+
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+
+ animationFrameRef.current = requestAnimationFrame(() => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const selectionEnd = { x: e.clientX, y: e.clientY };
+ const selected = [];
+
+ groups.forEach(group => {
+ group.children.forEach((row) => {
+ row.children.forEach((img) => {
+ const imgElement = document.getElementById(img.id);
+ if (imgElement) {
+ const rect = imgElement.getBoundingClientRect();
+ if (
+ rect.left < Math.max(selectionStart.x, selectionEnd.x) &&
+ rect.right > Math.min(selectionStart.x, selectionEnd.x) &&
+ rect.top < Math.max(selectionStart.y, selectionEnd.y) &&
+ rect.bottom > Math.min(selectionStart.y, selectionEnd.y)
+ ) {
+ selected.push(img);
+ }
+ }
+ });
+ });
+ });
+
+ onImageSelect(selected);
+ });
+ }, [groups, isSelecting, selectionStart, onImageSelect]);
+
+ const handleMouseUp = useCallback((e) => {
+ if (e.button !== 0) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ setIsSelecting(false);
+ }, []);
+
+ const renderDisplayGroup = useCallback((group) => {
+ const { top: overScanTop, bottom: overScanBottom } = overScan;
+ const { name, children, height, top, paddingTop } = group;
+
+ // group not in rendering area, return empty div
+ if (top >= overScanBottom || top + height <= overScanTop) {
+ return ();
+ }
+
+ let childrenStartIndex = children.findIndex(r => r.top >= overScanTop);
+ let childrenEndIndex = children.findIndex(r => r.top >= overScanBottom);
+
+ // group in rendering area, but the image not need to render. eg: overScan: { top: 488, bottom: 1100 }, group: { top: 0, height: 521 },
+ // in this time, part of an image is in the rendering area, don't render image
+ if (childrenStartIndex === -1 && childrenEndIndex === -1) {
+ return ();
+ }
+
+ childrenStartIndex = Math.max(childrenStartIndex, 0);
+ if (childrenEndIndex === -1) {
+ childrenEndIndex = children.length;
+ }
+ if (childrenEndIndex > 0) {
+ childrenEndIndex = childrenEndIndex - 1;
+ }
+
+ return (
+
+ {mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && (
+
{name || gettext('Empty')}
+ )}
+
+ {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
+ return row.children.map((img) => {
+ const isSelected = selectedImageIds.includes(img.id);
+ return (
+ onImageClick(e, img)}
+ onDoubleClick={(e) => onImageDoubleClick(e, img)}
+ onContextMenu={(e) => onImageRightClick(e, img)}
+ />
+ );
+ });
+ })}
+
+
+ );
+ }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]);
+
+ if (!Array.isArray(groups) || groups.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {groups.map((group) => {
+ return renderDisplayGroup(group);
+ })}
+
+ );
+};
+
+Content.propTypes = {
+ groups: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ children: PropTypes.arrayOf(PropTypes.shape({
+ top: PropTypes.number.isRequired,
+ children: PropTypes.arrayOf(PropTypes.shape({
+ src: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ })).isRequired,
+ })).isRequired,
+ height: PropTypes.number.isRequired,
+ top: PropTypes.number.isRequired,
+ paddingTop: PropTypes.number.isRequired,
+ })),
+ overScan: PropTypes.shape({
+ top: PropTypes.number.isRequired,
+ bottom: PropTypes.number.isRequired,
+ }).isRequired,
+ columns: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ gap: PropTypes.number.isRequired,
+ mode: PropTypes.string,
+ selectedImages: PropTypes.array.isRequired,
+ onImageSelect: PropTypes.func.isRequired,
+ onImageClick: PropTypes.func.isRequired,
+ onImageDoubleClick: PropTypes.func.isRequired,
+ onImageRightClick: PropTypes.func.isRequired,
+};
+
+export default Content;
diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js
index af741a5633..82378589d7 100644
--- a/frontend/src/metadata/views/gallery/index.js
+++ b/frontend/src/metadata/views/gallery/index.js
@@ -1,135 +1,19 @@
-import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
-import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
-import metadataAPI from '../../api';
-import URLDecorator from '../../../utils/url-decorator';
+import React, { useState, useCallback } from 'react';
import toaster from '../../../components/toast';
import Main from './main';
-import ContextMenu from './context-menu';
-import ImageDialog from '../../../components/dialog/image-dialog';
-import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog';
-import ModalPortal from '../../../components/modal-portal';
import { useMetadataView } from '../../hooks/metadata-view';
import { Utils } from '../../../utils/utils';
-import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell';
-import { siteRoot, fileServerRoot, useGoFileserver, gettext, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants';
-import { EVENT_BUS_TYPE, PER_LOAD_NUMBER, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants';
-import { getRowById } from '../../utils/table';
-import { getEventClassName } from '../../utils/common';
+import { gettext } from '../../../utils/constants';
+import { PER_LOAD_NUMBER } from '../../constants';
import './index.css';
const Gallery = () => {
- const [isFirstLoading, setFirstLoading] = useState(true);
const [isLoadingMore, setLoadingMore] = useState(false);
- const [zoomGear, setZoomGear] = useState(0);
- const [containerWidth, setContainerWidth] = useState(0);
- const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
- const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY);
- const [isImagePopupOpen, setIsImagePopupOpen] = useState(false);
- const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
- const [imageIndex, setImageIndex] = useState(0);
- const [selectedImages, setSelectedImages] = useState([]);
- const containerRef = useRef(null);
- const renderMoreTimer = useRef(null);
- const lastState = useRef({ visibleAreaFirstImage: { groupIndex: 0, rowIndex: 0 } });
+ const { metadata, store, deleteFilesCallback } = useMetadataView();
- const { metadata, store, updateCurrentDirent, deleteFilesCallback } = useMetadataView();
- const repoID = window.sfMetadataContext.getSetting('repoID');
-
- useEffect(() => {
- updateCurrentDirent();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Number of images per row
- const columns = useMemo(() => {
- return 8 - zoomGear;
- }, [zoomGear]);
-
- const imageSize = useMemo(() => {
- return (containerWidth - (columns - 1) * 2 - 32) / columns;
- }, [containerWidth, columns]);
-
- const dateMode = useMemo(() => {
- switch (mode) {
- case GALLERY_DATE_MODE.YEAR:
- return 'YYYY';
- case GALLERY_DATE_MODE.MONTH:
- return 'YYYY-MM';
- case GALLERY_DATE_MODE.DAY:
- return 'YYYY-MM-DD';
- default:
- return 'YYYY-MM-DD';
- }
- }, [mode]);
-
- const groups = useMemo(() => {
- if (isFirstLoading) return [];
- const firstSort = metadata.view.sorts[0];
- let init = metadata.rows.filter(row => Utils.imageCheck(getFileNameFromRecord(row)))
- .reduce((_init, record) => {
- const id = record[PRIVATE_COLUMN_KEY.ID];
- const fileName = getFileNameFromRecord(record);
- const parentDir = getParentDirFromRecord(record);
- const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
- const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : '';
- const img = {
- id,
- name: fileName,
- path: parentDir,
- url: `${siteRoot}lib/${repoID}/file${path}`,
- src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
- thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`,
- downloadURL: `${fileServerRoot}repos/${repoID}/files${path}?op=download`,
- date: date,
- };
- let _group = _init.find(g => g.name === date);
- if (_group) {
- _group.children.push(img);
- } else {
- _init.push({
- name: date,
- children: [img],
- });
- }
- return _init;
- }, []);
-
- let _groups = [];
- const imageHeight = imageSize + GALLERY_IMAGE_GAP;
- const paddingTop = mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT;
- init.forEach((_init, index) => {
- const { children, ...__init } = _init;
- let top = 0;
- let rows = [];
- if (index > 0) {
- const lastGroup = _groups[index - 1];
- const { top: lastGroupTop, height: lastGroupHeight } = lastGroup;
- top = lastGroupTop + lastGroupHeight;
- }
- children.forEach((child, childIndex) => {
- const rowIndex = ~~(childIndex / columns);
- if (!rows[rowIndex]) rows[rowIndex] = { top: paddingTop + top + rowIndex * imageHeight, children: [] };
- child.groupIndex = index;
- child.rowIndex = rowIndex;
- rows[rowIndex].children.push(child);
- });
-
- const height = rows.length * imageHeight + paddingTop;
- _groups.push({
- ...__init,
- top,
- height,
- paddingTop,
- children: rows
- });
- });
- return _groups;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]);
-
- const loadMore = useCallback(async () => {
+ const onLoadMore = useCallback(async () => {
if (isLoadingMore) return;
if (!metadata.hasMore) return;
setLoadingMore(true);
@@ -146,207 +30,12 @@ const Gallery = () => {
}, [isLoadingMore, metadata, store]);
- useEffect(() => {
- const gear = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0) || 0;
- setZoomGear(gear);
-
- const mode = window.sfMetadataContext.localStorage.getItem('gallery-group-by', GALLERY_DATE_MODE.DAY) || GALLERY_DATE_MODE.DAY;
- setMode(mode);
-
- const switchGalleryModeSubscribe = window.sfMetadataContext.eventBus.subscribe(
- EVENT_BUS_TYPE.SWITCH_GALLERY_GROUP_BY,
- (mode) => {
- setMode(mode);
- window.sfMetadataContext.localStorage.setItem('gallery-group-by', mode);
- }
- );
-
- const container = containerRef.current;
- if (container) {
- const { offsetWidth, clientHeight } = container;
- setContainerWidth(offsetWidth);
-
- // Calculate initial overScan information
- const columns = 8 - gear;
- const imageSize = (offsetWidth - columns * 2 - 2) / columns;
- setOverScan({ top: 0, bottom: clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 2 });
- }
- setFirstLoading(false);
-
- // resize
- const handleResize = () => {
- if (!container) return;
- setContainerWidth(container.offsetWidth);
- };
- const resizeObserver = new ResizeObserver(handleResize);
- container && resizeObserver.observe(container);
-
- // op
- const modifyGalleryZoomGearSubscribe = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, (zoomGear) => {
- window.sfMetadataContext.localStorage.setItem('zoom-gear', zoomGear);
- setZoomGear(zoomGear);
- });
-
- return () => {
- container && resizeObserver.unobserve(container);
- modifyGalleryZoomGearSubscribe();
- switchGalleryModeSubscribe();
- renderMoreTimer.current && clearTimeout(renderMoreTimer.current);
- };
- }, []);
-
- useEffect(() => {
- if (!imageSize || imageSize < 0) return;
- if (imageSize === lastState.current.imageSize) return;
- const perImageOffset = imageSize - lastState.current.imageSize;
- const { groupIndex, rowIndex } = lastState.current.visibleAreaFirstImage;
- const rowOffset = groups.reduce((previousValue, current, currentIndex) => {
- if (currentIndex < groupIndex) {
- return previousValue + current.children.length;
- }
- return previousValue;
- }, 0) + rowIndex;
- const topOffset = rowOffset * perImageOffset + groupIndex * (mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT);
- containerRef.current.scrollTop = containerRef.current.scrollTop + topOffset;
- lastState.current = { ...lastState.current, imageSize };
- }, [imageSize, groups, mode]);
-
- const handleScroll = useCallback(() => {
- if (!containerRef.current) return;
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
- if (scrollTop + clientHeight >= scrollHeight - 10) {
- loadMore();
- } else {
- renderMoreTimer.current && clearTimeout(renderMoreTimer.current);
- renderMoreTimer.current = setTimeout(() => {
- const { scrollTop, clientHeight } = containerRef.current;
- const overScanTop = Math.max(0, scrollTop - (imageSize + GALLERY_IMAGE_GAP) * 3);
- const overScanBottom = scrollTop + clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 3;
- let groupIndex = 0;
- let rowIndex = 0;
- let flag = false;
- for (let i = 0; i < groups.length; i++) {
- const group = groups[i];
- for (let j = 0; j < group.children.length; j++) {
- const row = group.children[j];
- if (row.top >= scrollTop) {
- groupIndex = i;
- rowIndex = j;
- flag = true;
- }
- if (flag) break;
- }
- if (flag) break;
- }
- lastState.current = { ...lastState.current, visibleAreaFirstImage: { groupIndex, rowIndex } };
- setOverScan({ top: overScanTop, bottom: overScanBottom });
- renderMoreTimer.current = null;
- }, 200);
- }
- }, [imageSize, loadMore, renderMoreTimer, groups]);
-
- const imageItems = useMemo(() => {
- return groups.flatMap(group => group.children.flatMap(row => row.children));
- }, [groups]);
-
- const updateSelectedImage = useCallback((image = null) => {
- const imageInfo = image ? getRowById(metadata, image.id) : null;
- if (!imageInfo) {
- updateCurrentDirent();
- return;
- }
- updateCurrentDirent({
- type: 'file',
- name: image.name,
- path: image.path,
- file_tags: []
- });
- }, [metadata, updateCurrentDirent]);
-
- const handleClick = useCallback((event, image) => {
- if (event.metaKey || event.ctrlKey) {
- setSelectedImages(prev =>
- prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image]
- );
- updateSelectedImage(image);
- } else if (event.shiftKey && selectedImages.length > 0) {
- const lastSelected = selectedImages[selectedImages.length - 1];
- const start = imageItems.indexOf(lastSelected);
- const end = imageItems.indexOf(image);
- const range = imageItems.slice(Math.min(start, end), Math.max(start, end) + 1);
- setSelectedImages(prev => Array.from(new Set([...prev, ...range])));
- updateSelectedImage(null);
- } else {
- setSelectedImages([image]);
- updateSelectedImage(image);
- }
- }, [imageItems, selectedImages, updateSelectedImage]);
-
- const handleDoubleClick = useCallback((event, image) => {
- const index = imageItems.findIndex(item => item.id === image.id);
- setImageIndex(index);
- setIsImagePopupOpen(true);
- }, [imageItems]);
-
- const handleRightClick = useCallback((event, image) => {
- event.preventDefault();
- const index = imageItems.findIndex(item => item.id === image.id);
- if (isNaN(index) || index === -1) return;
-
- setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]);
- }, [imageItems]);
-
- const moveToPrevImage = () => {
- const imageItemsLength = imageItems.length;
- setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength);
- };
-
- const moveToNextImage = () => {
- const imageItemsLength = imageItems.length;
- setImageIndex((prevState) => (prevState + 1) % imageItemsLength);
- };
-
- const handleImageSelection = useCallback((selectedImages) => {
- setSelectedImages(selectedImages);
- }, []);
-
- const closeImagePopup = () => {
- setIsImagePopupOpen(false);
- };
-
- const handleDownload = useCallback(() => {
- if (selectedImages.length) {
- if (selectedImages.length === 1) {
- const image = selectedImages[0];
- let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name);
- let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath });
- location.href = url;
- } else {
- if (!useGoFileserver) {
- setIsZipDialogOpen(true);
- } else {
- const dirents = selectedImages.map(image => {
- const value = image.path === '/' ? image.name : `${image.path}/${image.name}`;
- return value;
- });
- metadataAPI.zipDownload(repoID, '/', dirents).then((res) => {
- const zipToken = res.data['zip_token'];
- location.href = `${fileServerRoot}zip/${zipToken}`;
- }).catch(error => {
- const errMessage = Utils.getErrorMsg(error);
- toaster.danger(errMessage);
- });
- }
- }
- }
- }, [repoID, selectedImages]);
-
- const handleDelete = useCallback(() => {
- if (!selectedImages.length) return;
+ const handleDelete = useCallback((deletedImages, callback) => {
+ if (!deletedImages.length) return;
let recordsIds = [];
let paths = [];
let fileNames = [];
- selectedImages.forEach((record) => {
+ deletedImages.forEach((record) => {
const { path: parentDir, name } = record || {};
if (parentDir && name) {
const path = Utils.joinPath(parentDir, name);
@@ -360,7 +49,7 @@ const Gallery = () => {
toaster.danger(error);
},
success_callback: () => {
- setSelectedImages([]);
+ callback && callback();
deleteFilesCallback(paths, fileNames);
let msg = fileNames.length > 1
? gettext('Successfully deleted {name} and {n} other items')
@@ -370,75 +59,11 @@ const Gallery = () => {
toaster.success(msg);
},
});
- }, [selectedImages, store, deleteFilesCallback]);
-
- const closeZipDialog = () => {
- setIsZipDialogOpen(false);
- };
-
- const handleClickOutside = useCallback((event) => {
- const className = getEventClassName(event);
- const isClickInsideImage = className.includes('metadata-gallery-image-item') || className.includes('metadata-gallery-grid-image');
-
- if (!isClickInsideImage && containerRef.current.contains(event.target)) {
- handleImageSelection([]);
- updateSelectedImage();
- }
- }, [handleImageSelection, updateSelectedImage]);
+ }, [store, deleteFilesCallback]);
return (
-
-
- {!isFirstLoading && (
- <>
-
- {isLoadingMore &&
-
-
-
- }
- >
- )}
-
-
containerRef.current.getBoundingClientRect()}
- getContainerRect={() => containerRef.current.getBoundingClientRect()}
- onDownload={handleDownload}
- onDelete={handleDelete}
- />
- {isImagePopupOpen && (
-
-
-
- )}
- {isZipDialogOpen &&
-
- image.path === '/' ? image.name : `${image.path}/${image.name}`)}
- toggleDialog={closeZipDialog}
- />
-
- }
+
+
);
};
diff --git a/frontend/src/metadata/views/gallery/main.js b/frontend/src/metadata/views/gallery/main.js
index d05db1c80d..549db124fb 100644
--- a/frontend/src/metadata/views/gallery/main.js
+++ b/frontend/src/metadata/views/gallery/main.js
@@ -1,197 +1,416 @@
-import React, { useState, useCallback, useMemo, useRef } from 'react';
+import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
-import EmptyTip from '../../../components/empty-tip';
-import { gettext } from '../../../utils/constants';
-import { GALLERY_DATE_MODE } from '../../constants';
-import Image from './image';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import metadataAPI from '../../api';
+import URLDecorator from '../../../utils/url-decorator';
+import toaster from '../../../components/toast';
+import Content from './content';
+import ContextMenu from './context-menu';
+import ImageDialog from '../../../components/dialog/image-dialog';
+import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog';
+import ModalPortal from '../../../components/modal-portal';
+import { useMetadataView } from '../../hooks/metadata-view';
+import { Utils } from '../../../utils/utils';
+import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell';
+import { siteRoot, fileServerRoot, useGoFileserver, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants';
+import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants';
+import { getRowById } from '../../utils/table';
+import { getEventClassName } from '../../utils/common';
+
+import './index.css';
+
+const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => {
+ const [isFirstLoading, setFirstLoading] = useState(true);
+ const [zoomGear, setZoomGear] = useState(0);
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
+ const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY);
+ const [isImagePopupOpen, setIsImagePopupOpen] = useState(false);
+ const [isZipDialogOpen, setIsZipDialogOpen] = useState(false);
+ const [imageIndex, setImageIndex] = useState(0);
+ const [selectedImages, setSelectedImages] = useState([]);
-const GalleryMain = ({
- groups,
- overScan,
- columns,
- size,
- gap,
- mode,
- selectedImages,
- onImageSelect,
- onImageClick,
- onImageDoubleClick,
- onImageRightClick
-}) => {
const containerRef = useRef(null);
- const imageRef = useRef(null);
- const animationFrameRef = useRef(null);
+ const renderMoreTimer = useRef(null);
+ const lastState = useRef({ visibleAreaFirstImage: { groupIndex: 0, rowIndex: 0 } });
- const [isSelecting, setIsSelecting] = useState(false);
- const [selectionStart, setSelectionStart] = useState(null);
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+ const { updateCurrentDirent } = useMetadataView();
- const imageHeight = useMemo(() => size + gap, [size, gap]);
- const selectedImageIds = useMemo(() => selectedImages.map(img => img.id), [selectedImages]);
-
- const handleMouseDown = useCallback((e) => {
- if (e.button !== 0) return;
- if (e.ctrlKey || e.metaKey || e.shiftKey) return;
-
- setIsSelecting(true);
- setSelectionStart({ x: e.clientX, y: e.clientY });
+ useEffect(() => {
+ updateCurrentDirent();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const handleMouseMove = useCallback((e) => {
- if (!isSelecting) return;
+ // Number of images per row
+ const columns = useMemo(() => {
+ return 8 - zoomGear;
+ }, [zoomGear]);
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
+ const imageSize = useMemo(() => {
+ return (containerWidth - (columns - 1) * 2 - 32) / columns;
+ }, [containerWidth, columns]);
+
+ const dateMode = useMemo(() => {
+ switch (mode) {
+ case GALLERY_DATE_MODE.YEAR:
+ return 'YYYY';
+ case GALLERY_DATE_MODE.MONTH:
+ return 'YYYY-MM';
+ case GALLERY_DATE_MODE.DAY:
+ return 'YYYY-MM-DD';
+ default:
+ return 'YYYY-MM-DD';
}
+ }, [mode]);
- animationFrameRef.current = requestAnimationFrame(() => {
- e.preventDefault();
- e.stopPropagation();
-
- const selectionEnd = { x: e.clientX, y: e.clientY };
- const selected = [];
-
- groups.forEach(group => {
- group.children.forEach((row) => {
- row.children.forEach((img) => {
- const imgElement = document.getElementById(img.id);
- if (imgElement) {
- const rect = imgElement.getBoundingClientRect();
- if (
- rect.left < Math.max(selectionStart.x, selectionEnd.x) &&
- rect.right > Math.min(selectionStart.x, selectionEnd.x) &&
- rect.top < Math.max(selectionStart.y, selectionEnd.y) &&
- rect.bottom > Math.min(selectionStart.y, selectionEnd.y)
- ) {
- selected.push(img);
- }
- }
+ const groups = useMemo(() => {
+ if (isFirstLoading) return [];
+ const firstSort = metadata.view.sorts[0];
+ let init = metadata.rows.filter(row => Utils.imageCheck(getFileNameFromRecord(row)))
+ .reduce((_init, record) => {
+ const id = record[PRIVATE_COLUMN_KEY.ID];
+ const fileName = getFileNameFromRecord(record);
+ const parentDir = getParentDirFromRecord(record);
+ const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
+ const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : '';
+ const img = {
+ id,
+ name: fileName,
+ path: parentDir,
+ url: `${siteRoot}lib/${repoID}/file${path}`,
+ src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
+ thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`,
+ downloadURL: `${fileServerRoot}repos/${repoID}/files${path}?op=download`,
+ date: date,
+ };
+ let _group = _init.find(g => g.name === date);
+ if (_group) {
+ _group.children.push(img);
+ } else {
+ _init.push({
+ name: date,
+ children: [img],
});
- });
+ }
+ return _init;
+ }, []);
+
+ let _groups = [];
+ const imageHeight = imageSize + GALLERY_IMAGE_GAP;
+ const paddingTop = mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT;
+ init.forEach((_init, index) => {
+ const { children, ...__init } = _init;
+ let top = 0;
+ let rows = [];
+ if (index > 0) {
+ const lastGroup = _groups[index - 1];
+ const { top: lastGroupTop, height: lastGroupHeight } = lastGroup;
+ top = lastGroupTop + lastGroupHeight;
+ }
+ children.forEach((child, childIndex) => {
+ const rowIndex = ~~(childIndex / columns);
+ if (!rows[rowIndex]) rows[rowIndex] = { top: paddingTop + top + rowIndex * imageHeight, children: [] };
+ child.groupIndex = index;
+ child.rowIndex = rowIndex;
+ rows[rowIndex].children.push(child);
});
- onImageSelect(selected);
+ const height = rows.length * imageHeight + paddingTop;
+ _groups.push({
+ ...__init,
+ top,
+ height,
+ paddingTop,
+ children: rows
+ });
});
- }, [groups, isSelecting, selectionStart, onImageSelect]);
+ return _groups;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]);
- const handleMouseUp = useCallback((e) => {
- if (e.button !== 0) return;
+ useEffect(() => {
+ const gear = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0) || 0;
+ setZoomGear(gear);
- e.preventDefault();
- e.stopPropagation();
- setIsSelecting(false);
+ const mode = window.sfMetadataContext.localStorage.getItem('gallery-group-by', GALLERY_DATE_MODE.DAY) || GALLERY_DATE_MODE.DAY;
+ setMode(mode);
+
+ const switchGalleryModeSubscribe = window.sfMetadataContext.eventBus.subscribe(
+ EVENT_BUS_TYPE.SWITCH_GALLERY_GROUP_BY,
+ (mode) => {
+ setMode(mode);
+ window.sfMetadataContext.localStorage.setItem('gallery-group-by', mode);
+ }
+ );
+
+ const container = containerRef.current;
+ if (container) {
+ const { offsetWidth, clientHeight } = container;
+ setContainerWidth(offsetWidth);
+
+ // Calculate initial overScan information
+ const columns = 8 - gear;
+ const imageSize = (offsetWidth - columns * 2 - 2) / columns;
+ setOverScan({ top: 0, bottom: clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 2 });
+ }
+ setFirstLoading(false);
+
+ // resize
+ const handleResize = () => {
+ if (!container) return;
+ setContainerWidth(container.offsetWidth);
+ };
+ const resizeObserver = new ResizeObserver(handleResize);
+ container && resizeObserver.observe(container);
+
+ // op
+ const modifyGalleryZoomGearSubscribe = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, (zoomGear) => {
+ window.sfMetadataContext.localStorage.setItem('zoom-gear', zoomGear);
+ setZoomGear(zoomGear);
+ });
+
+ return () => {
+ container && resizeObserver.unobserve(container);
+ modifyGalleryZoomGearSubscribe();
+ switchGalleryModeSubscribe();
+ renderMoreTimer.current && clearTimeout(renderMoreTimer.current);
+ };
}, []);
- const renderDisplayGroup = useCallback((group) => {
- const { top: overScanTop, bottom: overScanBottom } = overScan;
- const { name, children, height, top, paddingTop } = group;
+ useEffect(() => {
+ if (!imageSize || imageSize < 0) return;
+ if (imageSize === lastState.current.imageSize) return;
+ const perImageOffset = imageSize - lastState.current.imageSize;
+ const { groupIndex, rowIndex } = lastState.current.visibleAreaFirstImage;
+ const rowOffset = groups.reduce((previousValue, current, currentIndex) => {
+ if (currentIndex < groupIndex) {
+ return previousValue + current.children.length;
+ }
+ return previousValue;
+ }, 0) + rowIndex;
+ const topOffset = rowOffset * perImageOffset + groupIndex * (mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT);
+ containerRef.current.scrollTop = containerRef.current.scrollTop + topOffset;
+ lastState.current = { ...lastState.current, imageSize };
+ }, [imageSize, groups, mode]);
- // group not in rendering area, return empty div
- if (top >= overScanBottom || top + height <= overScanTop) {
- return ();
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ if (scrollTop + clientHeight >= scrollHeight - 10) {
+ onLoadMore();
+ } else {
+ renderMoreTimer.current && clearTimeout(renderMoreTimer.current);
+ renderMoreTimer.current = setTimeout(() => {
+ const { scrollTop, clientHeight } = containerRef.current;
+ const overScanTop = Math.max(0, scrollTop - (imageSize + GALLERY_IMAGE_GAP) * 3);
+ const overScanBottom = scrollTop + clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 3;
+ let groupIndex = 0;
+ let rowIndex = 0;
+ let flag = false;
+ for (let i = 0; i < groups.length; i++) {
+ const group = groups[i];
+ for (let j = 0; j < group.children.length; j++) {
+ const row = group.children[j];
+ if (row.top >= scrollTop) {
+ groupIndex = i;
+ rowIndex = j;
+ flag = true;
+ }
+ if (flag) break;
+ }
+ if (flag) break;
+ }
+ lastState.current = { ...lastState.current, visibleAreaFirstImage: { groupIndex, rowIndex } };
+ setOverScan({ top: overScanTop, bottom: overScanBottom });
+ renderMoreTimer.current = null;
+ }, 200);
}
+ }, [imageSize, onLoadMore, renderMoreTimer, groups]);
- let childrenStartIndex = children.findIndex(r => r.top >= overScanTop);
- let childrenEndIndex = children.findIndex(r => r.top >= overScanBottom);
+ const imageItems = useMemo(() => {
+ return groups.flatMap(group => group.children.flatMap(row => row.children));
+ }, [groups]);
- // group in rendering area, but the image not need to render. eg: overScan: { top: 488, bottom: 1100 }, group: { top: 0, height: 521 },
- // in this time, part of an image is in the rendering area, don't render image
- if (childrenStartIndex === -1 && childrenEndIndex === -1) {
- return ();
+ const updateSelectedImage = useCallback((image = null) => {
+ const imageInfo = image ? getRowById(metadata, image.id) : null;
+ if (!imageInfo) {
+ updateCurrentDirent();
+ return;
}
+ updateCurrentDirent({
+ type: 'file',
+ name: image.name,
+ path: image.path,
+ file_tags: []
+ });
+ }, [metadata, updateCurrentDirent]);
- childrenStartIndex = Math.max(childrenStartIndex, 0);
- if (childrenEndIndex === -1) {
- childrenEndIndex = children.length;
+ const handleClick = useCallback((event, image) => {
+ if (event.metaKey || event.ctrlKey) {
+ setSelectedImages(prev =>
+ prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image]
+ );
+ updateSelectedImage(image);
+ } else if (event.shiftKey && selectedImages.length > 0) {
+ const lastSelected = selectedImages[selectedImages.length - 1];
+ const start = imageItems.indexOf(lastSelected);
+ const end = imageItems.indexOf(image);
+ const range = imageItems.slice(Math.min(start, end), Math.max(start, end) + 1);
+ setSelectedImages(prev => Array.from(new Set([...prev, ...range])));
+ updateSelectedImage(null);
+ } else {
+ setSelectedImages([image]);
+ updateSelectedImage(image);
}
- if (childrenEndIndex > 0) {
- childrenEndIndex = childrenEndIndex - 1;
+ }, [imageItems, selectedImages, updateSelectedImage]);
+
+ const handleDoubleClick = useCallback((event, image) => {
+ const index = imageItems.findIndex(item => item.id === image.id);
+ setImageIndex(index);
+ setIsImagePopupOpen(true);
+ }, [imageItems]);
+
+ const handleRightClick = useCallback((event, image) => {
+ event.preventDefault();
+ const index = imageItems.findIndex(item => item.id === image.id);
+ if (isNaN(index) || index === -1) return;
+
+ setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]);
+ }, [imageItems]);
+
+ const moveToPrevImage = () => {
+ const imageItemsLength = imageItems.length;
+ setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength);
+ };
+
+ const moveToNextImage = () => {
+ const imageItemsLength = imageItems.length;
+ setImageIndex((prevState) => (prevState + 1) % imageItemsLength);
+ };
+
+ const handleImageSelection = useCallback((selectedImages) => {
+ setSelectedImages(selectedImages);
+ }, []);
+
+ const closeImagePopup = () => {
+ setIsImagePopupOpen(false);
+ };
+
+ const handleDownload = useCallback(() => {
+ if (!selectedImages.length) return;
+ if (selectedImages.length === 1) {
+ const image = selectedImages[0];
+ let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name);
+ let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath });
+ location.href = url;
+ return;
}
+ if (!useGoFileserver) {
+ setIsZipDialogOpen(true);
+ return;
+ }
+ const dirents = selectedImages.map(image => {
+ const value = image.path === '/' ? image.name : `${image.path}/${image.name}`;
+ return value;
+ });
+ metadataAPI.zipDownload(repoID, '/', dirents).then((res) => {
+ const zipToken = res.data['zip_token'];
+ location.href = `${fileServerRoot}zip/${zipToken}`;
+ }).catch(error => {
+ const errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ }, [repoID, selectedImages]);
- return (
-
- {mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && (
-
{name || gettext('Empty')}
- )}
-
- {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
- return row.children.map((img) => {
- const isSelected = selectedImageIds.includes(img.id);
- return (
- onImageClick(e, img)}
- onDoubleClick={(e) => onImageDoubleClick(e, img)}
- onContextMenu={(e) => onImageRightClick(e, img)}
- />
- );
- });
- })}
-
-
- );
- }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]);
+ const handleDelete = useCallback(() => {
+ if (!selectedImages.length) return;
+ onDelete(selectedImages, () => {
+ setSelectedImages([]);
+ });
+ }, [selectedImages, onDelete]);
- if (!Array.isArray(groups) || groups.length === 0) {
- return ;
- }
+ const closeZipDialog = () => {
+ setIsZipDialogOpen(false);
+ };
+
+ const handleClickOutside = useCallback((event) => {
+ const className = getEventClassName(event);
+ const isClickInsideImage = className.includes('metadata-gallery-image-item') || className.includes('metadata-gallery-grid-image');
+
+ if (!isClickInsideImage && containerRef.current.contains(event.target)) {
+ handleImageSelection([]);
+ updateSelectedImage();
+ }
+ }, [handleImageSelection, updateSelectedImage]);
return (
-
- {groups.map((group) => {
- return renderDisplayGroup(group);
- })}
-
+ <>
+
+ {!isFirstLoading && (
+ <>
+
+ {isLoadingMore &&
+
+
+
+ }
+ >
+ )}
+
+ containerRef.current.getBoundingClientRect()}
+ getContainerRect={() => containerRef.current.getBoundingClientRect()}
+ onDownload={handleDownload}
+ onDelete={handleDelete}
+ />
+ {isImagePopupOpen && (
+
+
+
+ )}
+ {isZipDialogOpen && (
+
+ image.path === '/' ? image.name : `${image.path}/${image.name}`)}
+ toggleDialog={closeZipDialog}
+ />
+
+ )}
+ >
);
};
-GalleryMain.propTypes = {
- groups: PropTypes.arrayOf(PropTypes.shape({
- name: PropTypes.string.isRequired,
- children: PropTypes.arrayOf(PropTypes.shape({
- top: PropTypes.number.isRequired,
- children: PropTypes.arrayOf(PropTypes.shape({
- src: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- })).isRequired,
- })).isRequired,
- height: PropTypes.number.isRequired,
- top: PropTypes.number.isRequired,
- paddingTop: PropTypes.number.isRequired,
- })),
- overScan: PropTypes.shape({
- top: PropTypes.number.isRequired,
- bottom: PropTypes.number.isRequired,
- }).isRequired,
- columns: PropTypes.number.isRequired,
- size: PropTypes.number.isRequired,
- gap: PropTypes.number.isRequired,
- mode: PropTypes.string,
- selectedImages: PropTypes.array.isRequired,
- onImageSelect: PropTypes.func.isRequired,
- onImageClick: PropTypes.func.isRequired,
- onImageDoubleClick: PropTypes.func.isRequired,
- onImageRightClick: PropTypes.func.isRequired,
+Main.propTypes = {
+ isLoadingMore: PropTypes.bool,
+ metadata: PropTypes.object,
+ onDelete: PropTypes.func,
+ onLoadMore: PropTypes.func
};
-export default GalleryMain;
+export default Main;
diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js
index 3eb676522c..b94c6d38a7 100644
--- a/frontend/src/pages/lib-content-view/lib-content-view.js
+++ b/frontend/src/pages/lib-content-view/lib-content-view.js
@@ -23,7 +23,7 @@ import DeleteFolderDialog from '../../components/dialog/delete-folder-dialog';
import { EVENT_BUS_TYPE } from '../../components/common/event-bus-type';
import { PRIVATE_FILE_TYPE } from '../../constants';
import { MetadataProvider, CollaboratorsProvider } from '../../metadata/hooks';
-import { LIST_MODE, METADATA_MODE, FACE_RECOGNITION_MODE, DIRENT_DETAIL_MODE } from '../../components/dir-view-mode/constants';
+import { LIST_MODE, METADATA_MODE, DIRENT_DETAIL_MODE } from '../../components/dir-view-mode/constants';
import CurDirPath from '../../components/cur-dir-path';
import DirTool from '../../components/cur-dir-path/dir-tool';
import Detail from '../../components/dirent-detail';
@@ -547,19 +547,6 @@ class LibContentView extends React.Component {
window.history.pushState({ url: url, path: '' }, '', url);
};
- showFaceRecognition = (filePath, viewId) => {
- const repoID = this.props.repoID;
- const repoInfo = this.state.currentRepoInfo;
- this.setState({
- currentMode: FACE_RECOGNITION_MODE,
- path: filePath,
- viewId: viewId,
- isDirentDetailShow: false
- });
- const url = `${siteRoot}library/${repoID}/${encodeURIComponent(repoInfo.repo_name)}/?view=${encodeURIComponent(viewId)}`;
- window.history.pushState({ url: url, path: '' }, '', url);
- };
-
hideFileMetadata = () => {
this.setState({
currentMode: LIST_MODE,
@@ -1891,10 +1878,6 @@ class LibContentView extends React.Component {
if (node.path !== this.state.path) {
this.showFileMetadata(node.path, node.view_id || '0000', node.view_type || VIEW_TYPE.TABLE);
}
- } else if (Utils.isFaceRecognition(node?.object?.type)) {
- if (node.path !== this.state.path) {
- this.showFaceRecognition(node.path, node.view_id || '0000');
- }
} else {
let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path);
let dirent = node.object;
@@ -2032,7 +2015,7 @@ class LibContentView extends React.Component {
isDirentSelected: false,
isAllDirentSelected: false,
});
- if (this.state.currentMode === METADATA_MODE || this.state.currentMode === FACE_RECOGNITION_MODE) {
+ if (this.state.currentMode === METADATA_MODE) {
this.setState({
currentMode: cookie.load('seafile_view_mode') || LIST_MODE,
});
@@ -2422,6 +2405,7 @@ class LibContentView extends React.Component {
getMarkDownFileName={this.getMarkDownFileName}
openMarkdownFile={this.openMarkdownFile}
updateCurrentDirent={this.updateCurrentDirent}
+ closeDirentDetail={this.closeDirentDetail}
/>
:
{gettext('Folder does not exist.')}
diff --git a/media/favicons/face-recognition-view.png b/media/favicons/face-recognition-view.png
new file mode 100644
index 0000000000..53192e902a
Binary files /dev/null and b/media/favicons/face-recognition-view.png differ
diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py
index 7a29f4fdd9..5d9e9535ee 100644
--- a/seahub/api2/endpoints/metadata_manage.py
+++ b/seahub/api2/endpoints/metadata_manage.py
@@ -13,7 +13,8 @@ from seahub.api2.authentication import TokenAuthentication
from seahub.repo_metadata.models import RepoMetadata, RepoMetadataViews
from seahub.views import check_folder_permission
from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id, init_metadata, \
- get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, get_metadata_by_faces, extract_file_details
+ get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, \
+ extract_file_details, get_someone_similar_faces
from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_view_records
from seahub.utils.timeutils import datetime_to_isoformat_timestr
from seahub.utils.repo import is_repo_admin
@@ -831,7 +832,7 @@ class FacesRecords(APIView):
def get(self, request, repo_id):
start = request.GET.get('start', 0)
- limit = request.GET.get('limit', 100)
+ limit = request.GET.get('limit', 1000)
try:
start = int(start)
@@ -853,19 +854,18 @@ class FacesRecords(APIView):
error_msg = f'The metadata module is disabled for repo {repo_id}.'
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
- permission = check_folder_permission(request, repo_id, '/')
- if not permission:
- error_msg = 'Permission denied.'
- return api_error(status.HTTP_403_FORBIDDEN, error_msg)
-
repo = seafile_api.get_repo(repo_id)
if not repo:
error_msg = 'Library %s not found.' % repo_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+ if not can_read_metadata(request, repo_id):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.utils import FACES_TABLE
try:
metadata = metadata_server_api.get_metadata()
@@ -880,7 +880,7 @@ class FacesRecords(APIView):
if not faces_table_id:
return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used')
- sql = f'SELECT * FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.photo_links.name}` IS NOT NULL LIMIT {start}, {limit}'
+ sql = f'SELECT `{FACES_TABLE.columns.id.name}`, `{FACES_TABLE.columns.name.name}`, `{FACES_TABLE.columns.photo_links.name}`, `{FACES_TABLE.columns.vector.name}` FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.photo_links.name}` IS NOT NULL LIMIT {start}, {limit}'
try:
query_result = metadata_server_api.query_rows(sql)
@@ -889,49 +889,60 @@ class FacesRecords(APIView):
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
- faces = query_result.get('results')
+ faces_records = query_result.get('results')
+ metadata_columns = query_result.get('metadata', [])
+ metadata_columns.append({
+ 'key': '_similar_photo',
+ 'type': 'text',
+ 'name': '_similar_photo',
+ 'data': None,
+ })
+ metadata_columns.append({
+ 'key': '_is_someone',
+ 'type': 'checkbox',
+ 'name': '_is_someone',
+ 'data': None,
+ })
- if not faces:
- error_msg = 'Records not found'
- return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+ if not faces_records:
+ return Response({
+ 'metadata': metadata_columns,
+ 'results': [],
+ })
try:
- query_result = get_metadata_by_faces(faces, metadata_server_api)
+ similar_result = get_someone_similar_faces(faces_records, metadata_server_api)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
- if not query_result:
- error_msg = 'Records not found'
- return api_error(status.HTTP_404_NOT_FOUND, error_msg)
- classify_result = dict()
- for row in query_result:
- link_row_ids = [item['row_id'] for item in row.get(METADATA_TABLE.columns.face_links.name, [])]
+ if not query_result:
+ return Response({
+ 'metadata': metadata_columns,
+ 'results': [],
+ })
+
+ similar_result_dict = dict()
+ for row in similar_result:
+ similar_result_dict[row['_id']] = row
+
+ for record in faces_records:
+ vector = record.get(FACES_TABLE.columns.vector.name, None)
+ record['_is_someone'] = bool(vector)
+ if FACES_TABLE.columns.vector.name in record:
+ del record[FACES_TABLE.columns.vector.name]
+ link_row_ids = [item['row_id'] for item in record.get(FACES_TABLE.columns.photo_links.name, [])]
if not link_row_ids:
continue
- for link_row_id in link_row_ids:
- if link_row_id not in classify_result:
- classify_result[link_row_id] = []
- file_name = row.get(METADATA_TABLE.columns.file_name.name, '')
- parent_dir = row.get(METADATA_TABLE.columns.parent_dir.name, '')
- size = row.get(METADATA_TABLE.columns.size.name, 0)
- mtime = row.get('_mtime')
- classify_result[link_row_id].append({
- 'path': os.path.join(parent_dir, file_name),
- 'file_name': file_name,
- 'parent_dir': parent_dir,
- 'size': size,
- 'mtime': mtime
- })
+ link_row_id = link_row_ids[0]
+ if link_row_id in similar_result_dict:
+ record['_similar_photo'] = similar_result_dict[link_row_id]
- id_to_name = {item.get(FACES_TABLE.columns.id.name): item.get(FACES_TABLE.columns.name.name, '') for item in faces}
- classify_result = [{
- 'record_id': key,
- 'name': id_to_name.get(key, ''),
- 'link_photos': value
- } for key, value in classify_result.items()]
- return Response({'results': classify_result})
+ return Response({
+ 'metadata': metadata_columns,
+ 'results': faces_records,
+ })
class FacesRecord(APIView):
@@ -999,6 +1010,91 @@ class FacesRecord(APIView):
return Response({'success': True})
+class PeoplePhotos(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, people_id):
+ start = request.GET.get('start', 0)
+ limit = request.GET.get('limit', 1000)
+
+ try:
+ start = int(start)
+ limit = int(limit)
+ except:
+ start = 0
+ limit = 1000
+
+ if start < 0:
+ error_msg = 'start invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if limit < 0:
+ error_msg = 'limit invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ metadata = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not metadata or not metadata.enabled:
+ error_msg = f'The metadata module is disabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ if not can_read_metadata(request, repo_id):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+ from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+
+ try:
+ metadata = metadata_server_api.get_metadata()
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ tables = metadata.get('tables', [])
+ faces_table_id = [table['id'] for table in tables if table['name'] == FACES_TABLE.name]
+ faces_table_id = faces_table_id[0] if faces_table_id else None
+ if not faces_table_id:
+ return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used')
+
+ sql = f'SELECT `{FACES_TABLE.columns.photo_links.name}` FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.id.name}` = "{people_id}" LIMIT {start}, {limit}'
+
+ try:
+ query_result = metadata_server_api.query_rows(sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ faces_records = query_result.get('results')
+
+ if not faces_records:
+ return Response({'metadata': [], 'results': []})
+
+ faces_record = faces_records[0]
+
+ try:
+ record_ids = [item['row_id'] for item in faces_record.get(FACES_TABLE.columns.photo_links.name, [])]
+ selected_ids = record_ids[start:limit]
+ selected_ids_str = ', '.join(["'%s'" % id for id in selected_ids])
+
+ sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}`, `{METADATA_TABLE.columns.file_ctime.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ({selected_ids_str})'
+ someone_photos_result = metadata_server_api.query_rows(sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response(someone_photos_result)
+
+
class FaceRecognitionManage(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, )
diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py
index a152aa5bbc..b620064070 100644
--- a/seahub/repo_metadata/metadata_server_api.py
+++ b/seahub/repo_metadata/metadata_server_api.py
@@ -47,6 +47,18 @@ def list_metadata_view_records(repo_id, user, view, start=0, limit=1000):
metadata_server_api = MetadataServerAPI(repo_id, user)
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
sql = gen_view_data_sql(METADATA_TABLE, columns, view, start, limit, user)
+
+ # Remove face-vectors from the query SQL because they are too large
+ query_fields_str = ''
+ for column in columns:
+ column_name = column.get('name')
+ if column_name == METADATA_TABLE.columns.face_vectors.name:
+ continue
+ column_name_str = '`%s`, ' % column_name
+ query_fields_str += column_name_str
+ query_fields_str = query_fields_str.strip(', ')
+ sql = sql.replace('*', query_fields_str)
+
response_results = metadata_server_api.query_rows(sql, [])
return response_results
@@ -70,7 +82,7 @@ class MetadataServerAPI:
def gen_headers(self):
payload = {
- 'exp': int(time.time()) + 3600,
+ 'exp': int(time.time()) + 3600,
'base_id': self.base_id,
'user': self.user
}
@@ -88,7 +100,7 @@ class MetadataServerAPI:
if response.status_code == 404:
return {'success': True}
return parse_response(response)
-
+
def insert_rows(self, table_id, rows):
url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/rows'
data = {
@@ -97,7 +109,7 @@ class MetadataServerAPI:
}
response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout)
return parse_response(response)
-
+
def update_rows(self, table_id, rows):
url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/rows'
data = {
@@ -153,7 +165,7 @@ class MetadataServerAPI:
}
response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout)
return parse_response(response)
-
+
def delete_column(self, table_id, column_key):
url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns'
data = {
@@ -162,7 +174,7 @@ class MetadataServerAPI:
}
response = requests.delete(url, json=data, headers=self.headers, timeout=self.timeout)
return parse_response(response)
-
+
def update_column(self, table_id, column):
url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns'
data = {
diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py
index 52486ad708..1e5100e21e 100644
--- a/seahub/repo_metadata/utils.py
+++ b/seahub/repo_metadata/utils.py
@@ -29,24 +29,24 @@ def add_init_face_recognition_task(params):
return json.loads(resp.content)['task_id']
-def get_metadata_by_faces(faces, metadata_server_api):
+def get_someone_similar_faces(faces, metadata_server_api):
from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
- sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
+ sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
parameters = []
query_result = []
for face in faces:
link_row_ids = [item['row_id'] for item in face.get(FACES_TABLE.columns.photo_links.name, [])]
if not link_row_ids:
continue
- for link_row_id in link_row_ids:
- sql += '?, '
- parameters.append(link_row_id)
- if len(parameters) >= 10000:
- sql = sql.rstrip(', ') + ');'
- results = metadata_server_api.query_rows(sql, parameters).get('results', [])
- query_result.extend(results)
- sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
- parameters = []
+ link_row_id = link_row_ids[0]
+ sql += '?, '
+ parameters.append(link_row_id)
+ if len(parameters) >= 10000:
+ sql = sql.rstrip(', ') + ');'
+ results = metadata_server_api.query_rows(sql, parameters).get('results', [])
+ query_result.extend(results)
+ sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
+ parameters = []
if parameters:
sql = sql.rstrip(', ') + ');'
diff --git a/seahub/urls.py b/seahub/urls.py
index f2adf3ad67..041e3615ce 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -214,7 +214,7 @@ from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView,
from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView
from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \
MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
- FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails
+ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos
from seahub.api2.endpoints.user_list import UserListView
from seahub.api2.endpoints.seahub_io import SeahubIOStatus
@@ -1060,6 +1060,7 @@ if settings.ENABLE_METADATA_MANAGEMENT:
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/duplicate-view/$', MetadataViewsDuplicateView.as_view(), name='api-v2.1-metadata-view-duplicate'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-record/$', FacesRecord.as_view(), name='api-v2.1-metadata-face-record'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-records/$', FacesRecords.as_view(), name='api-v2.1-metadata-face-records'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/people-photos/(?P.+)/$', PeoplePhotos.as_view(), name='api-v2.1-metadata-people-photos'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-recognition/$', FaceRecognitionManage.as_view(), name='api-v2.1-metadata-face-recognition'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/extract-file-details/$', MetadataExtractFileDetails.as_view(), name='api-v2.1-metadata-extract-file-details'),
]