mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-26 07:22:34 +00:00
show overlapped images in details, support modify details in batch (#8143)
* show overlapped images in details, support modify details in batch * optimize * fix details state * code-optimize * Update apis.py --------- Co-authored-by: zhouwenxuan <aries@Mac.local> Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
@@ -7,9 +7,11 @@ const Title = ({ icon, iconSize, title }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-title dirent-title">
|
<div className="detail-title dirent-title">
|
||||||
<div className="detail-header-icon-container">
|
{icon && (
|
||||||
<img src={icon} width={iconSize} height={iconSize} alt="" />
|
<div className="detail-header-icon-container">
|
||||||
</div>
|
<img src={icon} width={iconSize} height={iconSize} alt="" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className="name ellipsis" title={title}>{title}</span>
|
<span className="name ellipsis" title={title}>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import LibDetail from './lib-details';
|
import LibDetail from './lib-details';
|
||||||
import DirentDetail from './dirent-details';
|
import DirentDetail from './dirent-details';
|
||||||
|
import MultiSelectionDetails from './multi-selection-details';
|
||||||
import ViewDetails from '../../metadata/components/view-details';
|
import ViewDetails from '../../metadata/components/view-details';
|
||||||
import ObjectUtils from '../../utils/object';
|
import ObjectUtils from '../../utils/object';
|
||||||
import { MetadataContext } from '../../metadata';
|
import { MetadataContext } from '../../metadata';
|
||||||
@@ -11,7 +12,18 @@ import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants';
|
|||||||
import { useTags } from '../../tag/hooks';
|
import { useTags } from '../../tag/hooks';
|
||||||
import { useMetadataStatus } from '../../hooks';
|
import { useMetadataStatus } from '../../hooks';
|
||||||
|
|
||||||
const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo, repoTags, fileTags, onClose, onFileTagChanged }) => {
|
const Detail = React.memo(({
|
||||||
|
repoID,
|
||||||
|
path,
|
||||||
|
currentMode,
|
||||||
|
dirent,
|
||||||
|
selectedDirents,
|
||||||
|
currentRepoInfo,
|
||||||
|
repoTags,
|
||||||
|
fileTags,
|
||||||
|
onClose,
|
||||||
|
onFileTagChanged
|
||||||
|
}) => {
|
||||||
const { enableMetadata, enableFaceRecognition, detailsSettings, modifyDetailsSettings } = useMetadataStatus();
|
const { enableMetadata, enableFaceRecognition, detailsSettings, modifyDetailsSettings } = useMetadataStatus();
|
||||||
const { tagsData, addTag, modifyLocalFileTags } = useTags();
|
const { tagsData, addTag, modifyLocalFileTags } = useTags();
|
||||||
|
|
||||||
@@ -36,6 +48,20 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
|
|||||||
|
|
||||||
if (isTag) return null;
|
if (isTag) return null;
|
||||||
|
|
||||||
|
// Handle multi-selection case
|
||||||
|
if (selectedDirents && selectedDirents.length > 1) {
|
||||||
|
return (
|
||||||
|
<MultiSelectionDetails
|
||||||
|
repoID={repoID}
|
||||||
|
path={path}
|
||||||
|
selectedDirents={selectedDirents}
|
||||||
|
currentRepoInfo={currentRepoInfo}
|
||||||
|
modifyLocalFileTags={modifyLocalFileTags}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isView && !dirent) {
|
if (isView && !dirent) {
|
||||||
const pathParts = path.split('/');
|
const pathParts = path.split('/');
|
||||||
const [, , viewId, children] = pathParts;
|
const [, , viewId, children] = pathParts;
|
||||||
@@ -54,7 +80,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
|
|||||||
<DirentDetail
|
<DirentDetail
|
||||||
repoID={repoID}
|
repoID={repoID}
|
||||||
path={isView ? dirent.path : path}
|
path={isView ? dirent.path : path}
|
||||||
dirent={dirent}
|
dirent={selectedDirents[0] || dirent}
|
||||||
currentRepoInfo={currentRepoInfo}
|
currentRepoInfo={currentRepoInfo}
|
||||||
repoTags={repoTags}
|
repoTags={repoTags}
|
||||||
fileTags={fileTags}
|
fileTags={fileTags}
|
||||||
@@ -74,6 +100,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
|
|||||||
props.repoID !== nextProps.repoID ||
|
props.repoID !== nextProps.repoID ||
|
||||||
props.path !== nextProps.path ||
|
props.path !== nextProps.path ||
|
||||||
!ObjectUtils.isSameObject(props.dirent, nextProps.dirent) ||
|
!ObjectUtils.isSameObject(props.dirent, nextProps.dirent) ||
|
||||||
|
!ObjectUtils.isSameObject(props.selectedDirents, nextProps.selectedDirents) ||
|
||||||
!ObjectUtils.isSameObject(props.currentRepoInfo, nextProps.currentRepoInfo) ||
|
!ObjectUtils.isSameObject(props.currentRepoInfo, nextProps.currentRepoInfo) ||
|
||||||
JSON.stringify(props.repoTags || []) !== JSON.stringify(nextProps.repoTags || []) ||
|
JSON.stringify(props.repoTags || []) !== JSON.stringify(nextProps.repoTags || []) ||
|
||||||
JSON.stringify(props.fileTags || []) !== JSON.stringify(nextProps.fileTags || []);
|
JSON.stringify(props.fileTags || []) !== JSON.stringify(nextProps.fileTags || []);
|
||||||
@@ -85,6 +112,7 @@ Detail.propTypes = {
|
|||||||
path: PropTypes.string,
|
path: PropTypes.string,
|
||||||
currentMode: PropTypes.string,
|
currentMode: PropTypes.string,
|
||||||
dirent: PropTypes.object,
|
dirent: PropTypes.object,
|
||||||
|
selectedDirents: PropTypes.array,
|
||||||
currentRepoInfo: PropTypes.object,
|
currentRepoInfo: PropTypes.object,
|
||||||
repoTags: PropTypes.array,
|
repoTags: PropTypes.array,
|
||||||
fileTags: PropTypes.array,
|
fileTags: PropTypes.array,
|
||||||
|
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Popover } from 'reactstrap';
|
||||||
|
import Editor from '../../../metadata/components/cell-editors/tags-editor';
|
||||||
|
import DeleteTag from '../../../metadata/components/cell-editors/tags-editor/delete-tags';
|
||||||
|
import { getRowById } from '../../sf-table/utils/table';
|
||||||
|
import { gettext } from '../../../utils/constants';
|
||||||
|
import { KeyCodes } from '../../../constants';
|
||||||
|
import { getEventClassName } from '../../../utils/dom';
|
||||||
|
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../../../metadata/constants';
|
||||||
|
import { useTags } from '../../../tag/hooks';
|
||||||
|
import tagsAPI from '../../../tag/api';
|
||||||
|
import { Utils } from '../../../utils/utils';
|
||||||
|
import toaster from '../../toast';
|
||||||
|
import { getCellValueByColumn } from '../../../metadata/utils/cell';
|
||||||
|
import { getTagId, getTagName } from '../../../tag/utils/cell';
|
||||||
|
|
||||||
|
const DirentsTagsEditor = ({
|
||||||
|
records,
|
||||||
|
field,
|
||||||
|
onChange,
|
||||||
|
repoID,
|
||||||
|
modifyLocalFileTags
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
|
||||||
|
const { tagsData, context } = useTags();
|
||||||
|
const canEditData = useMemo(() => window.sfMetadataContext && window.sfMetadataContext.canModifyRow() || false, []);
|
||||||
|
|
||||||
|
const displayTags = useMemo(() => {
|
||||||
|
if (!records || records.length === 0) return [];
|
||||||
|
|
||||||
|
return getCellValueByColumn(records[0], field) || [];
|
||||||
|
}, [records, field]);
|
||||||
|
|
||||||
|
const displayTagIds = useMemo(() => {
|
||||||
|
if (!Array.isArray(displayTags) || displayTags.length === 0) return [];
|
||||||
|
|
||||||
|
return displayTags.filter(tag => getRowById(tagsData, tag.row_id)).map(tag => tag.row_id);
|
||||||
|
}, [displayTags, tagsData]);
|
||||||
|
|
||||||
|
const onClick = useCallback((event) => {
|
||||||
|
if (!event.target) return;
|
||||||
|
const className = getEventClassName(event);
|
||||||
|
if (className.indexOf('sf-metadata-search-tags') > -1) return;
|
||||||
|
const dom = document.querySelector('.sf-metadata-tags-editor');
|
||||||
|
if (!dom) return;
|
||||||
|
if (dom.contains(event.target)) return;
|
||||||
|
if (ref.current && !ref.current.contains(event.target) && showEditor) {
|
||||||
|
setShowEditor(false);
|
||||||
|
}
|
||||||
|
}, [showEditor]);
|
||||||
|
|
||||||
|
const onHotKey = useCallback((event) => {
|
||||||
|
if (event.keyCode === KeyCodes.Esc) {
|
||||||
|
if (showEditor) {
|
||||||
|
setShowEditor(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showEditor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
document.addEventListener('keydown', onHotKey, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onClick);
|
||||||
|
document.removeEventListener('keydown', onHotKey, true);
|
||||||
|
};
|
||||||
|
}, [onClick, onHotKey]);
|
||||||
|
|
||||||
|
const openEditor = useCallback(() => {
|
||||||
|
setShowEditor(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateBatchTags = useCallback((newTagIds, oldTagIds = []) => {
|
||||||
|
if (!records || records.length === 0) return;
|
||||||
|
|
||||||
|
const newTags = newTagIds.map(id => {
|
||||||
|
const tag = getRowById(tagsData, id);
|
||||||
|
return {
|
||||||
|
display_value: getTagName(tag),
|
||||||
|
row_id: getTagId(tag),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onChange && onChange(PRIVATE_COLUMN_KEY.TAGS, newTags);
|
||||||
|
|
||||||
|
const batchTagUpdates = records.map(record => ({
|
||||||
|
record_id: record._id,
|
||||||
|
tags: newTagIds
|
||||||
|
}));
|
||||||
|
|
||||||
|
tagsAPI.updateFileTags(repoID, batchTagUpdates)
|
||||||
|
.then(() => {
|
||||||
|
batchTagUpdates.forEach(({ record_id, tags }) => {
|
||||||
|
modifyLocalFileTags && modifyLocalFileTags(record_id, tags);
|
||||||
|
|
||||||
|
if (window?.sfMetadataContext?.eventBus) {
|
||||||
|
const newValue = tags ? tags.map(id => ({ row_id: id, display_value: id })) : [];
|
||||||
|
const update = { [PRIVATE_COLUMN_KEY.TAGS]: newValue };
|
||||||
|
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { recordId: record_id }, update);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = Utils.getErrorMsg(error);
|
||||||
|
toaster.danger(errorMsg);
|
||||||
|
});
|
||||||
|
}, [records, repoID, tagsData, onChange, modifyLocalFileTags]);
|
||||||
|
|
||||||
|
const onDeleteTag = useCallback((tagId, event) => {
|
||||||
|
event && event.stopPropagation();
|
||||||
|
event && event.nativeEvent && event.nativeEvent.stopImmediatePropagation();
|
||||||
|
const newValue = displayTagIds.slice(0);
|
||||||
|
let optionIdx = displayTagIds.indexOf(tagId);
|
||||||
|
if (optionIdx > -1) {
|
||||||
|
newValue.splice(optionIdx, 1);
|
||||||
|
}
|
||||||
|
updateBatchTags(newValue, displayTagIds);
|
||||||
|
setShowEditor(false);
|
||||||
|
}, [displayTagIds, updateBatchTags]);
|
||||||
|
|
||||||
|
const onSelectTag = useCallback((tagId) => {
|
||||||
|
const newValue = displayTagIds.slice(0);
|
||||||
|
if (!newValue.includes(tagId)) {
|
||||||
|
newValue.push(tagId);
|
||||||
|
}
|
||||||
|
updateBatchTags(newValue, displayTagIds);
|
||||||
|
}, [displayTagIds, updateBatchTags]);
|
||||||
|
|
||||||
|
const onDeselectTag = useCallback((tagId) => {
|
||||||
|
const newValue = displayTagIds.slice(0);
|
||||||
|
let optionIdx = displayTagIds.indexOf(tagId);
|
||||||
|
if (optionIdx > -1) {
|
||||||
|
newValue.splice(optionIdx, 1);
|
||||||
|
}
|
||||||
|
updateBatchTags(newValue, displayTagIds);
|
||||||
|
}, [displayTagIds, updateBatchTags]);
|
||||||
|
|
||||||
|
const renderEditor = useCallback(() => {
|
||||||
|
if (!showEditor) return null;
|
||||||
|
const { width, top, bottom } = ref.current.getBoundingClientRect();
|
||||||
|
const editorHeight = 400;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
let placement = 'bottom-end';
|
||||||
|
if (viewportHeight - bottom < editorHeight && top > editorHeight) {
|
||||||
|
placement = 'top-end';
|
||||||
|
} else if (viewportHeight - bottom < editorHeight && top < editorHeight) {
|
||||||
|
placement = 'left-start';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
target={ref}
|
||||||
|
isOpen={true}
|
||||||
|
placement={placement}
|
||||||
|
hideArrow={true}
|
||||||
|
fade={false}
|
||||||
|
className="sf-metadata-property-editor-popover sf-metadata-tags-property-editor-popover"
|
||||||
|
boundariesElement="viewport"
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
saveImmediately={true}
|
||||||
|
value={displayTags}
|
||||||
|
column={{ ...field, width: Math.max(width - 2, 400) }}
|
||||||
|
onSelect={onSelectTag}
|
||||||
|
onDeselect={onDeselectTag}
|
||||||
|
canEditData={canEditData}
|
||||||
|
canAddTag={context.canAddTag()}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}, [showEditor, field, displayTags, context, canEditData, onSelectTag, onDeselectTag]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sf-metadata-property-detail-editor sf-metadata-tags-property-detail-editor"
|
||||||
|
placeholder={gettext('Empty')}
|
||||||
|
ref={ref}
|
||||||
|
onClick={openEditor}
|
||||||
|
>
|
||||||
|
{displayTagIds.length > 0 && (<DeleteTag value={displayTagIds} tags={tagsData} onDelete={onDeleteTag} />)}
|
||||||
|
{renderEditor()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DirentsTagsEditor.propTypes = {
|
||||||
|
records: PropTypes.array.isRequired,
|
||||||
|
field: PropTypes.object.isRequired,
|
||||||
|
onBatchMetadataRefresh: PropTypes.func.isRequired,
|
||||||
|
repoID: PropTypes.string.isRequired,
|
||||||
|
modifyLocalFileTags: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DirentsTagsEditor;
|
@@ -0,0 +1,62 @@
|
|||||||
|
.multi-selection-thumbnails {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 144px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-thumbnail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--bs-border-secondary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-thumbnail {
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-selection-thumbnails.with-background {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-thumbnail {
|
||||||
|
position: absolute;
|
||||||
|
width: auto;
|
||||||
|
height: 144px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--bs-border-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useCallback, useMemo, useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Detail, Header, Body } from '../detail';
|
||||||
|
import { Utils } from '../../../utils/utils';
|
||||||
|
import { gettext, siteRoot, thumbnailSizeForGrid } from '../../../utils/constants';
|
||||||
|
import DetailItem from '../detail-item';
|
||||||
|
import { CellType, PRIVATE_COLUMN_KEY } from '../../../metadata/constants';
|
||||||
|
import { useMetadataStatus } from '../../../hooks';
|
||||||
|
import RateEditor from '../../../metadata/components/detail-editor/rate-editor';
|
||||||
|
import metadataAPI from '../../../metadata/api';
|
||||||
|
import Loading from '../../loading';
|
||||||
|
import { getColumnDisplayName } from '../../../metadata/utils/column';
|
||||||
|
import DirentsTagsEditor from './dirents-tags-editor';
|
||||||
|
import { getCellValueByColumn, getFileObjIdFromRecord, getRecordIdFromRecord } from '../../../metadata/utils/cell';
|
||||||
|
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const MultiSelectionDetails = ({
|
||||||
|
repoID,
|
||||||
|
path,
|
||||||
|
selectedDirents,
|
||||||
|
currentRepoInfo,
|
||||||
|
modifyLocalFileTags,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const { enableMetadata, enableTags } = useMetadataStatus();
|
||||||
|
const [records, setRecords] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const fileCount = useMemo(() => selectedDirents.filter(dirent => dirent.type === 'file').length, [selectedDirents]);
|
||||||
|
const folderCount = useMemo(() => selectedDirents.filter(dirent => dirent.type === 'dir').length, [selectedDirents]);
|
||||||
|
|
||||||
|
const onlyFiles = folderCount === 0;
|
||||||
|
const hasFiles = fileCount > 0;
|
||||||
|
|
||||||
|
const { imageDirents, fileDirents } = useMemo(() => ({
|
||||||
|
imageDirents: selectedDirents.filter(dirent =>
|
||||||
|
dirent.type === 'file' && Utils.imageCheck(dirent.name)
|
||||||
|
),
|
||||||
|
fileDirents: selectedDirents.filter(dirent => dirent.type === 'file')
|
||||||
|
}), [selectedDirents]);
|
||||||
|
|
||||||
|
const { rateField, tagField } = useMemo(() => {
|
||||||
|
const rateColumn = { key: PRIVATE_COLUMN_KEY.FILE_RATE, type: CellType.RATE, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.FILE_RATE) };
|
||||||
|
const tagColumn = { key: PRIVATE_COLUMN_KEY.TAGS, type: CellType.TAGS, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.TAGS) };
|
||||||
|
|
||||||
|
return {
|
||||||
|
rateField: {
|
||||||
|
...rateColumn,
|
||||||
|
name: getColumnDisplayName(rateColumn.key)
|
||||||
|
},
|
||||||
|
tagField: {
|
||||||
|
...tagColumn,
|
||||||
|
name: getColumnDisplayName(tagColumn.key),
|
||||||
|
type: CellType.TAGS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRecords = useCallback(() => {
|
||||||
|
if (!enableMetadata || !onlyFiles || !hasFiles) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const files = fileDirents.map(dirent => {
|
||||||
|
return {
|
||||||
|
parent_dir: path,
|
||||||
|
file_name: dirent.name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
metadataAPI.getRecords(repoID, files)
|
||||||
|
.then(res => {
|
||||||
|
const { results } = res.data;
|
||||||
|
const orderedRecords = fileDirents.map(dirent => {
|
||||||
|
return results.find(record => {
|
||||||
|
const recordPath = record[PRIVATE_COLUMN_KEY.PARENT_DIR] || '';
|
||||||
|
const recordName = record[PRIVATE_COLUMN_KEY.FILE_NAME] || '';
|
||||||
|
return recordPath === path && recordName === dirent.name;
|
||||||
|
});
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
setRecords(orderedRecords);
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch (err => {
|
||||||
|
setRecords([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [enableMetadata, onlyFiles, hasFiles, fileDirents, repoID, path]);
|
||||||
|
|
||||||
|
const onChange = useCallback((key, value) => {
|
||||||
|
const newRecords = records.map(record => ({
|
||||||
|
...record,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
setRecords(newRecords);
|
||||||
|
|
||||||
|
if (key === PRIVATE_COLUMN_KEY.TAGS) return;
|
||||||
|
|
||||||
|
const recordsData = records.map(record => ({
|
||||||
|
record_id: getRecordIdFromRecord(record),
|
||||||
|
record: { [key]: value },
|
||||||
|
obj_id: getFileObjIdFromRecord(record),
|
||||||
|
}));
|
||||||
|
metadataAPI.modifyRecords(repoID, recordsData);
|
||||||
|
}, [repoID, records]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRecords();
|
||||||
|
}, [getRecords]);
|
||||||
|
|
||||||
|
const rate = useMemo(() => {
|
||||||
|
if (!records || records.length === 0) return null;
|
||||||
|
|
||||||
|
return getCellValueByColumn(records[0], rateField);
|
||||||
|
}, [records, rateField]);
|
||||||
|
|
||||||
|
const getThumbnailSrc = (dirent) => {
|
||||||
|
if (dirent.type === 'dir' || (dirent.type === 'file' && !Utils.imageCheck(dirent.name))) {
|
||||||
|
return Utils.getDirentIcon(dirent, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirent.type !== 'file' || !Utils.imageCheck(dirent.name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentRepoInfo.encrypted
|
||||||
|
? `${siteRoot}repo/${repoID}/raw${Utils.encodePath(`${path === '/' ? '' : path}/${dirent.name}`)}`
|
||||||
|
: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${Utils.encodePath(`${path === '/' ? '' : path}/${dirent.name}`)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOverlappingThumbnails = () => {
|
||||||
|
const maxOverlayThumbnails = 6;
|
||||||
|
const angles = [-15, 8, -8, 12, -10, 6];
|
||||||
|
|
||||||
|
const renderOverlayItem = (dirent, index, isBackgroundMode = false) => {
|
||||||
|
const src = getThumbnailSrc(dirent);
|
||||||
|
const className = `overlay-thumbnail large${!isBackgroundMode ? ' no-background-item' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dirent.name}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
zIndex: 10 + index,
|
||||||
|
transform: `rotate(${angles[index] || 0}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={dirent.name}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
const iconContainer = e.target.parentNode.querySelector('.fallback-icon');
|
||||||
|
if (iconContainer) iconContainer.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageDirents.length === 0) {
|
||||||
|
const overlayItems = selectedDirents.slice(0, maxOverlayThumbnails);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="multi-selection-thumbnails no-background">
|
||||||
|
{overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, false))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With background image mode
|
||||||
|
const backgroundImage = imageDirents[0];
|
||||||
|
const overlayItems = selectedDirents
|
||||||
|
.filter(dirent => dirent.name !== backgroundImage.name)
|
||||||
|
.slice(-maxOverlayThumbnails);
|
||||||
|
const backgroundSrc = getThumbnailSrc(backgroundImage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="multi-selection-thumbnails with-background">
|
||||||
|
<div className="background-thumbnail">
|
||||||
|
<img
|
||||||
|
src={backgroundSrc}
|
||||||
|
alt={backgroundImage.name}
|
||||||
|
/>
|
||||||
|
<div className="background-overlay"></div>
|
||||||
|
</div>
|
||||||
|
{overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, true))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Detail className="multi-selection-details">
|
||||||
|
<Header
|
||||||
|
title={gettext('Details')}
|
||||||
|
icon=''
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<Body>
|
||||||
|
{renderOverlappingThumbnails()}
|
||||||
|
|
||||||
|
<div className="detail-content">
|
||||||
|
<p className="text-center">{gettext(`${selectedDirents.length} items have been selected`)}</p>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<Loading />
|
||||||
|
<p className="text-muted mt-2">{gettext('Loading metadata...')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && onlyFiles && hasFiles && window.app.pageOptions.enableFileTags && enableTags && (
|
||||||
|
<DetailItem field={tagField} readonly={false} className="sf-metadata-property-detail-editor sf-metadata-tags-property-detail-editor">
|
||||||
|
<DirentsTagsEditor
|
||||||
|
records={records}
|
||||||
|
field={tagField}
|
||||||
|
onChange={onChange}
|
||||||
|
repoID={repoID}
|
||||||
|
modifyLocalFileTags={modifyLocalFileTags}
|
||||||
|
/>
|
||||||
|
</DetailItem>
|
||||||
|
)}
|
||||||
|
{!isLoading && onlyFiles && hasFiles && enableMetadata && (
|
||||||
|
<DetailItem field={rateField} className="sf-metadata-property-detail-editor sf-metadata-rate-property-detail-editor">
|
||||||
|
<RateEditor
|
||||||
|
value={rate}
|
||||||
|
field={rateField}
|
||||||
|
onChange={(value) => onChange(PRIVATE_COLUMN_KEY.FILE_RATE, value)}
|
||||||
|
/>
|
||||||
|
</DetailItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Body>
|
||||||
|
</Detail>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiSelectionDetails.propTypes = {
|
||||||
|
repoID: PropTypes.string.isRequired,
|
||||||
|
path: PropTypes.string.isRequired,
|
||||||
|
selectedDirents: PropTypes.array.isRequired,
|
||||||
|
currentRepoInfo: PropTypes.object.isRequired,
|
||||||
|
modifyLocalFileTags: PropTypes.func,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiSelectionDetails;
|
@@ -107,6 +107,11 @@ class MetadataManagerAPI {
|
|||||||
return this.req.get(url, { params: params });
|
return this.req.get(url, { params: params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRecords(repoID, files) {
|
||||||
|
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/batch-records/';
|
||||||
|
return this.req.post(url, { files: files });
|
||||||
|
}
|
||||||
|
|
||||||
modifyRecord(repoID, { recordId, parentDir, fileName }, updateData) {
|
modifyRecord(repoID, { recordId, parentDir, fileName }, updateData) {
|
||||||
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/';
|
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/';
|
||||||
let data = {
|
let data = {
|
||||||
|
@@ -1838,7 +1838,7 @@ class LibContentView extends React.Component {
|
|||||||
return item.name !== name;
|
return item.name !== name;
|
||||||
});
|
});
|
||||||
this.recalculateSelectedDirents([name], direntList);
|
this.recalculateSelectedDirents([name], direntList);
|
||||||
this.setState({ direntList: direntList });
|
this.setState({ direntList: direntList, currentDirent: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
// only one scene: The moved items are inside current path
|
// only one scene: The moved items are inside current path
|
||||||
@@ -1852,6 +1852,7 @@ class LibContentView extends React.Component {
|
|||||||
selectedDirentList: [],
|
selectedDirentList: [],
|
||||||
isDirentSelected: false,
|
isDirentSelected: false,
|
||||||
isAllDirentSelected: false,
|
isAllDirentSelected: false,
|
||||||
|
currentDirent: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2378,7 +2379,7 @@ class LibContentView extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detailPath = this.state.path;
|
let detailPath = this.state.path;
|
||||||
if (!currentDirent && currentMode !== METADATA_MODE && currentMode !== TAGS_MODE) {
|
if (!currentDirent && currentMode !== METADATA_MODE && currentMode !== TAGS_MODE && this.state.selectedDirentList.length === 0) {
|
||||||
detailPath = Utils.getDirName(this.state.path);
|
detailPath = Utils.getDirName(this.state.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2577,6 +2578,7 @@ class LibContentView extends React.Component {
|
|||||||
repoID={this.props.repoID}
|
repoID={this.props.repoID}
|
||||||
currentRepoInfo={{ ...this.state.currentRepoInfo }}
|
currentRepoInfo={{ ...this.state.currentRepoInfo }}
|
||||||
dirent={detailDirent}
|
dirent={detailDirent}
|
||||||
|
selectedDirents={this.state.selectedDirentList}
|
||||||
repoTags={this.state.repoTags}
|
repoTags={this.state.repoTags}
|
||||||
fileTags={this.state.isViewFile ? this.state.fileTags : []}
|
fileTags={this.state.isViewFile ? this.state.fileTags : []}
|
||||||
onFileTagChanged={this.onFileTagChanged}
|
onFileTagChanged={this.onFileTagChanged}
|
||||||
|
@@ -33,6 +33,7 @@ from seahub.settings import MD_FILE_COUNT_LIMIT
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MetadataManage(APIView):
|
class MetadataManage(APIView):
|
||||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||||
permission_classes = (IsAuthenticated, )
|
permission_classes = (IsAuthenticated, )
|
||||||
@@ -749,6 +750,98 @@ class MetadataColumns(APIView):
|
|||||||
return Response({'success': True})
|
return Response({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataBatchRecords(APIView):
|
||||||
|
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
|
throttle_classes = (UserRateThrottle,)
|
||||||
|
|
||||||
|
def post(self, request, repo_id):
|
||||||
|
"""
|
||||||
|
Get metadata records for multiple files in batch
|
||||||
|
Request body:
|
||||||
|
files: list of {parent_dir, file_name} objects
|
||||||
|
"""
|
||||||
|
files = request.data.get('files', [])
|
||||||
|
if not files or not isinstance(files, list):
|
||||||
|
error_msg = 'files parameter is required and must be a list'
|
||||||
|
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
|
||||||
|
|
||||||
|
if len(files) > METADATA_RECORD_UPDATE_LIMIT:
|
||||||
|
error_msg = 'Number of records exceeds the limit of %s.' % METADATA_RECORD_UPDATE_LIMIT
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not can_read_metadata(request, repo_id):
|
||||||
|
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 = f'Library {repo_id} not found.'
|
||||||
|
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||||
|
|
||||||
|
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
||||||
|
|
||||||
|
from seafevents.repo_metadata.constants import METADATA_TABLE
|
||||||
|
|
||||||
|
try:
|
||||||
|
columns_data = metadata_server_api.list_columns(METADATA_TABLE.id)
|
||||||
|
all_columns = columns_data.get('columns', [])
|
||||||
|
metadata_columns = []
|
||||||
|
for column in all_columns:
|
||||||
|
if column.get('key') in ['_rate', '_tags']:
|
||||||
|
metadata_columns.append({
|
||||||
|
'key': column.get('key'),
|
||||||
|
'name': column.get('name'),
|
||||||
|
'type': column.get('type'),
|
||||||
|
'data': column.get('data', {})
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
error_msg = 'Internal Server Error'
|
||||||
|
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||||
|
|
||||||
|
where_conditions = []
|
||||||
|
parameters = []
|
||||||
|
|
||||||
|
for file_info in files:
|
||||||
|
parent_dir = file_info.get('parent_dir')
|
||||||
|
file_name = file_info.get('file_name')
|
||||||
|
|
||||||
|
if not parent_dir or not file_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
where_conditions.append(f'(`{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?)')
|
||||||
|
parameters.extend([parent_dir, file_name])
|
||||||
|
|
||||||
|
if not where_conditions:
|
||||||
|
return Response({
|
||||||
|
'results': [],
|
||||||
|
'metadata': {'columns': metadata_columns}
|
||||||
|
})
|
||||||
|
|
||||||
|
where_clause = ' OR '.join(where_conditions)
|
||||||
|
sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE {where_clause};'
|
||||||
|
try:
|
||||||
|
query_result = metadata_server_api.query_rows(sql, parameters)
|
||||||
|
results = query_result.get('results', [])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'results': results,
|
||||||
|
'metadata': {'columns': metadata_columns}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
error_msg = 'Internal Server Error'
|
||||||
|
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
|
||||||
|
|
||||||
|
|
||||||
class MetadataFolders(APIView):
|
class MetadataFolders(APIView):
|
||||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@@ -2422,21 +2515,26 @@ class MetadataFileTags(APIView):
|
|||||||
from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
|
from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
|
||||||
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
||||||
|
|
||||||
success_records = []
|
row_id_map = {}
|
||||||
failed_records = []
|
|
||||||
for file_tags in file_tags_data:
|
for file_tags in file_tags_data:
|
||||||
record_id = file_tags.get('record_id', '')
|
record_id = file_tags.get('record_id', '')
|
||||||
tags = file_tags.get('tags', [])
|
tags = file_tags.get('tags', [])
|
||||||
if not record_id:
|
if not record_id:
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
|
row_id_map[record_id] = tags
|
||||||
|
|
||||||
metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags })
|
if not row_id_map:
|
||||||
success_records.append(record_id)
|
return api_error(status.HTTP_400_BAD_REQUEST, 'No valid file_tags_data provided')
|
||||||
except Exception as e:
|
|
||||||
failed_records.append(record_id)
|
|
||||||
|
|
||||||
return Response({'success': success_records, 'fail': failed_records})
|
try:
|
||||||
|
metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, row_id_map)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
|
||||||
|
|
||||||
|
return Response({'success': True })
|
||||||
|
|
||||||
|
|
||||||
class MetadataTagFiles(APIView):
|
class MetadataTagFiles(APIView):
|
||||||
|
@@ -3,12 +3,14 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad
|
|||||||
MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
|
MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
|
||||||
FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
|
FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
|
||||||
MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \
|
MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \
|
||||||
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView
|
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \
|
||||||
|
MetadataBatchRecords
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'),
|
re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'),
|
||||||
re_path(r'^records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
|
re_path(r'^records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
|
||||||
re_path(r'^record/$', MetadataRecord.as_view(), name='api-v2.1-metadata-record-info'),
|
re_path(r'^record/$', MetadataRecord.as_view(), name='api-v2.1-metadata-record-info'),
|
||||||
|
re_path(r'^batch-records/$', MetadataBatchRecords.as_view(), name='api-v2.1-metadata-batch-records'),
|
||||||
re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
|
re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
|
||||||
|
|
||||||
# view
|
# view
|
||||||
|
Reference in New Issue
Block a user