From 2cb758e3023a07c89a6dafb87a75e8fa581b5703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E7=92=87?= <37972689+YangGuoXuan-0503@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:37:24 +0800 Subject: [PATCH] refactor: face ui (#6981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: face ui * feat: people pgotos * feat: people pgotos * feat: optimize ui * feat: optimize ui * feat: delete file * optimize code * feat: replace icon * update * feat: replace icon * feat: optimize back btn --------- Co-authored-by: 杨国璇 Co-authored-by: 杨国璇 Co-authored-by: ‘JoinTyang’ --- .../assets/icons/face-recognition-view.svg | 15 + .../src/components/cur-dir-path/dir-tool.js | 2 - .../src/components/dir-view-mode/constants.js | 1 - .../dir-view-mode/dir-column-view.js | 12 +- .../dirent-detail/detail/header/index.css | 2 +- frontend/src/metadata/api.js | 9 +- .../metadata/components/view-details/index.js | 1 + .../view-toolbar/face-recognition/index.js | 46 ++ .../metadata/components/view-toolbar/index.js | 8 + .../src/metadata/constants/event-bus-type.js | 3 + frontend/src/metadata/constants/view.js | 3 + frontend/src/metadata/context.js | 33 +- frontend/src/metadata/hooks/metadata-view.js | 1 + frontend/src/metadata/hooks/metadata.js | 4 +- .../src/metadata/metadata-tree-view/index.js | 14 +- frontend/src/metadata/metadata-view/view.js | 6 +- frontend/src/metadata/store/index.js | 16 + .../src/metadata/store/operations/apply.js | 38 ++ .../metadata/store/operations/constants.js | 6 + .../src/metadata/store/server-operator.js | 11 + .../views/face-recognition/face-group.js | 113 ---- .../metadata/views/face-recognition/index.css | 24 +- .../metadata/views/face-recognition/index.js | 173 +----- .../views/face-recognition/peoples/index.css | 17 + .../views/face-recognition/peoples/index.js | 103 ++++ .../face-recognition/peoples/people/index.css | 75 +++ .../face-recognition/peoples/people/index.js | 124 ++++ .../peoples/people/op-menu/index.js | 65 +++ .../face-recognition/person-photos/index.css | 56 ++ .../face-recognition/person-photos/index.js | 153 +++++ .../src/metadata/views/gallery/content.js | 197 +++++++ frontend/src/metadata/views/gallery/index.js | 399 +------------ frontend/src/metadata/views/gallery/main.js | 543 ++++++++++++------ .../lib-content-view/lib-content-view.js | 22 +- media/favicons/face-recognition-view.png | Bin 0 -> 1120 bytes seahub/api2/endpoints/metadata_manage.py | 178 ++++-- seahub/repo_metadata/metadata_server_api.py | 22 +- seahub/repo_metadata/utils.py | 22 +- seahub/urls.py | 3 +- 39 files changed, 1593 insertions(+), 927 deletions(-) create mode 100644 frontend/src/assets/icons/face-recognition-view.svg create mode 100644 frontend/src/metadata/components/view-toolbar/face-recognition/index.js delete mode 100644 frontend/src/metadata/views/face-recognition/face-group.js create mode 100644 frontend/src/metadata/views/face-recognition/peoples/index.css create mode 100644 frontend/src/metadata/views/face-recognition/peoples/index.js create mode 100644 frontend/src/metadata/views/face-recognition/peoples/people/index.css create mode 100644 frontend/src/metadata/views/face-recognition/peoples/people/index.js create mode 100644 frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js create mode 100644 frontend/src/metadata/views/face-recognition/person-photos/index.css create mode 100644 frontend/src/metadata/views/face-recognition/person-photos/index.js create mode 100644 frontend/src/metadata/views/gallery/content.js create mode 100644 media/favicons/face-recognition-view.png 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 @@ + + + + +photos-classfied-by-people + + + + + 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 ; - })} - - - - {group.photos.map((photo, index) => { - return ( - showPhoto(event, photo)}> - - - - - - - ); - })} - -
{item.text}
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)} + > +
+ {name} +
+
+
+ {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 0000000000000000000000000000000000000000..53192e902a0e75069cb66ce5b219c72152d04acf GIT binary patch literal 1120 zcmV-m1fTnfP)%$ z;lM4hWOCs=oe|e(E1QHBF#?NC6?!6PAOu=^7KxO zU~j`s#~^e&V@x~WoX*>adr{U zT|Ulc6?=Ua#;-+!NcW8oOQ+PMr z<749YEoozdXN9*{%ymsGUm1WYWEkoudFIu93{H-jV)C9e73^%7vdSW4Z(kD;Ecdbb z2Brf4J4D|vs=0zKqWclgMViNFrsci$!d^horV7doaIg@YVpgkkB#jR1dq2pZopWCs z9V5SOS`8{QP}k!Dw_;iH2Hfgs_Ugn0hhay&8+oy5NLsc6QR0~xni@yf_hB^mf5M|) z4+h1v3ECj)#FSJ)89+^UJI-hCmiu?7*pl@kpp)+HD0k;0R8i?^;nCO_y$WS1*_aTS zx$bGgHO~`Jmpy#?|&lJRA5pPnHDRX~+R`ui%Jx3@f!4LF8E8|G3KbY@jSc+T~$_^PD5~ zUqM~ka5|XS20*9Wf}H1ki@ybR7CH;iregpUU=U2<<+i#-o%L97l%= 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'), ]