1
0
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:
杨国璇 2024-11-05 17:37:24 +08:00 committed by GitHub
parent 8f04a770f7
commit 2cb758e302
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1593 additions and 927 deletions

View 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

View File

@ -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} />

View File

@ -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';

View File

@ -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}

View File

@ -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;
}

View File

@ -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();

View File

@ -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]);

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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',
};

View File

@ -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 = {

View File

@ -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;

View File

@ -127,6 +127,7 @@ export const MetadataViewProvider = ({
deleteFilesCallback: params.deleteFilesCallback,
renameFileCallback: params.renameFileCallback,
updateCurrentDirent: params.updateCurrentDirent,
closeDirentDetail: params.closeDirentDetail,
}}
>
{children}

View File

@ -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 => {

View File

@ -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');

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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 = [

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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%;
}

View 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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View 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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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, )

View File

@ -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

View File

@ -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(', ') + ');'

View File

@ -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'),
]