1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-14 22:33:17 +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:
杨国璇
2024-09-08 12:16:33 +08:00
committed by GitHub
parent 4c295bc38c
commit 4ccd0f3477
19 changed files with 412 additions and 153 deletions

View File

@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import cookie from 'react-cookies'; import cookie from 'react-cookies';
import { siteRoot } from '../utils/constants'; import { siteRoot } from '../utils/constants';
import { VIEW_TYPE_DEFAULT_BASIC_FILTER, VIEW_TYPE_DEFAULT_SORTS } from './metadata-view/_basic';
class MetadataManagerAPI { class MetadataManagerAPI {
init({ server, username, password, token }) { init({ server, username, password, token }) {
@@ -114,7 +115,14 @@ class MetadataManagerAPI {
addView = (repoID, name, type = 'table') => { addView = (repoID, name, type = 'table') => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; 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' } }); return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } });
}; };

View File

@@ -19,12 +19,27 @@ const SORT_COLUMN_OPTIONS = [
CellType.RATE, 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 TEXT_SORTER_COLUMN_TYPES = [CellType.TEXT];
const NUMBER_SORTER_COLUMN_TYPES = [CellType.NUMBER, CellType.RATE]; const NUMBER_SORTER_COLUMN_TYPES = [CellType.NUMBER, CellType.RATE];
export { export {
SORT_TYPE, SORT_TYPE,
SORT_COLUMN_OPTIONS, SORT_COLUMN_OPTIONS,
GALLERY_SORT_COLUMN_OPTIONS,
GALLERY_FIRST_SORT_COLUMN_OPTIONS,
TEXT_SORTER_COLUMN_TYPES, TEXT_SORTER_COLUMN_TYPES,
NUMBER_SORTER_COLUMN_TYPES, NUMBER_SORTER_COLUMN_TYPES,
}; };

View File

@@ -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 = { export const VIEW_TYPE = {
TABLE: 'table', TABLE: 'table',
GALLERY: 'gallery' GALLERY: 'gallery'
@@ -8,3 +12,23 @@ export const VIEW_TYPE_ICON = {
[VIEW_TYPE.GALLERY]: 'image', [VIEW_TYPE.GALLERY]: 'image',
'image': '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,
};

View File

@@ -1,35 +1,32 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback } from 'react';
import { Button, Input } from 'reactstrap'; import { Button, Input } from 'reactstrap';
import { EVENT_BUS_TYPE } from '../../constants'; import { EVENT_BUS_TYPE } from '../../constants';
import Icon from '../../../../components/icon'; import Icon from '../../../../components/icon';
import './slider-setter.css'; import './slider-setter.css';
const SliderSetter = () => { const SliderSetter = () => {
const [sliderValue, setSliderValue] = useState(() => { const [sliderValue, setSliderValue] = useState(() => {
const savedValue = localStorage.getItem('sliderValue'); const savedValue = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0);
return savedValue !== null ? parseInt(savedValue, 10) : 0; return savedValue || 0;
}); });
useEffect(() => {
localStorage.setItem('sliderValue', sliderValue);
}, [sliderValue]);
const handleGalleryColumnsChange = useCallback((e) => { const handleGalleryColumnsChange = useCallback((e) => {
const adjust = parseInt(e.target.value, 10); const adjust = parseInt(e.target.value, 10);
setSliderValue(adjust); 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 handleImageExpand = useCallback(() => {
const adjust = Math.min(sliderValue + 1, 2); const adjust = Math.min(sliderValue + 1, 2);
setSliderValue(adjust); 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]); }, [sliderValue]);
const handleImageShrink = useCallback(() => { const handleImageShrink = useCallback(() => {
const adjust = Math.max(sliderValue - 1, -2); const adjust = Math.max(sliderValue - 1, -2);
setSliderValue(adjust); 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]); }, [sliderValue]);
return ( return (

View File

@@ -6,7 +6,7 @@ import { getValidSorts, CommonlyUsedHotkey } from '../../_basic';
import { gettext } from '../../utils'; import { gettext } from '../../utils';
import { SortPopover } from '../popover'; 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 [isShowSetter, setShowSetter] = useState(false);
const sorts = useMemo(() => { const sorts = useMemo(() => {
@@ -54,6 +54,7 @@ const SortSetter = ({ target, sorts: propsSorts, readOnly, columns, isNeedSubmit
<SortPopover <SortPopover
isNeedSubmit={isNeedSubmit} isNeedSubmit={isNeedSubmit}
readOnly={readOnly} readOnly={readOnly}
type={type}
target={target} target={target}
columns={columns} columns={columns}
sorts={sorts} sorts={sorts}
@@ -68,10 +69,11 @@ const SortSetter = ({ target, sorts: propsSorts, readOnly, columns, isNeedSubmit
const propTypes = { const propTypes = {
isNeedSubmit: PropTypes.bool,
readOnly: PropTypes.bool, readOnly: PropTypes.bool,
wrapperClass: PropTypes.string, wrapperClass: PropTypes.string,
target: PropTypes.string, target: PropTypes.string,
isNeedSubmit: PropTypes.bool, type: PropTypes.string,
sorts: PropTypes.array, sorts: PropTypes.array,
columns: PropTypes.array, columns: PropTypes.array,
modifySorts: PropTypes.func, modifySorts: PropTypes.func,

View File

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

View File

@@ -55,3 +55,7 @@
.sf-metadata-basic-filters-select .sf-metadata-option-group { .sf-metadata-basic-filters-select .sf-metadata-option-group {
margin-left: 6px; margin-left: 6px;
} }
.filter-group-basic .sf-metadata-filters-list {
min-height: unset;
}

View File

@@ -4,6 +4,7 @@ import { FormGroup, Label } from 'reactstrap';
import { gettext } from '../../../../utils'; import { gettext } from '../../../../utils';
import { PRIVATE_COLUMN_KEY } from '../../../../_basic'; import { PRIVATE_COLUMN_KEY } from '../../../../_basic';
import FileOrFolderFilter from './file-folder-filter'; import FileOrFolderFilter from './file-folder-filter';
import FileTypeFilter from './file-type-filter';
import './index.css'; import './index.css';
@@ -17,27 +18,43 @@ const BasicFilters = ({ readOnly, filters = [], onChange }) => {
onChange(newFilters); onChange(newFilters);
}, [filters, onChange]); }, [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 ( return (
<FormGroup className="filter-group p-4"> <FormGroup className="filter-group-basic filter-group p-4">
<Label className="filter-group-name">{gettext('Basic')}</Label> <Label className="filter-group-name">{gettext('Basic')}</Label>
<div className="filter-group-container"> <div className="filter-group-container">
{filters.map(filter => { <div className="sf-metadata-filters-list">
{filters.map((filter, index) => {
const { column_key, filter_term } = filter; const { column_key, filter_term } = filter;
if (column_key === PRIVATE_COLUMN_KEY.IS_DIR) { if (column_key === PRIVATE_COLUMN_KEY.IS_DIR) {
return ( return (
<FileOrFolderFilter key={column_key} readOnly={readOnly} value={filter_term} onChange={onChangeFileOrFolderFilter} /> <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; return null;
})} })}
</div> </div>
</div>
</FormGroup> </FormGroup>
); );
}; };
BasicFilters.propTypes = { BasicFilters.propTypes = {
readOnly: PropTypes.bool, readOnly: PropTypes.bool,
value: PropTypes.string, filters: PropTypes.array,
columns: PropTypes.array,
onChange: PropTypes.func, onChange: PropTypes.func,
}; };

View File

@@ -5,8 +5,10 @@ import { Button, UncontrolledPopover } from 'reactstrap';
import { CustomizeAddTool, CustomizeSelect, Icon } from '@seafile/sf-metadata-ui-component'; import { CustomizeAddTool, CustomizeSelect, Icon } from '@seafile/sf-metadata-ui-component';
import { import {
COLUMNS_ICON_CONFIG, COLUMNS_ICON_CONFIG,
SORT_COLUMN_OPTIONS, VIEW_SORT_COLUMN_OPTIONS,
VIEW_FIRST_SORT_COLUMN_OPTIONS,
SORT_TYPE, SORT_TYPE,
VIEW_TYPE,
getColumnByKey, getColumnByKey,
} from '../../../_basic'; } from '../../../_basic';
import { execSortsOperation, getDisplaySorts, isSortsEmpty, SORT_OPERATION } from './utils'; import { execSortsOperation, getDisplaySorts, isSortsEmpty, SORT_OPERATION } from './utils';
@@ -28,8 +30,9 @@ const SORT_TYPES = [
const propTypes = { const propTypes = {
readOnly: PropTypes.bool, readOnly: PropTypes.bool,
target: PropTypes.string.isRequired,
isNeedSubmit: PropTypes.bool, isNeedSubmit: PropTypes.bool,
target: PropTypes.string.isRequired,
type: PropTypes.string,
sorts: PropTypes.array, sorts: PropTypes.array,
columns: PropTypes.array.isRequired, columns: PropTypes.array.isRequired,
onSortComponentToggle: PropTypes.func, onSortComponentToggle: PropTypes.func,
@@ -44,8 +47,10 @@ class SortPopover extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { sorts, columns } = this.props; const { sorts, columns, type } = this.props;
this.sortTypeOptions = this.createSortTypeOptions(); 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.columnsOptions = this.createColumnsOptions(columns);
this.state = { this.state = {
sorts: getDisplaySorts(sorts, columns), sorts: getDisplaySorts(sorts, columns),
@@ -154,7 +159,7 @@ class SortPopover extends Component {
}; };
createColumnsOptions = (columns = []) => { 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) => { return sortableColumns.map((column) => {
const { type, name } = column; const { type, name } = column;
return { return {
@@ -189,7 +194,7 @@ class SortPopover extends Component {
renderSortItem = (column, sort, index) => { renderSortItem = (column, sort, index) => {
const { name, type } = column; const { name, type } = column;
const { readOnly } = this.props; const { readOnly, type: viewType } = this.props;
const selectedColumn = { const selectedColumn = {
label: ( label: (
<Fragment> <Fragment>
@@ -205,11 +210,16 @@ class SortPopover extends Component {
label: <span className="select-option-name">{selectedTypeOption?.name || gettext('Up')}</span> 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 ( return (
<div key={'sort-item-' + index} className="sort-item"> <div key={'sort-item-' + index} className="sort-item">
{!readOnly && {!readOnly &&
<div className="delete-sort" onClick={(event) => this.deleteSort(event, index)}> <div className="delete-sort" onClick={!(viewType === VIEW_TYPE.GALLERY && index === 0) ? () => {} : (event) => this.deleteSort(event, index)}>
<Icon iconName="fork-number"/> {!(viewType === VIEW_TYPE.GALLERY && index === 0) && <Icon iconName="fork-number"/>}
</div> </div>
} }
<div className="condition"> <div className="condition">
@@ -218,7 +228,7 @@ class SortPopover extends Component {
readOnly={readOnly} readOnly={readOnly}
value={selectedColumn} value={selectedColumn}
onSelectOption={(value) => this.onSelectColumn(value, index)} onSelectOption={(value) => this.onSelectColumn(value, index)}
options={this.columnsOptions} options={columnsOptions}
searchable={true} searchable={true}
searchPlaceholder={gettext('Search property')} searchPlaceholder={gettext('Search property')}
noOptionsPlaceholder={gettext('No results')} noOptionsPlaceholder={gettext('No results')}

View File

@@ -63,6 +63,7 @@ const ViewToolBar = ({ viewId }) => {
if (!view) return null; if (!view) return null;
const viewType = view.type;
const readOnly = !window.sfMetadataContext.canModifyView(view); const readOnly = !window.sfMetadataContext.canModifyView(view);
return ( return (
@@ -91,10 +92,11 @@ const ViewToolBar = ({ viewId }) => {
target="sf-metadata-sort-popover" target="sf-metadata-sort-popover"
readOnly={readOnly} readOnly={readOnly}
sorts={view.sorts} sorts={view.sorts}
type={viewType}
columns={viewColumns} columns={viewColumns}
modifySorts={modifySorts} modifySorts={modifySorts}
/> />
{view.type !== VIEW_TYPE.GALLERY && ( {viewType !== VIEW_TYPE.GALLERY && (
<GroupbySetter <GroupbySetter
isNeedSubmit={true} isNeedSubmit={true}
wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-groupby" wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-groupby"
@@ -105,7 +107,7 @@ const ViewToolBar = ({ viewId }) => {
modifyGroupbys={modifyGroupbys} modifyGroupbys={modifyGroupbys}
/> />
)} )}
{view.type !== VIEW_TYPE.GALLERY && ( {viewType !== VIEW_TYPE.GALLERY && (
<HideColumnSetter <HideColumnSetter
wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-hide-column" wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-hide-column"
target="sf-metadata-hide-column-popover" target="sf-metadata-hide-column-popover"

View File

@@ -1,4 +1,4 @@
.metadata-gallery-container { .sf-metadata-gallery-container {
height: calc(100vh - 100px); height: calc(100vh - 100px);
margin: 2px; margin: 2px;
position: relative; position: relative;
@@ -61,3 +61,12 @@
.metadata-gallery-grid-image:hover { .metadata-gallery-grid-image:hover {
transform: scale(1.05); transform: scale(1.05);
} }
.sf-metadata-gallery-loading-more {
height: 30px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

View File

@@ -1,116 +1,107 @@
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
import { useMetadata } from '../../../hooks'; import { useMetadata } from '../../../hooks';
import { Utils } from '../../../../../utils/utils'; 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 { 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'; import './index.css';
const BATCH_SIZE = 100;
const CONCURRENCY_LIMIT = 3; const CONCURRENCY_LIMIT = 3;
const IMAGE_GAP = 2;
const Gallery = () => { 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 imageRefs = useRef([]);
const containerRef = useRef(null); 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'); const repoID = window.sfMetadataContext.getSetting('repoID');
useEffect(() => { // Number of images per row
const handleResize = () => { const columns = useMemo(() => {
if (containerRef.current) { return 8 - zoomGear;
setContainerWidth(containerRef.current.offsetWidth); }, [zoomGear]);
}
};
const resizeObserver = new ResizeObserver(handleResize); const imageSize = useMemo(() => {
const currentContainer = containerRef.current; return (containerWidth - columns * 2 - 2) / columns;
}, [containerWidth, columns]);
if (currentContainer) { const groups = useMemo(() => {
resizeObserver.observe(currentContainer); if (isFirstLoading) return [];
} const firstSort = metadata.view.sorts[0];
let init = metadata.rows.reduce((_init, record) => {
return () => { const fileName = record[PRIVATE_COLUMN_KEY.FILE_NAME];
if (currentContainer) { const parentDir = record[PRIVATE_COLUMN_KEY.PARENT_DIR];
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 path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
const date = item[PRIVATE_COLUMN_KEY.FILE_CTIME].split('T')[0]; const date = getDateDisplayString(record[firstSort.column_key], 'YYYY-MM-DD');
const src = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`; const img = {
return {
name: fileName, name: fileName,
url: `${siteRoot}lib/${repoID}/file${path}`, url: `${siteRoot}lib/${repoID}/file${path}`,
src: src, src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`,
date: date, 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(() => { let _groups = [];
return imageItems.reduce((acc, item) => { init.forEach((_init, index) => {
if (!acc[item.date]) { const { children } = _init;
acc[item.date] = []; 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); _groups.push({
return acc; ..._init,
}, {}); top,
}, [imageItems]); height,
});
});
return _groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize]);
const handleScroll = useCallback(() => { const loadMore = useCallback(async () => {
if (visibleItems >= imageItems.length) return; if (isLoadingMore) return;
if (containerRef.current) { if (!metadata.hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current; setLoadingMore(true);
if (scrollTop + clientHeight >= scrollHeight - 10) {
setVisibleItems(prev => Math.min(prev + BATCH_SIZE, imageItems.length));
}
}
}, [visibleItems, imageItems.length]);
useEffect(() => { try {
const container = containerRef.current; await store.loadMore(PER_LOAD_NUMBER);
if (container) { setLoadingMore(false);
container.addEventListener('scroll', handleScroll); } catch (error) {
return () => container.removeEventListener('scroll', handleScroll); const errorMsg = Utils.getErrorMsg(error);
toaster.danger(errorMsg);
setLoadingMore(false);
return;
} }
}, [handleScroll]);
}, [isLoadingMore, metadata, store]);
const loadNextImage = useCallback(() => { const loadNextImage = useCallback(() => {
if (loadingQueue.length === 0 || concurrentLoads >= CONCURRENCY_LIMIT) return; if (loadingQueue.length === 0 || concurrentLoads >= CONCURRENCY_LIMIT) return;
@@ -142,16 +133,65 @@ const Gallery = () => {
}, [loadingQueue, concurrentLoads, loadNextImage]); }, [loadingQueue, concurrentLoads, loadNextImage]);
useEffect(() => { 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 () => { return () => {
container && resizeObserver.unobserve(container);
modifyGalleryZoomGearSubscribe();
// Cleanup image references on unmount // Cleanup image references on unmount
imageRefs.current.forEach(img => { imageRefs.current.forEach(img => {
img.onload = null; img.onload = null;
img.onerror = null; img.onerror = null;
}); });
imageRefs.current = []; 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) => { const addToQueue = (image) => {
setLoadingQueue(prev => [...prev, image]); setLoadingQueue(prev => [...prev, image]);
loadNextImage(); loadNextImage();
@@ -159,24 +199,13 @@ const Gallery = () => {
return ( return (
<div className="sf-metadata-container"> <div className="sf-metadata-container">
<div ref={containerRef} className="metadata-gallery-container"> <div className="sf-metadata-gallery-container" ref={containerRef} onScroll={handleScroll} >
{Object.keys(groupedImages).map(date => ( {!isFirstLoading && (
<div key={date} className="metadata-gallery-date-group"> <>
<div className="metadata-gallery-date-tag">{date}</div> <Main groups={groups} size={imageSize} onLoad={addToQueue} columns={columns} overScan={overScan} gap={IMAGE_GAP} />
<ul className="metadata-gallery-image-list" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}> {isLoadingMore && (<div className="sf-metadata-gallery-loading-more"><CenteredLoading /></div>)}
{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> </div>
</div> </div>
); );

View File

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

View File

@@ -51,5 +51,5 @@ export const EVENT_BUS_TYPE = {
ERROR: 'error', ERROR: 'error',
// gallery // gallery
MODIFY_GALLERY_COLUMNS: 'modify_gallery_columns', MODIFY_GALLERY_ZOOM_GEAR: 'modify_gallery_zoom_gear',
}; };

View File

@@ -17,7 +17,7 @@ export const MetadataProvider = ({
...params ...params
}) => { }) => {
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
const [metadata, setMetadata] = useState({ rows: [], columns: [] }); const [metadata, setMetadata] = useState({ rows: [], columns: [], view: {} });
const storeRef = useRef(null); const storeRef = useRef(null);
const { collaborators } = useCollaborators(); const { collaborators } = useCollaborators();
const { showFirstView, setShowFirstView } = usePropsMetadata(); const { showFirstView, setShowFirstView } = usePropsMetadata();

View File

@@ -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 { class View {
constructor(object, columns) { constructor(object, columns) {
@@ -14,7 +14,7 @@ class View {
this.filters = object.filters || []; this.filters = object.filters || [];
this.filter_conjunction = object.filter_conjunction || 'Or'; 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 // sort
this.sorts = object.sorts || []; this.sorts = object.sorts || [];

View File

@@ -550,6 +550,8 @@ class LibContentView extends React.Component {
content: '', content: '',
viewId: '', viewId: '',
isDirentDetailShow: false isDirentDetailShow: false
}, () => {
this.showDir('/');
}); });
}; };

View File

@@ -101,7 +101,7 @@ class MetadataManage(APIView):
task_id = add_init_metadata_task(params=params) task_id = add_init_metadata_task(params=params)
metadata_view = RepoMetadataViews.objects.filter(repo_id=repo_id).first() metadata_view = RepoMetadataViews.objects.filter(repo_id=repo_id).first()
if not metadata_view: 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: except Exception as e:
logger.error(e) logger.error(e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
@@ -576,6 +576,7 @@ class MetadataViews(APIView):
# Add a metadata view # Add a metadata view
view_name = request.data.get('name') view_name = request.data.get('name')
view_type = request.data.get('type', 'table') view_type = request.data.get('type', 'table')
view_data = request.data.get('data', {})
if not view_name: if not view_name:
error_msg = 'view name is invalid.' error_msg = 'view name is invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg) 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) return api_error(status.HTTP_403_FORBIDDEN, error_msg)
try: 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: except Exception as e:
logger.exception(e) logger.exception(e)
error_msg = 'Internal Server Error' error_msg = 'Internal Server Error'

View File

@@ -62,9 +62,10 @@ class RepoMetadata(models.Model):
class RepoView(object): 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.name = name
self.type = type self.type = type
self.view_data = view_data
self.view_json = {} self.view_json = {}
self.init_view(view_ids) self.init_view(view_ids)
@@ -81,14 +82,18 @@ class RepoView(object):
"hidden_columns": [], "hidden_columns": [],
"type": self.type, "type": self.type,
} }
self.view_json.update(self.view_data)
class RepoMetadataViewsManager(models.Manager): 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() metadata_views = self.filter(repo_id=repo_id).first()
if not metadata_views: 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_json = new_view.view_json
view_id = view_json.get('_id') view_id = view_json.get('_id')
view_details = { view_details = {
@@ -103,7 +108,7 @@ class RepoMetadataViewsManager(models.Manager):
view_details = json.loads(metadata_views.details) view_details = json.loads(metadata_views.details)
view_name = get_no_duplicate_obj_name(view_name, metadata_views.view_names) view_name = get_no_duplicate_obj_name(view_name, metadata_views.view_names)
exist_view_ids = metadata_views.view_ids 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_json = new_view.view_json
view_id = view_json.get('_id') view_id = view_json.get('_id')
view_details['views'].append(view_json) view_details['views'].append(view_json)