mirror of
https://github.com/haiwen/seahub.git
synced 2025-07-12 14:38:58 +00:00
refactor: face ui (#6981)
* 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: 杨国璇 <ygx@Hello-word.local> Co-authored-by: 杨国璇 <ygx@192.168.1.3> Co-authored-by: ‘JoinTyang’ <yangtong1009@163.com>
This commit is contained in:
parent
8f04a770f7
commit
2cb758e302
15
frontend/src/assets/icons/face-recognition-view.svg
Normal file
15
frontend/src/assets/icons/face-recognition-view.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#999999;}
|
||||
</style>
|
||||
<title>photos-classfied-by-people</title>
|
||||
<g id="photos-classfied-by-people">
|
||||
<path id="形状" class="st0" d="M13.7,17.5c1.4,0.8,3.1,0.8,4.5,0s2.3-2.3,2.3-3.9c0-2.5-2-4.5-4.5-4.5s-4.5,2-4.5,4.5
|
||||
C11.5,15.2,12.3,16.7,13.7,17.5z M26,25.8c0,1.6-4.5,3.9-9.3,4C11.5,29.8,6,27.5,6,25.8c0-3.3,4.5-5.3,10-5.3S26,22.5,26,25.8z"/>
|
||||
<path id="形状结合" class="st0" d="M16,0c8.8,0,16,7.2,16,16s-7.2,16-16,16S0,24.8,0,16S7.2,0,16,0z M16,3C8.8,3,3,8.8,3,16
|
||||
s5.8,13,13,13s13-5.8,13-13S23.2,3,16,3z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 907 B |
@ -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 (
|
||||
<div className="dir-tool">
|
||||
<MetadataViewToolBar viewId={viewId} isCustomPermission={isCustomPermission} showDetail={this.showDirentDetail} />
|
||||
|
@ -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';
|
||||
|
@ -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 && (
|
||||
<SeafileMetadata
|
||||
mediaUrl={mediaUrl}
|
||||
repoID={this.props.repoID}
|
||||
@ -204,11 +204,9 @@ class DirColumnView extends React.Component {
|
||||
deleteFilesCallback={this.props.deleteFilesCallback}
|
||||
renameFileCallback={this.props.renameFileCallback}
|
||||
updateCurrentDirent={this.props.updateCurrentDirent}
|
||||
closeDirentDetail={this.props.closeDirentDetail}
|
||||
/>
|
||||
}
|
||||
{currentMode === FACE_RECOGNITION_MODE &&
|
||||
<FaceRecognition repoID={this.props.repoID}/>
|
||||
}
|
||||
)}
|
||||
{currentMode === LIST_MODE &&
|
||||
<DirListView
|
||||
path={this.props.path}
|
||||
|
@ -4,7 +4,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 2.5rem;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-bottom: 1px solid #eee;
|
||||
height: 48px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
@ -272,15 +272,20 @@ class MetadataManagerAPI {
|
||||
return this.req.get(url);
|
||||
};
|
||||
|
||||
updateFaceName = (repoID, recordID, name) => {
|
||||
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();
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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 (
|
||||
<>
|
||||
<div className="sf-metadata-tool-left-operations">
|
||||
<GalleryGroupBySetter view={view} />
|
||||
<GallerySliderSetter view={view} />
|
||||
{!isCustomPermission && (
|
||||
<div className="cur-view-path-btn ml-2" onClick={showDetail}>
|
||||
<span className="sf3-font sf3-font-info" aria-label={gettext('Properties')} title={gettext('Properties')}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sf-metadata-tool-right-operations"></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FaceRecognitionViewToolbar.propTypes = {
|
||||
isCustomPermission: PropTypes.bool,
|
||||
view: PropTypes.object.isRequired,
|
||||
showDetail: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FaceRecognitionViewToolbar;
|
@ -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 && (
|
||||
<FaceRecognitionViewToolbar
|
||||
isCustomPermission={isCustomPermission}
|
||||
view={view}
|
||||
showDetail={showDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
|
@ -127,6 +127,7 @@ export const MetadataViewProvider = ({
|
||||
deleteFilesCallback: params.deleteFilesCallback,
|
||||
renameFileCallback: params.renameFileCallback,
|
||||
updateCurrentDirent: params.updateCurrentDirent,
|
||||
closeDirentDetail: params.closeDirentDetail,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -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 => {
|
||||
|
@ -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');
|
||||
|
@ -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 <Table />;
|
||||
}
|
||||
case VIEW_TYPE.FACE_RECOGNITION: {
|
||||
return (<FaceRecognition />);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 (
|
||||
<div key={group.record_id} className="sf-metadata-face-recognition-item">
|
||||
{isRenaming ?
|
||||
(<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={changeName}
|
||||
onBlur={updateName}
|
||||
onKeyDown={onRenameKeyDown}
|
||||
/>)
|
||||
:
|
||||
(<div className="sf-metadata-face-recognition-name form-control" onClick={renameName}>{name}</div>)
|
||||
}
|
||||
<table className="table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{theadData.map((item, index) => {
|
||||
return <th key={index} width={item.width}>{item.text}</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.photos.map((photo, index) => {
|
||||
return (
|
||||
<tr key={index} onClick={(event) => showPhoto(event, photo)}>
|
||||
<td className="text-center"><img src={photo.src} alt="" className="thumbnail cursor-pointer" /></td>
|
||||
<td><a href={`${siteRoot}lib/${repoID}/file${photo.path}`} onClick={(event) => showPhoto(event, photo)}>{photo.file_name}</a></td>
|
||||
<td>{photo.parent_dir}</td>
|
||||
<td>{Utils.bytesToSize(photo.size)}</td>
|
||||
<td title={dayjs(photo.mtime).fromNow()}>{dayjs(photo.mtime).fromNow()}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FaceGroup.propTypes = {
|
||||
repoID: PropTypes.string,
|
||||
group: PropTypes.object.isRequired,
|
||||
onPhotoClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FaceGroup;
|
@ -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;
|
||||
}
|
||||
|
@ -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 (<CenteredLoading />);
|
||||
}
|
||||
const onRename = useCallback((id, newName, oldName) => {
|
||||
store.renamePeopleName(id, newName, oldName);
|
||||
}, [store]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sf-metadata-wrapper">
|
||||
<div className="sf-metadata-main">
|
||||
<div className="sf-metadata-container">
|
||||
<div className="sf-metadata-face-recognition" ref={containerRef} onScroll={handleScroll}>
|
||||
{faceData.length > 0 && faceData.map((face) => {
|
||||
return (<FaceGroup key={face.record_id} group={face} repoID={repoID} onPhotoClick={onPhotoClick} />);
|
||||
})}
|
||||
{isLoadingMore && (
|
||||
<div className="sf-metadata-face-recognition-loading-more">
|
||||
<CenteredLoading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isImagePopupOpen && (
|
||||
<ModalPortal>
|
||||
<ImageDialog
|
||||
imageItems={imageItems}
|
||||
imageIndex={imageIndex}
|
||||
closeImagePopup={closeImagePopup}
|
||||
moveToPrevImage={moveToPrevImage}
|
||||
moveToNextImage={moveToNextImage}
|
||||
/>
|
||||
</ModalPortal>
|
||||
<div className="sf-metadata-container">
|
||||
{showPeopleFaces ? (
|
||||
<PeoplePhotos people={peopleRef.current} onClose={closePeople} onDeletePeoplePhotos={onDeletePeoplePhotos} />
|
||||
) : (
|
||||
<Peoples peoples={peoples} onRename={onRename} onOpenPeople={openPeople} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FaceRecognition.propTypes = {
|
||||
repoID: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default FaceRecognition;
|
||||
|
@ -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%;
|
||||
}
|
103
frontend/src/metadata/views/face-recognition/peoples/index.js
Normal file
103
frontend/src/metadata/views/face-recognition/peoples/index.js
Normal file
@ -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 (<EmptyTip text={gettext('No faces')} />);
|
||||
|
||||
return (
|
||||
<div className="sf-metadata-face-recognition-container sf-metadata-peoples-container" ref={containerRef} onScroll={handleScroll}>
|
||||
{peoples.length > 0 && peoples.map((people) => {
|
||||
return (
|
||||
<People
|
||||
key={people._id}
|
||||
haveFreezed={haveFreezed}
|
||||
people={people}
|
||||
onOpenPeople={onOpenPeople}
|
||||
onRename={onRename}
|
||||
onFreezed={onFreezed}
|
||||
onUnFreezed={onUnFreezed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoadingMore && (
|
||||
<div className="sf-metadata-face-recognition-loading-more">
|
||||
<CenteredLoading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Peoples.propTypes = {
|
||||
peoples: PropTypes.array,
|
||||
onOpenPeople: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Peoples;
|
@ -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;
|
||||
}
|
@ -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 (
|
||||
<div
|
||||
className={classNames('sf-metadata-people-info px-3 d-flex justify-content-between align-items-center', {
|
||||
'readonly': readonly,
|
||||
})}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDoubleClick={haveFreezed ? () => {} : () => onOpenPeople(people)}
|
||||
>
|
||||
<div className="sf-metadata-people-info-img mr-2">
|
||||
<img src={similarPhotoURL} alt={name} height={36} width={36} />
|
||||
</div>
|
||||
<div className="sf-metadata-people-info-name-count">
|
||||
<div className="sf-metadata-people-info-name">
|
||||
{renaming ? (
|
||||
<Input className="sf-metadata-people-info-renaming" autoFocus value={name} onChange={onChange} onBlur={onBlur} onKeyDown={onKeyDown} />
|
||||
) : (
|
||||
<div className="sf-metadata-people-info-name-display">{name}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sf-metadata-people-info-count">
|
||||
{photosCount + ' ' + gettext('items')}
|
||||
</div>
|
||||
</div>
|
||||
{!readonly && people._is_someone && (
|
||||
<div className="sf-metadata-people-info-op">
|
||||
{active && (
|
||||
<OpMenu onRename={setRenamingState} onFreezed={onFreezed} onUnFreezed={_onUnFreezed} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
People.propTypes = {
|
||||
haveFreezed: PropTypes.bool,
|
||||
people: PropTypes.object.isRequired,
|
||||
onOpenPeople: PropTypes.func,
|
||||
onFreezed: PropTypes.func,
|
||||
onUnFreezed: PropTypes.func,
|
||||
};
|
||||
|
||||
export default People;
|
@ -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 (
|
||||
<Dropdown isOpen={isShow} toggle={toggle}>
|
||||
<DropdownToggle
|
||||
tag="i"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
className="sf-dropdown-toggle sf3-font-more sf3-font"
|
||||
title={gettext('More operations')}
|
||||
aria-label={gettext('More operations')}
|
||||
onClick={onClick}
|
||||
data-toggle="dropdown"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownItem data-toggle="rename" onClick={onItemClick}>{gettext('Rename')}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
OpMenu.propTypes = {
|
||||
onRename: PropTypes.func,
|
||||
onFreezed: PropTypes.func,
|
||||
onUnFreezed: PropTypes.func,
|
||||
};
|
||||
|
||||
export default OpMenu;
|
||||
|
@ -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);
|
||||
}
|
@ -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 (<CenteredLoading />);
|
||||
|
||||
return (
|
||||
<div className="sf-metadata-face-recognition-container sf-metadata-people-photos-container">
|
||||
<div className="sf-metadata-people-photos-header">
|
||||
<div className="sf-metadata-people-photos-header-back" onClick={onClose}>
|
||||
<i className="sf3-font sf3-font-arrow rotate-180"></i>
|
||||
</div>
|
||||
<div className="sf-metadata-people-name">{people._name || gettext('Person image')}</div>
|
||||
</div>
|
||||
<Gallery metadata={metadata} isLoadingMore={isLoadingMore} onLoadMore={onLoadMore} onDelete={handelDelete} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PeoplePhotos.propTypes = {
|
||||
people: PropTypes.object,
|
||||
onClose: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
};
|
||||
|
||||
export default PeoplePhotos;
|
197
frontend/src/metadata/views/gallery/content.js
Normal file
197
frontend/src/metadata/views/gallery/content.js
Normal file
@ -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 (<div key={name} className="w-100" style={{ height, flexShrink: 0 }}></div>);
|
||||
}
|
||||
|
||||
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 (<div key={name} className="w-100" style={{ height, flexShrink: 0 }}></div>);
|
||||
}
|
||||
|
||||
childrenStartIndex = Math.max(childrenStartIndex, 0);
|
||||
if (childrenEndIndex === -1) {
|
||||
childrenEndIndex = children.length;
|
||||
}
|
||||
if (childrenEndIndex > 0) {
|
||||
childrenEndIndex = childrenEndIndex - 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="metadata-gallery-date-group"
|
||||
style={{ height, paddingTop }}
|
||||
>
|
||||
{mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && (
|
||||
<div className="metadata-gallery-date-tag">{name || gettext('Empty')}</div>
|
||||
)}
|
||||
<div
|
||||
ref={imageRef}
|
||||
className="metadata-gallery-image-list"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
paddingTop: childrenStartIndex * imageHeight,
|
||||
paddingBottom: (children.length - 1 - childrenEndIndex) * imageHeight,
|
||||
}}
|
||||
>
|
||||
{children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
|
||||
return row.children.map((img) => {
|
||||
const isSelected = selectedImageIds.includes(img.id);
|
||||
return (
|
||||
<Image
|
||||
key={img.src}
|
||||
isSelected={isSelected}
|
||||
img={img}
|
||||
size={size}
|
||||
onClick={(e) => onImageClick(e, img)}
|
||||
onDoubleClick={(e) => onImageDoubleClick(e, img)}
|
||||
onContextMenu={(e) => onImageRightClick(e, img)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]);
|
||||
|
||||
if (!Array.isArray(groups) || groups.length === 0) {
|
||||
return <EmptyTip text={gettext('No record')}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='metadata-gallery-main'
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
return renderDisplayGroup(group);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
@ -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 (
|
||||
<div className="sf-metadata-container" onMouseDown={handleClickOutside}>
|
||||
<div className={`sf-metadata-gallery-container sf-metadata-gallery-container-${mode}`} ref={containerRef} onScroll={handleScroll} >
|
||||
{!isFirstLoading && (
|
||||
<>
|
||||
<Main
|
||||
groups={groups}
|
||||
size={imageSize}
|
||||
columns={columns}
|
||||
overScan={overScan}
|
||||
gap={GALLERY_IMAGE_GAP}
|
||||
mode={mode}
|
||||
selectedImages={selectedImages}
|
||||
onImageSelect={handleImageSelection}
|
||||
onImageClick={handleClick}
|
||||
onImageDoubleClick={handleDoubleClick}
|
||||
onImageRightClick={handleRightClick}
|
||||
/>
|
||||
{isLoadingMore &&
|
||||
<div className="sf-metadata-gallery-loading-more">
|
||||
<CenteredLoading />
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu
|
||||
getContentRect={() => containerRef.current.getBoundingClientRect()}
|
||||
getContainerRect={() => containerRef.current.getBoundingClientRect()}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{isImagePopupOpen && (
|
||||
<ModalPortal>
|
||||
<ImageDialog
|
||||
imageItems={imageItems}
|
||||
imageIndex={imageIndex}
|
||||
closeImagePopup={closeImagePopup}
|
||||
moveToPrevImage={moveToPrevImage}
|
||||
moveToNextImage={moveToNextImage}
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{isZipDialogOpen &&
|
||||
<ModalPortal>
|
||||
<ZipDownloadDialog
|
||||
repoID={repoID}
|
||||
path={'/'}
|
||||
target={selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`)}
|
||||
toggleDialog={closeZipDialog}
|
||||
/>
|
||||
</ModalPortal>
|
||||
}
|
||||
<div className="sf-metadata-container">
|
||||
<Main isLoadingMore={isLoadingMore} metadata={metadata} onDelete={handleDelete} onLoadMore={onLoadMore} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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 (<div key={name} className="w-100" style={{ height, flexShrink: 0 }}></div>);
|
||||
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 (<div key={name} className="w-100" style={{ height, flexShrink: 0 }}></div>);
|
||||
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 (
|
||||
<div
|
||||
key={name}
|
||||
className="metadata-gallery-date-group"
|
||||
style={{ height, paddingTop }}
|
||||
>
|
||||
{mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && (
|
||||
<div className="metadata-gallery-date-tag">{name || gettext('Empty')}</div>
|
||||
)}
|
||||
<div
|
||||
ref={imageRef}
|
||||
className="metadata-gallery-image-list"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
paddingTop: childrenStartIndex * imageHeight,
|
||||
paddingBottom: (children.length - 1 - childrenEndIndex) * imageHeight,
|
||||
}}
|
||||
>
|
||||
{children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => {
|
||||
return row.children.map((img) => {
|
||||
const isSelected = selectedImageIds.includes(img.id);
|
||||
return (
|
||||
<Image
|
||||
key={img.src}
|
||||
isSelected={isSelected}
|
||||
img={img}
|
||||
size={size}
|
||||
onClick={(e) => onImageClick(e, img)}
|
||||
onDoubleClick={(e) => onImageDoubleClick(e, img)}
|
||||
onContextMenu={(e) => onImageRightClick(e, img)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [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 <EmptyTip text={gettext('No record')}/>;
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='metadata-gallery-main'
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
return renderDisplayGroup(group);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={`sf-metadata-gallery-container sf-metadata-gallery-container-${mode}`}
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onMouseDown={handleClickOutside}
|
||||
>
|
||||
{!isFirstLoading && (
|
||||
<>
|
||||
<Content
|
||||
groups={groups}
|
||||
size={imageSize}
|
||||
columns={columns}
|
||||
overScan={overScan}
|
||||
gap={GALLERY_IMAGE_GAP}
|
||||
mode={mode}
|
||||
selectedImages={selectedImages}
|
||||
onImageSelect={handleImageSelection}
|
||||
onImageClick={handleClick}
|
||||
onImageDoubleClick={handleDoubleClick}
|
||||
onImageRightClick={handleRightClick}
|
||||
/>
|
||||
{isLoadingMore &&
|
||||
<div className="sf-metadata-gallery-loading-more">
|
||||
<CenteredLoading />
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu
|
||||
getContentRect={() => containerRef.current.getBoundingClientRect()}
|
||||
getContainerRect={() => containerRef.current.getBoundingClientRect()}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{isImagePopupOpen && (
|
||||
<ModalPortal>
|
||||
<ImageDialog
|
||||
imageItems={imageItems}
|
||||
imageIndex={imageIndex}
|
||||
closeImagePopup={closeImagePopup}
|
||||
moveToPrevImage={moveToPrevImage}
|
||||
moveToNextImage={moveToNextImage}
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{isZipDialogOpen && (
|
||||
<ModalPortal>
|
||||
<ZipDownloadDialog
|
||||
repoID={repoID}
|
||||
path={'/'}
|
||||
target={selectedImages.map(image => image.path === '/' ? image.name : `${image.path}/${image.name}`)}
|
||||
toggleDialog={closeZipDialog}
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
@ -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}
|
||||
/>
|
||||
:
|
||||
<div className="message err-tip">{gettext('Folder does not exist.')}</div>
|
||||
|
BIN
media/favicons/face-recognition-view.png
Normal file
BIN
media/favicons/face-recognition-view.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
@ -13,7 +13,8 @@ from seahub.api2.authentication import TokenAuthentication
|
||||
from seahub.repo_metadata.models import RepoMetadata, RepoMetadataViews
|
||||
from seahub.views import check_folder_permission
|
||||
from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id, init_metadata, \
|
||||
get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, get_metadata_by_faces, extract_file_details
|
||||
get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, \
|
||||
extract_file_details, get_someone_similar_faces
|
||||
from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_view_records
|
||||
from seahub.utils.timeutils import datetime_to_isoformat_timestr
|
||||
from seahub.utils.repo import is_repo_admin
|
||||
@ -831,7 +832,7 @@ class FacesRecords(APIView):
|
||||
|
||||
def get(self, request, repo_id):
|
||||
start = request.GET.get('start', 0)
|
||||
limit = request.GET.get('limit', 100)
|
||||
limit = request.GET.get('limit', 1000)
|
||||
|
||||
try:
|
||||
start = int(start)
|
||||
@ -853,19 +854,18 @@ class FacesRecords(APIView):
|
||||
error_msg = f'The metadata module is disabled for repo {repo_id}.'
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
|
||||
permission = check_folder_permission(request, repo_id, '/')
|
||||
if not permission:
|
||||
error_msg = 'Permission denied.'
|
||||
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
||||
|
||||
repo = seafile_api.get_repo(repo_id)
|
||||
if not repo:
|
||||
error_msg = 'Library %s not found.' % repo_id
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
|
||||
if not can_read_metadata(request, repo_id):
|
||||
error_msg = 'Permission denied.'
|
||||
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
||||
|
||||
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
||||
|
||||
from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
|
||||
from seafevents.repo_metadata.utils import FACES_TABLE
|
||||
|
||||
try:
|
||||
metadata = metadata_server_api.get_metadata()
|
||||
@ -880,7 +880,7 @@ class FacesRecords(APIView):
|
||||
if not faces_table_id:
|
||||
return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used')
|
||||
|
||||
sql = f'SELECT * FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.photo_links.name}` IS NOT NULL LIMIT {start}, {limit}'
|
||||
sql = f'SELECT `{FACES_TABLE.columns.id.name}`, `{FACES_TABLE.columns.name.name}`, `{FACES_TABLE.columns.photo_links.name}`, `{FACES_TABLE.columns.vector.name}` FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.photo_links.name}` IS NOT NULL LIMIT {start}, {limit}'
|
||||
|
||||
try:
|
||||
query_result = metadata_server_api.query_rows(sql)
|
||||
@ -889,49 +889,60 @@ class FacesRecords(APIView):
|
||||
error_msg = 'Internal Server Error'
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||
|
||||
faces = query_result.get('results')
|
||||
faces_records = query_result.get('results')
|
||||
metadata_columns = query_result.get('metadata', [])
|
||||
metadata_columns.append({
|
||||
'key': '_similar_photo',
|
||||
'type': 'text',
|
||||
'name': '_similar_photo',
|
||||
'data': None,
|
||||
})
|
||||
metadata_columns.append({
|
||||
'key': '_is_someone',
|
||||
'type': 'checkbox',
|
||||
'name': '_is_someone',
|
||||
'data': None,
|
||||
})
|
||||
|
||||
if not faces:
|
||||
error_msg = 'Records not found'
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
if not faces_records:
|
||||
return Response({
|
||||
'metadata': metadata_columns,
|
||||
'results': [],
|
||||
})
|
||||
|
||||
try:
|
||||
query_result = get_metadata_by_faces(faces, metadata_server_api)
|
||||
similar_result = get_someone_similar_faces(faces_records, metadata_server_api)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
error_msg = 'Internal Server Error'
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||
if not query_result:
|
||||
error_msg = 'Records not found'
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
|
||||
classify_result = dict()
|
||||
for row in query_result:
|
||||
link_row_ids = [item['row_id'] for item in row.get(METADATA_TABLE.columns.face_links.name, [])]
|
||||
if not query_result:
|
||||
return Response({
|
||||
'metadata': metadata_columns,
|
||||
'results': [],
|
||||
})
|
||||
|
||||
similar_result_dict = dict()
|
||||
for row in similar_result:
|
||||
similar_result_dict[row['_id']] = row
|
||||
|
||||
for record in faces_records:
|
||||
vector = record.get(FACES_TABLE.columns.vector.name, None)
|
||||
record['_is_someone'] = bool(vector)
|
||||
if FACES_TABLE.columns.vector.name in record:
|
||||
del record[FACES_TABLE.columns.vector.name]
|
||||
link_row_ids = [item['row_id'] for item in record.get(FACES_TABLE.columns.photo_links.name, [])]
|
||||
if not link_row_ids:
|
||||
continue
|
||||
for link_row_id in link_row_ids:
|
||||
if link_row_id not in classify_result:
|
||||
classify_result[link_row_id] = []
|
||||
file_name = row.get(METADATA_TABLE.columns.file_name.name, '')
|
||||
parent_dir = row.get(METADATA_TABLE.columns.parent_dir.name, '')
|
||||
size = row.get(METADATA_TABLE.columns.size.name, 0)
|
||||
mtime = row.get('_mtime')
|
||||
classify_result[link_row_id].append({
|
||||
'path': os.path.join(parent_dir, file_name),
|
||||
'file_name': file_name,
|
||||
'parent_dir': parent_dir,
|
||||
'size': size,
|
||||
'mtime': mtime
|
||||
})
|
||||
link_row_id = link_row_ids[0]
|
||||
if link_row_id in similar_result_dict:
|
||||
record['_similar_photo'] = similar_result_dict[link_row_id]
|
||||
|
||||
id_to_name = {item.get(FACES_TABLE.columns.id.name): item.get(FACES_TABLE.columns.name.name, '') for item in faces}
|
||||
classify_result = [{
|
||||
'record_id': key,
|
||||
'name': id_to_name.get(key, ''),
|
||||
'link_photos': value
|
||||
} for key, value in classify_result.items()]
|
||||
return Response({'results': classify_result})
|
||||
return Response({
|
||||
'metadata': metadata_columns,
|
||||
'results': faces_records,
|
||||
})
|
||||
|
||||
|
||||
class FacesRecord(APIView):
|
||||
@ -999,6 +1010,91 @@ class FacesRecord(APIView):
|
||||
return Response({'success': True})
|
||||
|
||||
|
||||
class PeoplePhotos(APIView):
|
||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
throttle_classes = (UserRateThrottle,)
|
||||
|
||||
def get(self, request, repo_id, people_id):
|
||||
start = request.GET.get('start', 0)
|
||||
limit = request.GET.get('limit', 1000)
|
||||
|
||||
try:
|
||||
start = int(start)
|
||||
limit = int(limit)
|
||||
except:
|
||||
start = 0
|
||||
limit = 1000
|
||||
|
||||
if start < 0:
|
||||
error_msg = 'start invalid'
|
||||
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
|
||||
|
||||
if limit < 0:
|
||||
error_msg = 'limit invalid'
|
||||
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
|
||||
|
||||
metadata = RepoMetadata.objects.filter(repo_id=repo_id).first()
|
||||
if not metadata or not metadata.enabled:
|
||||
error_msg = f'The metadata module is disabled for repo {repo_id}.'
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
|
||||
repo = seafile_api.get_repo(repo_id)
|
||||
if not repo:
|
||||
error_msg = 'Library %s not found.' % repo_id
|
||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||
|
||||
if not can_read_metadata(request, repo_id):
|
||||
error_msg = 'Permission denied.'
|
||||
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
||||
|
||||
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
||||
from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
|
||||
|
||||
try:
|
||||
metadata = metadata_server_api.get_metadata()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
error_msg = 'Internal Server Error'
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||
|
||||
tables = metadata.get('tables', [])
|
||||
faces_table_id = [table['id'] for table in tables if table['name'] == FACES_TABLE.name]
|
||||
faces_table_id = faces_table_id[0] if faces_table_id else None
|
||||
if not faces_table_id:
|
||||
return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used')
|
||||
|
||||
sql = f'SELECT `{FACES_TABLE.columns.photo_links.name}` FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.id.name}` = "{people_id}" LIMIT {start}, {limit}'
|
||||
|
||||
try:
|
||||
query_result = metadata_server_api.query_rows(sql)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
error_msg = 'Internal Server Error'
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||
|
||||
faces_records = query_result.get('results')
|
||||
|
||||
if not faces_records:
|
||||
return Response({'metadata': [], 'results': []})
|
||||
|
||||
faces_record = faces_records[0]
|
||||
|
||||
try:
|
||||
record_ids = [item['row_id'] for item in faces_record.get(FACES_TABLE.columns.photo_links.name, [])]
|
||||
selected_ids = record_ids[start:limit]
|
||||
selected_ids_str = ', '.join(["'%s'" % id for id in selected_ids])
|
||||
|
||||
sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}`, `{METADATA_TABLE.columns.file_ctime.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ({selected_ids_str})'
|
||||
someone_photos_result = metadata_server_api.query_rows(sql)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
error_msg = 'Internal Server Error'
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||
|
||||
return Response(someone_photos_result)
|
||||
|
||||
|
||||
class FaceRecognitionManage(APIView):
|
||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||
permission_classes = (IsAuthenticated, )
|
||||
|
@ -47,6 +47,18 @@ def list_metadata_view_records(repo_id, user, view, start=0, limit=1000):
|
||||
metadata_server_api = MetadataServerAPI(repo_id, user)
|
||||
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
|
||||
sql = gen_view_data_sql(METADATA_TABLE, columns, view, start, limit, user)
|
||||
|
||||
# Remove face-vectors from the query SQL because they are too large
|
||||
query_fields_str = ''
|
||||
for column in columns:
|
||||
column_name = column.get('name')
|
||||
if column_name == METADATA_TABLE.columns.face_vectors.name:
|
||||
continue
|
||||
column_name_str = '`%s`, ' % column_name
|
||||
query_fields_str += column_name_str
|
||||
query_fields_str = query_fields_str.strip(', ')
|
||||
sql = sql.replace('*', query_fields_str)
|
||||
|
||||
response_results = metadata_server_api.query_rows(sql, [])
|
||||
return response_results
|
||||
|
||||
|
@ -29,24 +29,24 @@ def add_init_face_recognition_task(params):
|
||||
return json.loads(resp.content)['task_id']
|
||||
|
||||
|
||||
def get_metadata_by_faces(faces, metadata_server_api):
|
||||
def get_someone_similar_faces(faces, metadata_server_api):
|
||||
from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
|
||||
sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
|
||||
sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
|
||||
parameters = []
|
||||
query_result = []
|
||||
for face in faces:
|
||||
link_row_ids = [item['row_id'] for item in face.get(FACES_TABLE.columns.photo_links.name, [])]
|
||||
if not link_row_ids:
|
||||
continue
|
||||
for link_row_id in link_row_ids:
|
||||
sql += '?, '
|
||||
parameters.append(link_row_id)
|
||||
if len(parameters) >= 10000:
|
||||
sql = sql.rstrip(', ') + ');'
|
||||
results = metadata_server_api.query_rows(sql, parameters).get('results', [])
|
||||
query_result.extend(results)
|
||||
sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
|
||||
parameters = []
|
||||
link_row_id = link_row_ids[0]
|
||||
sql += '?, '
|
||||
parameters.append(link_row_id)
|
||||
if len(parameters) >= 10000:
|
||||
sql = sql.rstrip(', ') + ');'
|
||||
results = metadata_server_api.query_rows(sql, parameters).get('results', [])
|
||||
query_result.extend(results)
|
||||
sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
|
||||
parameters = []
|
||||
|
||||
if parameters:
|
||||
sql = sql.rstrip(', ') + ');'
|
||||
|
@ -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<repo_id>[-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<repo_id>[-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<repo_id>[-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<repo_id>[-0-9a-f]{36})/metadata/people-photos/(?P<people_id>.+)/$', PeoplePhotos.as_view(), name='api-v2.1-metadata-people-photos'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-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<repo_id>[-0-9a-f]{36})/metadata/extract-file-details/$', MetadataExtractFileDetails.as_view(), name='api-v2.1-metadata-extract-file-details'),
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user