mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-14 06:11:16 +00:00
feat: metadata gallery view (#6727)
* feat: metadata gallery view * feat: update code * feat: update code * feat: update code --------- Co-authored-by: 杨国璇 <ygx@192.168.1.4>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import cookie from 'react-cookies';
|
||||
import { siteRoot } from '../utils/constants';
|
||||
import { VIEW_TYPE_DEFAULT_BASIC_FILTER, VIEW_TYPE_DEFAULT_SORTS } from './metadata-view/_basic';
|
||||
|
||||
class MetadataManagerAPI {
|
||||
init({ server, username, password, token }) {
|
||||
@@ -114,7 +115,14 @@ class MetadataManagerAPI {
|
||||
|
||||
addView = (repoID, name, type = 'table') => {
|
||||
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/';
|
||||
const params = { name, type };
|
||||
let params = {
|
||||
name,
|
||||
type,
|
||||
data: {
|
||||
basic_filters: VIEW_TYPE_DEFAULT_BASIC_FILTER[type],
|
||||
sorts: VIEW_TYPE_DEFAULT_SORTS[type],
|
||||
}
|
||||
};
|
||||
return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } });
|
||||
};
|
||||
|
||||
|
@@ -19,12 +19,27 @@ const SORT_COLUMN_OPTIONS = [
|
||||
CellType.RATE,
|
||||
];
|
||||
|
||||
const GALLERY_SORT_COLUMN_OPTIONS = [
|
||||
CellType.CTIME,
|
||||
CellType.MTIME,
|
||||
CellType.RATE,
|
||||
CellType.NUMBER,
|
||||
CellType.FILE_NAME,
|
||||
];
|
||||
|
||||
const GALLERY_FIRST_SORT_COLUMN_OPTIONS = [
|
||||
CellType.CTIME,
|
||||
CellType.MTIME,
|
||||
];
|
||||
|
||||
const TEXT_SORTER_COLUMN_TYPES = [CellType.TEXT];
|
||||
const NUMBER_SORTER_COLUMN_TYPES = [CellType.NUMBER, CellType.RATE];
|
||||
|
||||
export {
|
||||
SORT_TYPE,
|
||||
SORT_COLUMN_OPTIONS,
|
||||
GALLERY_SORT_COLUMN_OPTIONS,
|
||||
GALLERY_FIRST_SORT_COLUMN_OPTIONS,
|
||||
TEXT_SORTER_COLUMN_TYPES,
|
||||
NUMBER_SORTER_COLUMN_TYPES,
|
||||
};
|
||||
|
@@ -1,3 +1,7 @@
|
||||
import { PRIVATE_COLUMN_KEY } from './column';
|
||||
import { FILTER_PREDICATE_TYPE } from './filter';
|
||||
import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_COLUMN_OPTIONS, SORT_TYPE } from './sort';
|
||||
|
||||
export const VIEW_TYPE = {
|
||||
TABLE: 'table',
|
||||
GALLERY: 'gallery'
|
||||
@@ -8,3 +12,23 @@ export const VIEW_TYPE_ICON = {
|
||||
[VIEW_TYPE.GALLERY]: 'image',
|
||||
'image': 'image'
|
||||
};
|
||||
|
||||
export const VIEW_TYPE_DEFAULT_BASIC_FILTER = {
|
||||
[VIEW_TYPE.TABLE]: [{ column_key: PRIVATE_COLUMN_KEY.IS_DIR, filter_predicate: FILTER_PREDICATE_TYPE.IS, filter_term: 'file' }],
|
||||
[VIEW_TYPE.GALLERY]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_TYPE, filter_predicate: FILTER_PREDICATE_TYPE.IS, filter_term: 'picture' }],
|
||||
};
|
||||
|
||||
export const VIEW_TYPE_DEFAULT_SORTS = {
|
||||
[VIEW_TYPE.TABLE]: [],
|
||||
[VIEW_TYPE.GALLERY]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
|
||||
};
|
||||
|
||||
export const VIEW_SORT_COLUMN_OPTIONS = {
|
||||
[VIEW_TYPE.TABLE]: SORT_COLUMN_OPTIONS,
|
||||
[VIEW_TYPE.GALLERY]: GALLERY_SORT_COLUMN_OPTIONS,
|
||||
};
|
||||
|
||||
export const VIEW_FIRST_SORT_COLUMN_OPTIONS = {
|
||||
[VIEW_TYPE.TABLE]: SORT_COLUMN_OPTIONS,
|
||||
[VIEW_TYPE.GALLERY]: GALLERY_FIRST_SORT_COLUMN_OPTIONS,
|
||||
};
|
||||
|
@@ -1,35 +1,32 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button, Input } from 'reactstrap';
|
||||
import { EVENT_BUS_TYPE } from '../../constants';
|
||||
import Icon from '../../../../components/icon';
|
||||
|
||||
import './slider-setter.css';
|
||||
|
||||
const SliderSetter = () => {
|
||||
const [sliderValue, setSliderValue] = useState(() => {
|
||||
const savedValue = localStorage.getItem('sliderValue');
|
||||
return savedValue !== null ? parseInt(savedValue, 10) : 0;
|
||||
const savedValue = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0);
|
||||
return savedValue || 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sliderValue', sliderValue);
|
||||
}, [sliderValue]);
|
||||
|
||||
const handleGalleryColumnsChange = useCallback((e) => {
|
||||
const adjust = parseInt(e.target.value, 10);
|
||||
setSliderValue(adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_COLUMNS, adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, adjust);
|
||||
}, []);
|
||||
|
||||
const handleImageExpand = useCallback(() => {
|
||||
const adjust = Math.min(sliderValue + 1, 2);
|
||||
setSliderValue(adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_COLUMNS, adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, adjust);
|
||||
}, [sliderValue]);
|
||||
|
||||
const handleImageShrink = useCallback(() => {
|
||||
const adjust = Math.max(sliderValue - 1, -2);
|
||||
setSliderValue(adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_COLUMNS, adjust);
|
||||
window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, adjust);
|
||||
}, [sliderValue]);
|
||||
|
||||
return (
|
||||
|
@@ -6,7 +6,7 @@ import { getValidSorts, CommonlyUsedHotkey } from '../../_basic';
|
||||
import { gettext } from '../../utils';
|
||||
import { SortPopover } from '../popover';
|
||||
|
||||
const SortSetter = ({ target, sorts: propsSorts, readOnly, columns, isNeedSubmit, wrapperClass, modifySorts }) => {
|
||||
const SortSetter = ({ target, type, sorts: propsSorts, readOnly, columns, isNeedSubmit, wrapperClass, modifySorts }) => {
|
||||
const [isShowSetter, setShowSetter] = useState(false);
|
||||
|
||||
const sorts = useMemo(() => {
|
||||
@@ -54,6 +54,7 @@ const SortSetter = ({ target, sorts: propsSorts, readOnly, columns, isNeedSubmit
|
||||
<SortPopover
|
||||
isNeedSubmit={isNeedSubmit}
|
||||
readOnly={readOnly}
|
||||
type={type}
|
||||
target={target}
|
||||
columns={columns}
|
||||
sorts={sorts}
|
||||
@@ -68,10 +69,11 @@ const SortSetter = ({ target, sorts: propsSorts, readOnly, columns, isNeedSubmit
|
||||
|
||||
|
||||
const propTypes = {
|
||||
isNeedSubmit: PropTypes.bool,
|
||||
readOnly: PropTypes.bool,
|
||||
wrapperClass: PropTypes.string,
|
||||
target: PropTypes.string,
|
||||
isNeedSubmit: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
sorts: PropTypes.array,
|
||||
columns: PropTypes.array,
|
||||
modifySorts: PropTypes.func,
|
||||
|
@@ -0,0 +1,69 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { gettext } from '../../../../utils';
|
||||
import { CustomizeSelect, Icon } from '@seafile/sf-metadata-ui-component';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'picture', name: gettext('Only pictures') },
|
||||
{ value: 'video', name: gettext('Only videos') },
|
||||
{ value: 'all', name: gettext('Pictures and videos') },
|
||||
];
|
||||
|
||||
const FileTypeFilter = ({ readOnly, value = 'picture', onChange: onChangeAPI }) => {
|
||||
|
||||
const options = useMemo(() => {
|
||||
return OPTIONS.map(o => {
|
||||
const { name } = o;
|
||||
return {
|
||||
value: o.value,
|
||||
label: (
|
||||
<div className="select-basic-filter-option">
|
||||
<div className="select-basic-filter-option-name" title={name} aria-label={name}>{name}</div>
|
||||
<div className="select-basic-filter-option-check-icon">
|
||||
{value === o.value && (<Icon iconName="check-mark" />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
const selectedOption = OPTIONS.find(o => o.value === value) || OPTIONS[2];
|
||||
return {
|
||||
label: (
|
||||
<div>
|
||||
{selectedOption.name}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const onChange = useCallback((newValue) => {
|
||||
if (newValue === value) return;
|
||||
onChangeAPI(newValue);
|
||||
}, [value, onChangeAPI]);
|
||||
|
||||
return (
|
||||
<CustomizeSelect
|
||||
readOnly={readOnly}
|
||||
className="sf-metadata-basic-filters-select"
|
||||
value={displayValue}
|
||||
options={options}
|
||||
onSelectOption={onChange}
|
||||
component={{
|
||||
DropDownIcon: (
|
||||
<i className="sf3-font sf3-font-down"></i>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FileTypeFilter.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FileTypeFilter;
|
@@ -55,3 +55,7 @@
|
||||
.sf-metadata-basic-filters-select .sf-metadata-option-group {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.filter-group-basic .sf-metadata-filters-list {
|
||||
min-height: unset;
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import { FormGroup, Label } from 'reactstrap';
|
||||
import { gettext } from '../../../../utils';
|
||||
import { PRIVATE_COLUMN_KEY } from '../../../../_basic';
|
||||
import FileOrFolderFilter from './file-folder-filter';
|
||||
import FileTypeFilter from './file-type-filter';
|
||||
|
||||
import './index.css';
|
||||
|
||||
@@ -17,27 +18,43 @@ const BasicFilters = ({ readOnly, filters = [], onChange }) => {
|
||||
onChange(newFilters);
|
||||
}, [filters, onChange]);
|
||||
|
||||
const onChangeFileTypeFilter = useCallback((newValue) => {
|
||||
const filterIndex = filters.findIndex(filter => filter.column_key === PRIVATE_COLUMN_KEY.FILE_TYPE);
|
||||
const filter = filters[filterIndex];
|
||||
const newFilters = filters.slice(0);
|
||||
newFilters[filterIndex] = { ...filter, filter_term: newValue };
|
||||
onChange(newFilters);
|
||||
}, [filters, onChange]);
|
||||
|
||||
return (
|
||||
<FormGroup className="filter-group p-4">
|
||||
<FormGroup className="filter-group-basic filter-group p-4">
|
||||
<Label className="filter-group-name">{gettext('Basic')}</Label>
|
||||
<div className="filter-group-container">
|
||||
{filters.map(filter => {
|
||||
<div className="sf-metadata-filters-list">
|
||||
{filters.map((filter, index) => {
|
||||
const { column_key, filter_term } = filter;
|
||||
if (column_key === PRIVATE_COLUMN_KEY.IS_DIR) {
|
||||
return (
|
||||
<FileOrFolderFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeFileOrFolderFilter} />
|
||||
);
|
||||
}
|
||||
if (column_key === PRIVATE_COLUMN_KEY.FILE_TYPE) {
|
||||
return (
|
||||
<FileTypeFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeFileTypeFilter} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
BasicFilters.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
value: PropTypes.string,
|
||||
filters: PropTypes.array,
|
||||
columns: PropTypes.array,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
|
@@ -5,8 +5,10 @@ import { Button, UncontrolledPopover } from 'reactstrap';
|
||||
import { CustomizeAddTool, CustomizeSelect, Icon } from '@seafile/sf-metadata-ui-component';
|
||||
import {
|
||||
COLUMNS_ICON_CONFIG,
|
||||
SORT_COLUMN_OPTIONS,
|
||||
VIEW_SORT_COLUMN_OPTIONS,
|
||||
VIEW_FIRST_SORT_COLUMN_OPTIONS,
|
||||
SORT_TYPE,
|
||||
VIEW_TYPE,
|
||||
getColumnByKey,
|
||||
} from '../../../_basic';
|
||||
import { execSortsOperation, getDisplaySorts, isSortsEmpty, SORT_OPERATION } from './utils';
|
||||
@@ -28,8 +30,9 @@ const SORT_TYPES = [
|
||||
|
||||
const propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
target: PropTypes.string.isRequired,
|
||||
isNeedSubmit: PropTypes.bool,
|
||||
target: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
sorts: PropTypes.array,
|
||||
columns: PropTypes.array.isRequired,
|
||||
onSortComponentToggle: PropTypes.func,
|
||||
@@ -44,8 +47,10 @@ class SortPopover extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { sorts, columns } = this.props;
|
||||
const { sorts, columns, type } = this.props;
|
||||
this.sortTypeOptions = this.createSortTypeOptions();
|
||||
this.supportFirstSortColumnOptions = VIEW_FIRST_SORT_COLUMN_OPTIONS[type || VIEW_TYPE.TABLE];
|
||||
this.supportSortColumnOptions = VIEW_SORT_COLUMN_OPTIONS[type || VIEW_TYPE.TABLE];
|
||||
this.columnsOptions = this.createColumnsOptions(columns);
|
||||
this.state = {
|
||||
sorts: getDisplaySorts(sorts, columns),
|
||||
@@ -154,7 +159,7 @@ class SortPopover extends Component {
|
||||
};
|
||||
|
||||
createColumnsOptions = (columns = []) => {
|
||||
const sortableColumns = columns.filter(column => SORT_COLUMN_OPTIONS.includes(column.type));
|
||||
const sortableColumns = columns.filter(column => this.supportSortColumnOptions.includes(column.type));
|
||||
return sortableColumns.map((column) => {
|
||||
const { type, name } = column;
|
||||
return {
|
||||
@@ -189,7 +194,7 @@ class SortPopover extends Component {
|
||||
|
||||
renderSortItem = (column, sort, index) => {
|
||||
const { name, type } = column;
|
||||
const { readOnly } = this.props;
|
||||
const { readOnly, type: viewType } = this.props;
|
||||
const selectedColumn = {
|
||||
label: (
|
||||
<Fragment>
|
||||
@@ -205,11 +210,16 @@ class SortPopover extends Component {
|
||||
label: <span className="select-option-name">{selectedTypeOption?.name || gettext('Up')}</span>
|
||||
};
|
||||
|
||||
let columnsOptions = this.columnsOptions;
|
||||
if (index === 0) {
|
||||
columnsOptions = columnsOptions.filter(o => this.supportFirstSortColumnOptions.includes(o.value.column.type));
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={'sort-item-' + index} className="sort-item">
|
||||
{!readOnly &&
|
||||
<div className="delete-sort" onClick={(event) => this.deleteSort(event, index)}>
|
||||
<Icon iconName="fork-number"/>
|
||||
<div className="delete-sort" onClick={!(viewType === VIEW_TYPE.GALLERY && index === 0) ? () => {} : (event) => this.deleteSort(event, index)}>
|
||||
{!(viewType === VIEW_TYPE.GALLERY && index === 0) && <Icon iconName="fork-number"/>}
|
||||
</div>
|
||||
}
|
||||
<div className="condition">
|
||||
@@ -218,7 +228,7 @@ class SortPopover extends Component {
|
||||
readOnly={readOnly}
|
||||
value={selectedColumn}
|
||||
onSelectOption={(value) => this.onSelectColumn(value, index)}
|
||||
options={this.columnsOptions}
|
||||
options={columnsOptions}
|
||||
searchable={true}
|
||||
searchPlaceholder={gettext('Search property')}
|
||||
noOptionsPlaceholder={gettext('No results')}
|
||||
|
@@ -63,6 +63,7 @@ const ViewToolBar = ({ viewId }) => {
|
||||
|
||||
if (!view) return null;
|
||||
|
||||
const viewType = view.type;
|
||||
const readOnly = !window.sfMetadataContext.canModifyView(view);
|
||||
|
||||
return (
|
||||
@@ -91,10 +92,11 @@ const ViewToolBar = ({ viewId }) => {
|
||||
target="sf-metadata-sort-popover"
|
||||
readOnly={readOnly}
|
||||
sorts={view.sorts}
|
||||
type={viewType}
|
||||
columns={viewColumns}
|
||||
modifySorts={modifySorts}
|
||||
/>
|
||||
{view.type !== VIEW_TYPE.GALLERY && (
|
||||
{viewType !== VIEW_TYPE.GALLERY && (
|
||||
<GroupbySetter
|
||||
isNeedSubmit={true}
|
||||
wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-groupby"
|
||||
@@ -105,7 +107,7 @@ const ViewToolBar = ({ viewId }) => {
|
||||
modifyGroupbys={modifyGroupbys}
|
||||
/>
|
||||
)}
|
||||
{view.type !== VIEW_TYPE.GALLERY && (
|
||||
{viewType !== VIEW_TYPE.GALLERY && (
|
||||
<HideColumnSetter
|
||||
wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-hide-column"
|
||||
target="sf-metadata-hide-column-popover"
|
||||
|
@@ -1,4 +1,4 @@
|
||||
.metadata-gallery-container {
|
||||
.sf-metadata-gallery-container {
|
||||
height: calc(100vh - 100px);
|
||||
margin: 2px;
|
||||
position: relative;
|
||||
@@ -61,3 +61,12 @@
|
||||
.metadata-gallery-grid-image:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.sf-metadata-gallery-loading-more {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
@@ -1,116 +1,107 @@
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
|
||||
import { useMetadata } from '../../../hooks';
|
||||
import { Utils } from '../../../../../utils/utils';
|
||||
import { PRIVATE_COLUMN_KEY } from '../../../_basic';
|
||||
import { getDateDisplayString, PRIVATE_COLUMN_KEY } from '../../../_basic';
|
||||
import { siteRoot, thumbnailSizeForGrid } from '../../../../../utils/constants';
|
||||
import { EVENT_BUS_TYPE } from '../../../constants';
|
||||
import { EVENT_BUS_TYPE, PER_LOAD_NUMBER } from '../../../constants';
|
||||
import Main from './main';
|
||||
import toaster from '../../../../../components/toast';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const CONCURRENCY_LIMIT = 3;
|
||||
const IMAGE_GAP = 2;
|
||||
|
||||
const Gallery = () => {
|
||||
const [imageWidth, setImageWidth] = useState(100);
|
||||
const [columns, setColumns] = useState(8);
|
||||
const [containerWidth, setContainerWidth] = useState(960);
|
||||
const [adjustValue, setAdjustValue] = useState(() => {
|
||||
try {
|
||||
const savedValue = localStorage.getItem('sliderValue');
|
||||
return savedValue !== null ? Number(savedValue) : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
const [visibleItems, setVisibleItems] = useState(BATCH_SIZE);
|
||||
const [loadingQueue, setLoadingQueue] = useState([]);
|
||||
const [concurrentLoads, setConcurrentLoads] = useState(0);
|
||||
|
||||
const imageRefs = useRef([]);
|
||||
const containerRef = useRef(null);
|
||||
const [isFirstLoading, setFirstLoading] = useState(true);
|
||||
const [isLoadingMore, setLoadingMore] = useState(false);
|
||||
const [zoomGear, setZoomGear] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [loadingQueue, setLoadingQueue] = useState([]);
|
||||
const [concurrentLoads, setConcurrentLoads] = useState(0);
|
||||
const [overScan, setOverScan] = useState({ top: 0, bottom: 0 });
|
||||
const renderMoreTimer = useRef(null);
|
||||
|
||||
const { metadata } = useMetadata();
|
||||
const { metadata, store } = useMetadata();
|
||||
const repoID = window.sfMetadataContext.getSetting('repoID');
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
// Number of images per row
|
||||
const columns = useMemo(() => {
|
||||
return 8 - zoomGear;
|
||||
}, [zoomGear]);
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
const currentContainer = containerRef.current;
|
||||
const imageSize = useMemo(() => {
|
||||
return (containerWidth - columns * 2 - 2) / columns;
|
||||
}, [containerWidth, columns]);
|
||||
|
||||
if (currentContainer) {
|
||||
resizeObserver.observe(currentContainer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentContainer) {
|
||||
resizeObserver.unobserve(currentContainer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_GALLERY_COLUMNS, (adjust) => {
|
||||
setAdjustValue(adjust);
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const columns = (Utils.isDesktop() ? 8 : 4) - adjustValue;
|
||||
const adjustedImageWidth = (containerWidth - columns * 2 - 2) / columns;
|
||||
setColumns(columns);
|
||||
setImageWidth(adjustedImageWidth);
|
||||
}, [containerWidth, adjustValue]);
|
||||
|
||||
const imageItems = useMemo(() => {
|
||||
return metadata.rows
|
||||
.filter(row => Utils.imageCheck(row[PRIVATE_COLUMN_KEY.FILE_NAME]))
|
||||
.map(item => {
|
||||
const fileName = item[PRIVATE_COLUMN_KEY.FILE_NAME];
|
||||
const parentDir = item[PRIVATE_COLUMN_KEY.PARENT_DIR];
|
||||
const groups = useMemo(() => {
|
||||
if (isFirstLoading) return [];
|
||||
const firstSort = metadata.view.sorts[0];
|
||||
let init = metadata.rows.reduce((_init, record) => {
|
||||
const fileName = record[PRIVATE_COLUMN_KEY.FILE_NAME];
|
||||
const parentDir = record[PRIVATE_COLUMN_KEY.PARENT_DIR];
|
||||
const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
|
||||
const date = item[PRIVATE_COLUMN_KEY.FILE_CTIME].split('T')[0];
|
||||
const src = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`;
|
||||
return {
|
||||
const date = getDateDisplayString(record[firstSort.column_key], 'YYYY-MM-DD');
|
||||
const img = {
|
||||
name: fileName,
|
||||
url: `${siteRoot}lib/${repoID}/file${path}`,
|
||||
src: src,
|
||||
src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
|
||||
date: date,
|
||||
};
|
||||
let _group = _init.find(g => g.name === date);
|
||||
if (_group) {
|
||||
_group.children.push(img);
|
||||
} else {
|
||||
_init.push({
|
||||
name: date,
|
||||
children: [img],
|
||||
});
|
||||
}, [metadata, repoID]);
|
||||
}
|
||||
return _init;
|
||||
}, []);
|
||||
|
||||
const groupedImages = useMemo(() => {
|
||||
return imageItems.reduce((acc, item) => {
|
||||
if (!acc[item.date]) {
|
||||
acc[item.date] = [];
|
||||
let _groups = [];
|
||||
init.forEach((_init, index) => {
|
||||
const { children } = _init;
|
||||
const childrenCount = children.length;
|
||||
const value = childrenCount / columns;
|
||||
const rows = childrenCount % columns ? Math.ceil(value) : ~~(value);
|
||||
const height = rows * (imageSize + IMAGE_GAP);
|
||||
let top = 0;
|
||||
if (index > 0) {
|
||||
const lastGroup = _groups[index - 1];
|
||||
const { top: lastGroupTop, height: lastGroupHeight } = lastGroup;
|
||||
top = lastGroupTop + lastGroupHeight;
|
||||
}
|
||||
acc[item.date].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
}, [imageItems]);
|
||||
_groups.push({
|
||||
..._init,
|
||||
top,
|
||||
height,
|
||||
});
|
||||
});
|
||||
return _groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (visibleItems >= imageItems.length) return;
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 10) {
|
||||
setVisibleItems(prev => Math.min(prev + BATCH_SIZE, imageItems.length));
|
||||
}
|
||||
}
|
||||
}, [visibleItems, imageItems.length]);
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoadingMore) return;
|
||||
if (!metadata.hasMore) return;
|
||||
setLoadingMore(true);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
try {
|
||||
await store.loadMore(PER_LOAD_NUMBER);
|
||||
setLoadingMore(false);
|
||||
} catch (error) {
|
||||
const errorMsg = Utils.getErrorMsg(error);
|
||||
toaster.danger(errorMsg);
|
||||
setLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
}, [isLoadingMore, metadata, store]);
|
||||
|
||||
const loadNextImage = useCallback(() => {
|
||||
if (loadingQueue.length === 0 || concurrentLoads >= CONCURRENCY_LIMIT) return;
|
||||
@@ -142,16 +133,65 @@ const Gallery = () => {
|
||||
}, [loadingQueue, concurrentLoads, loadNextImage]);
|
||||
|
||||
useEffect(() => {
|
||||
const gear = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0) || 0;
|
||||
setZoomGear(gear);
|
||||
|
||||
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 + IMAGE_GAP) * 2 });
|
||||
}
|
||||
setFirstLoading(false);
|
||||
|
||||
// resize
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) return;
|
||||
setContainerWidth(containerRef.current.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();
|
||||
|
||||
// Cleanup image references on unmount
|
||||
imageRefs.current.forEach(img => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
});
|
||||
imageRefs.current = [];
|
||||
renderMoreTimer.current && clearTimeout(renderMoreTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 + IMAGE_GAP) * 3);
|
||||
const overScanBottom = scrollTop + clientHeight + (imageSize + IMAGE_GAP) * 3;
|
||||
setOverScan({ top: overScanTop, bottom: overScanBottom });
|
||||
renderMoreTimer.current = null;
|
||||
}, 200);
|
||||
}
|
||||
}, [imageSize, loadMore, renderMoreTimer]);
|
||||
|
||||
const addToQueue = (image) => {
|
||||
setLoadingQueue(prev => [...prev, image]);
|
||||
loadNextImage();
|
||||
@@ -159,24 +199,13 @@ const Gallery = () => {
|
||||
|
||||
return (
|
||||
<div className="sf-metadata-container">
|
||||
<div ref={containerRef} className="metadata-gallery-container">
|
||||
{Object.keys(groupedImages).map(date => (
|
||||
<div key={date} className="metadata-gallery-date-group">
|
||||
<div className="metadata-gallery-date-tag">{date}</div>
|
||||
<ul className="metadata-gallery-image-list" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
|
||||
{groupedImages[date].slice(0, visibleItems).map((img, index) => (
|
||||
<li key={index} tabIndex={index} className='metadata-gallery-image-item' style={{ width: imageWidth, height: imageWidth }}>
|
||||
<img
|
||||
className="metadata-gallery-grid-image"
|
||||
src={img.src}
|
||||
alt={img.name}
|
||||
onLoad={() => addToQueue(img)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div className="sf-metadata-gallery-container" ref={containerRef} onScroll={handleScroll} >
|
||||
{!isFirstLoading && (
|
||||
<>
|
||||
<Main groups={groups} size={imageSize} onLoad={addToQueue} columns={columns} overScan={overScan} gap={IMAGE_GAP} />
|
||||
{isLoadingMore && (<div className="sf-metadata-gallery-loading-more"><CenteredLoading /></div>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -0,0 +1,65 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Main = ({ groups, overScan, columns, onLoad, size, gap }) => {
|
||||
|
||||
const renderDisplayGroup = useCallback((group) => {
|
||||
const { top: overScanTop, bottom: overScanBottom } = overScan;
|
||||
const { name, children, top, height } = group;
|
||||
let childrenStartIndex = children.findIndex((r, i) => {
|
||||
const rTop = ~~(i / columns) * (size + 2) + top;
|
||||
return rTop >= overScanTop;
|
||||
});
|
||||
childrenStartIndex = Math.max(childrenStartIndex, 0);
|
||||
let childrenEndIndex = children.findIndex((r, i) => {
|
||||
const rTop = ~~(i / columns) * (size + gap) + top;
|
||||
return rTop >= overScanBottom;
|
||||
});
|
||||
if (childrenEndIndex > -1 && childrenEndIndex !== 0) {
|
||||
childrenEndIndex = childrenEndIndex - 1;
|
||||
}
|
||||
if (childrenEndIndex === -1) {
|
||||
childrenEndIndex = children.length - 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={name} className="metadata-gallery-date-group w-100" style={{ height }}>
|
||||
{childrenStartIndex === 0 && (<div className="metadata-gallery-date-tag">{name}</div>)}
|
||||
<div
|
||||
className="metadata-gallery-image-list"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
paddingTop: ~~(childrenStartIndex / columns) * (size + gap),
|
||||
paddingBottom: ~~((children.length - 1 - childrenEndIndex) / columns) * (size + gap),
|
||||
}}
|
||||
>
|
||||
{children.slice(childrenStartIndex, childrenEndIndex).map((img) => (
|
||||
<div key={img.src} tabIndex={1} className='metadata-gallery-image-item' style={{ width: size, height: size }}>
|
||||
<img
|
||||
className="metadata-gallery-grid-image"
|
||||
src={img.src}
|
||||
alt={img.name}
|
||||
onLoad={() => onLoad(img)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [overScan, columns, onLoad, size, gap]);
|
||||
|
||||
if (!Array.isArray(groups) || groups.length === 0) return null;
|
||||
return groups.map((group, index) => {
|
||||
return renderDisplayGroup(group, index);
|
||||
});
|
||||
};
|
||||
|
||||
Main.propTypes = {
|
||||
groups: PropTypes.array,
|
||||
overScan: PropTypes.object,
|
||||
columns: PropTypes.number,
|
||||
onLoad: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Main;
|
@@ -51,5 +51,5 @@ export const EVENT_BUS_TYPE = {
|
||||
ERROR: 'error',
|
||||
|
||||
// gallery
|
||||
MODIFY_GALLERY_COLUMNS: 'modify_gallery_columns',
|
||||
MODIFY_GALLERY_ZOOM_GEAR: 'modify_gallery_zoom_gear',
|
||||
};
|
||||
|
@@ -17,7 +17,7 @@ export const MetadataProvider = ({
|
||||
...params
|
||||
}) => {
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [metadata, setMetadata] = useState({ rows: [], columns: [] });
|
||||
const [metadata, setMetadata] = useState({ rows: [], columns: [], view: {} });
|
||||
const storeRef = useRef(null);
|
||||
const { collaborators } = useCollaborators();
|
||||
const { showFirstView, setShowFirstView } = usePropsMetadata();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { getColumnByKey, VIEW_NOT_DISPLAY_COLUMN_KEYS, PRIVATE_COLUMN_KEY, FILTER_PREDICATE_TYPE, VIEW_TYPE } from '../../_basic';
|
||||
import { getColumnByKey, VIEW_NOT_DISPLAY_COLUMN_KEYS, VIEW_TYPE_DEFAULT_BASIC_FILTER, VIEW_TYPE } from '../../_basic';
|
||||
|
||||
class View {
|
||||
constructor(object, columns) {
|
||||
@@ -14,7 +14,7 @@ class View {
|
||||
this.filters = object.filters || [];
|
||||
this.filter_conjunction = object.filter_conjunction || 'Or';
|
||||
|
||||
this.basic_filters = object.basic_filters && object.basic_filters.length > 0 ? object.basic_filters : [{ column_key: PRIVATE_COLUMN_KEY.IS_DIR, filter_predicate: FILTER_PREDICATE_TYPE.IS, filter_term: 'all' }];
|
||||
this.basic_filters = object.basic_filters && object.basic_filters.length > 0 ? object.basic_filters : VIEW_TYPE_DEFAULT_BASIC_FILTER[this.type];
|
||||
|
||||
// sort
|
||||
this.sorts = object.sorts || [];
|
||||
|
@@ -550,6 +550,8 @@ class LibContentView extends React.Component {
|
||||
content: '',
|
||||
viewId: '',
|
||||
isDirentDetailShow: false
|
||||
}, () => {
|
||||
this.showDir('/');
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -101,7 +101,7 @@ class MetadataManage(APIView):
|
||||
task_id = add_init_metadata_task(params=params)
|
||||
metadata_view = RepoMetadataViews.objects.filter(repo_id=repo_id).first()
|
||||
if not metadata_view:
|
||||
RepoMetadataViews.objects.add_view(repo_id, 'All files')
|
||||
RepoMetadataViews.objects.add_view(repo_id, 'All files', 'table')
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
|
||||
@@ -576,6 +576,7 @@ class MetadataViews(APIView):
|
||||
# Add a metadata view
|
||||
view_name = request.data.get('name')
|
||||
view_type = request.data.get('type', 'table')
|
||||
view_data = request.data.get('data', {})
|
||||
if not view_name:
|
||||
error_msg = 'view name is invalid.'
|
||||
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
|
||||
@@ -596,7 +597,7 @@ class MetadataViews(APIView):
|
||||
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
|
||||
|
||||
try:
|
||||
new_view = RepoMetadataViews.objects.add_view(repo_id, view_name, view_type)
|
||||
new_view = RepoMetadataViews.objects.add_view(repo_id, view_name, view_type, view_data)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
error_msg = 'Internal Server Error'
|
||||
|
@@ -62,9 +62,10 @@ class RepoMetadata(models.Model):
|
||||
|
||||
class RepoView(object):
|
||||
|
||||
def __init__(self, name, type='table', view_ids=None):
|
||||
def __init__(self, name, type='table', view_data={}, view_ids=None):
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.view_data = view_data
|
||||
self.view_json = {}
|
||||
|
||||
self.init_view(view_ids)
|
||||
@@ -81,14 +82,18 @@ class RepoView(object):
|
||||
"hidden_columns": [],
|
||||
"type": self.type,
|
||||
}
|
||||
self.view_json.update(self.view_data)
|
||||
|
||||
|
||||
class RepoMetadataViewsManager(models.Manager):
|
||||
|
||||
def add_view(self, repo_id, view_name, view_type='table'):
|
||||
def add_view(self, repo_id, view_name, view_type='table', view_data={}):
|
||||
metadata_views = self.filter(repo_id=repo_id).first()
|
||||
if not metadata_views:
|
||||
new_view = RepoView(view_name)
|
||||
from seafevents.repo_metadata.utils import METADATA_TABLE
|
||||
new_view = RepoView(view_name, view_type, {
|
||||
'basic_filters': [{ 'column_key': METADATA_TABLE.columns.is_dir.key, 'filter_predicate': 'is', 'filter_term': 'file' }]
|
||||
})
|
||||
view_json = new_view.view_json
|
||||
view_id = view_json.get('_id')
|
||||
view_details = {
|
||||
@@ -103,7 +108,7 @@ class RepoMetadataViewsManager(models.Manager):
|
||||
view_details = json.loads(metadata_views.details)
|
||||
view_name = get_no_duplicate_obj_name(view_name, metadata_views.view_names)
|
||||
exist_view_ids = metadata_views.view_ids
|
||||
new_view = RepoView(view_name, view_type, exist_view_ids)
|
||||
new_view = RepoView(view_name, view_type, view_data, exist_view_ids)
|
||||
view_json = new_view.view_json
|
||||
view_id = view_json.get('_id')
|
||||
view_details['views'].append(view_json)
|
||||
|
Reference in New Issue
Block a user