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 (
|
||||
<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>
|
||||
);
|
||||
|
@@ -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,
|
||||
|
@@ -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 });
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
@@ -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}
|
||||
|
@@ -33,6 +33,7 @@ from seahub.settings import MD_FILE_COUNT_LIMIT
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class MetadataManage(APIView):
|
||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||
permission_classes = (IsAuthenticated, )
|
||||
@@ -749,6 +750,98 @@ class MetadataColumns(APIView):
|
||||
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):
|
||||
authentication_classes = (TokenAuthentication, SessionAuthentication)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
@@ -2422,21 +2515,26 @@ class MetadataFileTags(APIView):
|
||||
from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
|
||||
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
|
||||
|
||||
success_records = []
|
||||
failed_records = []
|
||||
row_id_map = {}
|
||||
|
||||
for file_tags in file_tags_data:
|
||||
record_id = file_tags.get('record_id', '')
|
||||
tags = file_tags.get('tags', [])
|
||||
if not record_id:
|
||||
continue
|
||||
try:
|
||||
|
||||
row_id_map[record_id] = tags
|
||||
|
||||
metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags })
|
||||
success_records.append(record_id)
|
||||
except Exception as e:
|
||||
failed_records.append(record_id)
|
||||
if not row_id_map:
|
||||
return api_error(status.HTTP_400_BAD_REQUEST, 'No valid file_tags_data provided')
|
||||
|
||||
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):
|
||||
|
@@ -3,12 +3,14 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad
|
||||
MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
|
||||
FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
|
||||
MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \
|
||||
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView
|
||||
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \
|
||||
MetadataBatchRecords
|
||||
|
||||
urlpatterns = [
|
||||
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'^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'),
|
||||
|
||||
# view
|
||||
|
Reference in New Issue
Block a user