1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-25 23:02:26 +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:
Aries
2025-08-20 17:58:50 +08:00
committed by GitHub
parent c4d4d4369e
commit f0d3587fd9
9 changed files with 659 additions and 16 deletions

View File

@@ -7,9 +7,11 @@ const Title = ({ icon, iconSize, title }) => {
return (
<div className="detail-title dirent-title">
<div className="detail-header-icon-container">
<img src={icon} width={iconSize} height={iconSize} alt="" />
</div>
{icon && (
<div className="detail-header-icon-container">
<img src={icon} width={iconSize} height={iconSize} alt="" />
</div>
)}
<span className="name ellipsis" title={title}>{title}</span>
</div>
);

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import LibDetail from './lib-details';
import DirentDetail from './dirent-details';
import MultiSelectionDetails from './multi-selection-details';
import ViewDetails from '../../metadata/components/view-details';
import ObjectUtils from '../../utils/object';
import { MetadataContext } from '../../metadata';
@@ -11,7 +12,18 @@ import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants';
import { useTags } from '../../tag/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 { tagsData, addTag, modifyLocalFileTags } = useTags();
@@ -36,6 +48,20 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
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) {
const pathParts = path.split('/');
const [, , viewId, children] = pathParts;
@@ -54,7 +80,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
<DirentDetail
repoID={repoID}
path={isView ? dirent.path : path}
dirent={dirent}
dirent={selectedDirents[0] || dirent}
currentRepoInfo={currentRepoInfo}
repoTags={repoTags}
fileTags={fileTags}
@@ -74,6 +100,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
props.repoID !== nextProps.repoID ||
props.path !== nextProps.path ||
!ObjectUtils.isSameObject(props.dirent, nextProps.dirent) ||
!ObjectUtils.isSameObject(props.selectedDirents, nextProps.selectedDirents) ||
!ObjectUtils.isSameObject(props.currentRepoInfo, nextProps.currentRepoInfo) ||
JSON.stringify(props.repoTags || []) !== JSON.stringify(nextProps.repoTags || []) ||
JSON.stringify(props.fileTags || []) !== JSON.stringify(nextProps.fileTags || []);
@@ -85,6 +112,7 @@ Detail.propTypes = {
path: PropTypes.string,
currentMode: PropTypes.string,
dirent: PropTypes.object,
selectedDirents: PropTypes.array,
currentRepoInfo: PropTypes.object,
repoTags: PropTypes.array,
fileTags: PropTypes.array,

View File

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

View File

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

View File

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

View File

@@ -107,6 +107,11 @@ class MetadataManagerAPI {
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) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/';
let data = {

View File

@@ -1838,7 +1838,7 @@ class LibContentView extends React.Component {
return item.name !== name;
});
this.recalculateSelectedDirents([name], direntList);
this.setState({ direntList: direntList });
this.setState({ direntList: direntList, currentDirent: null });
};
// only one scene: The moved items are inside current path
@@ -1852,6 +1852,7 @@ class LibContentView extends React.Component {
selectedDirentList: [],
isDirentSelected: false,
isAllDirentSelected: false,
currentDirent: null,
});
};
@@ -2378,7 +2379,7 @@ class LibContentView extends React.Component {
}
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);
}
@@ -2577,6 +2578,7 @@ class LibContentView extends React.Component {
repoID={this.props.repoID}
currentRepoInfo={{ ...this.state.currentRepoInfo }}
dirent={detailDirent}
selectedDirents={this.state.selectedDirentList}
repoTags={this.state.repoTags}
fileTags={this.state.isViewFile ? this.state.fileTags : []}
onFileTagChanged={this.onFileTagChanged}