UNPKG

@bigfishtv/cockpit

Version:

752 lines (691 loc) 22.3 kB
import PropTypes from 'prop-types' import React, { Component } from 'react' import ReactDOM from 'react-dom' import { createValue } from '@bigfishtv/react-forms' import { connect } from 'react-redux' import { Cell } from 'fixed-data-table' import { get, post } from '../../api/xhrUtils' import debounce from 'lodash/debounce' import isEqual from 'lodash/isEqual' import throttle from 'lodash/throttle' import { Grid, AutoSizer } from 'react-virtualized' import { addFile, getUploader } from '../../api/tankUpload' import { stringContains } from '../../utils/stringUtils' import { showPrompt } from '../../utils/promptUtils' import { modalHandler } from '../modal/ModalHost' import { isCtrlKeyPressed, isShiftKeyPressed } from '../../utils/selectKeyUtils' import { openImageEditModal } from '../../utils/imageEditUtils' import Spinner from '../Spinner' import FileDropzone from '../container/FileDropzone' import AssetFinderSidebarTree from '../asset/AssetFinderSidebarTree' import AssetsToolbar from '../asset/AssetFinderToolbar' import FilterInput from '../input/SearchInput' import Panel from '../container/panel/Panel' import AssetAutoCell from '../asset/AssetAutoCell' import FixedDataTable from '../table/FixedDataTable' import FixedDataTableDateCell from '../table/cell/FixedDataTableDateCell' import AssetEditModal from '../asset/AssetEditModal' import MediaPreviewModal from '../asset/MediaPreviewModal' import Checkbox from '../input/Checkbox' import Button from '../button/Button' const TableCellImage = props => { const asset = props.data[props.rowIndex] return <AssetAutoCell asset={asset} toolbar={false} size="cockpit-tiny" cellSize="small" /> } const TableCellUrl = props => { const url = '/uploads/' + props.data[props.rowIndex]['filename'] return ( <Cell {...props}> <a href={url} target="_blank"> {url} </a> </Cell> ) } const fields = [ { key: 'preview', Cell: TableCellImage, fixed: true, width: 66, }, { key: 'title', width: 350, }, { key: 'title', value: 'URL', Cell: TableCellUrl, width: 350, }, { key: 'kind', value: 'File Type', width: 100, }, { key: 'credit', value: 'Credit', width: 200, }, { key: 'reference_count', value: 'Usage', width: 110, sortType: 'numeric', }, { key: 'modified', value: 'Date Modified', width: 160, Cell: FixedDataTableDateCell, }, { key: 'created', value: 'Date Created', width: 160, Cell: FixedDataTableDateCell, }, ] const minCellWidth = 150 const minCellHeight = 150 const gridSpacing = 10 // should be evenly divisible by 2 /** * This is a monster component that is basically the media gallery. * It contains a tree sidebar for folder selection and has a grid/list view mode for display assets, along with basic filtering of file type and query string. */ @connect(state => ({ ...state.route.props })) export default class AssetFinder extends Component { static propTypes = { /** This defines whether or not the component should use its own logic or rely on callback props for basic functionality */ uncontrolled: PropTypes.bool, /** View mode -- only used if controlled */ viewMode: PropTypes.oneOf(['grid', 'list']), /** Filters array -- only used if controlled */ filters: PropTypes.array, /** Query string -- only used if controlled */ query: PropTypes.string, /** Fields that query string should search */ queryFields: PropTypes.array, /** Called when asset is selected, typically onDoubleClick */ onSelected: PropTypes.func, /** Whether or not uploads are allowed */ allowUploads: PropTypes.bool, /** Whether or not assets are allowed to be selected */ allowSelection: PropTypes.bool, /** Whether or not multiple assets are allowed to be selected */ multiple: PropTypes.bool, /** Whether or not to use sticky selection (sticky being ctrl/shift keys don't need to be held down) */ stickySelect: PropTypes.bool, /** The height to negate for the FixedDataTable list view */ negativeHeight: PropTypes.number, /** Array of asset objects if parent component has newly added/uploaded assets to display */ receivedFiles: PropTypes.array, onSelected: PropTypes.func, onFolderChange: PropTypes.func, onSelectionChange: PropTypes.func, onViewModeChange: PropTypes.func, onAssetTypeChange: PropTypes.func, /** Default tank asset folder id to add to any new uploads */ defaultFolderId: PropTypes.number, assetGetUrl: PropTypes.string, assetUpdateUrl: PropTypes.string, assetMoveUrl: PropTypes.string, assetDeleteUrl: PropTypes.string, folderGetUrl: PropTypes.string, folderAddUrl: PropTypes.string, folderUpdateUrl: PropTypes.string, folderDeleteUrl: PropTypes.string, showUnsortedFolder: PropTypes.bool, } static defaultProps = { uncontrolled: true, allowUploads: true, allowSelection: false, multiple: true, stickySelect: false, viewMode: 'grid', filters: [], assetType: null, query: null, queryFields: ['filename', 'title', 'caption', 'credit'], globalSearch: false, negativeHeight: 225, receivedFiles: [], onSelected: null, onFolderChange: () => console.warn('[AssetFinder] no onFolderChange prop'), onSelectionChange: () => console.warn('[AssetFinder] no onSelectionChange prop'), onViewModeChange: () => console.warn('[AssetFinder] no onViewModeChange prop'), onAssetTypeChange: () => console.warn('[AssetFinder] no onAssetTypeChange prop'), defaultFolderId: null, assetGetUrl: '/tank/assets.json', assetUpdateUrl: '/tank/assets/edit', assetMoveUrl: '/tank/assets/move.json', assetDeleteUrl: '/tank/assets/delete.json', folderGetUrl: '/admin/tank/asset_folders/tree.json', folderAddUrl: '/tank/asset_folders/add.json', folderUpdateUrl: '/tank/asset_folders/edit', folderDeleteUrl: '/tank/asset_folders/delete.json', showUnsortedFolder: true, } constructor(props) { super() this.state = { viewMode: props.viewMode, filters: props.filters, assetType: props.assetType, finalFilters: [], query: props.query, queryFields: props.queryFields, globalSearch: props.globalSearch, currentFolderId: props.currentFolderId, currentFolder: null, allAssets: [], assets: [], selectedAssets: [], contentWidth: 600, loadingAssets: false, errorLoadingAssets: false, } this.lastSelectedAsset = null this.lastSelectedList = [] this.handleResize = debounce(this.handleResize, 200) } componentDidMount() { window.addEventListener('resize', this.handleResize) this.handleResize() if (getUploader()) getUploader().bind('FileUploaded', this.handleFileUploaded, this) if (this.props.globalSearch) { this.doGlobalSearch() } else if (this.props.currentFolderId) { this.getAssetsByFolderId() } } componentWillUnmount() { window.removeEventListener('resize', this.handleResize) } componentWillReceiveProps(nextProps) { Object.keys(nextProps).map(key => { if (key in this.state && !isEqual(nextProps[key], this.state[key])) { if (key === 'currentFolderId') this.setState({ allAssets: [], assets: [] }) this.setState({ [key]: nextProps[key] }, () => { if (key == 'filters') { this.buildFilters(nextProps[key]) } else if (key == 'query') { if (this.state.globalSearch) { this.doGlobalSearch() } else { this.filterAssets() } } else if (key == 'currentFolderId') { this.getAssetsByFolderId() } else if (key === 'globalSearch') { if (this.state.globalSearch) { this.doGlobalSearch() } else { this.getAssetsByFolderId() } } }) } else if (key == 'receivedFiles' && !isEqual(nextProps[key], this.props[key])) { this.handleReceivedFiles(nextProps[key]) } }) } handleResize = () => { const boundingRect = ReactDOM.findDOMNode(this.refs.finderContent).getBoundingClientRect() const contentWidth = boundingRect.width const contentHeight = window.innerHeight - this.props.negativeHeight this.setState({ contentWidth, contentHeight }) } getAssetsByFolderId() { this.setState({ loadingAssets: true, errorLoadingAssets: false, allAssets: [], assets: [] }) const currentFolderId = this.props.currentFolderId || null get({ url: `${this.props.assetGetUrl}?folder_id=${currentFolderId !== null ? currentFolderId : ''}`, subject: 'assets', callback: allAssets => this.setState({ allAssets, loadingAssets: false, errorLoadingAssets: false }, () => { this.buildFilters(this.state.filters) }), callbackError: () => { this.setState({ loadingAssets: false, errorLoadingAssets: true }) console.warn('Failed to load assets') }, }) } doGlobalSearch = throttle(this._doGlobalSearch, 500, { leading: false }) _doGlobalSearch() { const query = (this.state.query || '').trim() if (!query) return this.setState({ loadingAssets: true, errorLoadingAssets: false, allAssets: [], assets: [] }) get({ url: this.props.assetGetUrl, params: { q: query, limit: 100, sort: 'modified', direction: 'desc', }, subject: 'assets', callback: allAssets => this.setState({ allAssets, loadingAssets: false, errorLoadingAssets: false }, () => { // this.buildFilters(this.state.filters) this.filterAssets() }), callbackError: () => { this.setState({ loadingAssets: false, errorLoadingAssets: true }) console.warn('Failed to load assets') }, }) } filterAssets() { const assets = this.state.allAssets.filter(asset => { let filterPass = true for (let filter of this.state.finalFilters) { if (filter.value !== null && filter.property in asset && asset[filter.property] !== filter.value) filterPass = false } let queryPass = false if (!this.state.globalSearch && this.state.query !== null && this.state.query !== '') { for (let queryField of this.state.queryFields) { if (queryField in asset && asset[queryField] !== null && stringContains(asset[queryField], this.state.query)) queryPass = true } } else { queryPass = true } return filterPass && queryPass }) this.setState({ assets }) } buildFilters(prevFilters) { const finalFilters = [...prevFilters] this.setState({ finalFilters }, () => { this.filterAssets() }) } handleGlobalSearchToggle = checked => { if (this.props.onGlobalSearchToggle) { this.props.onGlobalSearchToggle(checked) } console.log('handle', checked) this.setState({ globalSearch: checked, allAssets: [], assets: [] }, () => { // if(this.props.uncontrolled) { // }else{ // } }) } handleFolderChange = currentFolder => { if (!this.props.uncontrolled) { this.props.onFolderChange(currentFolder) } this.setState( { currentFolder, currentFolderId: currentFolder ? currentFolder.id : null, selectedAssets: [], }, () => { if (this.props.uncontrolled) { this.getAssetsByFolderId() } else { this.buildFilters(this.state.filters) } } ) } handleMoveAssetsToFolder = (folderId, assetIds) => { const { selectedAssets, currentFolderId } = this.state const selectedAssetIds = selectedAssets.map(asset => asset.id) if (assetIds.length === 1 && selectedAssetIds.indexOf(assetIds[0]) >= 0) assetIds = selectedAssetIds const affectedAssets = this.state.allAssets.filter(asset => assetIds.indexOf(asset.id) >= 0) const allAssets = this.state.allAssets.map(asset => { if (assetIds.indexOf(asset.id) >= 0) asset.folder_id = folderId return asset }) this.setState({ allAssets }, () => { this.filterAssets() }) const changes = affectedAssets.map(asset => ({ id: asset.id, folder_id: folderId })) post({ url: this.props.assetMoveUrl, successMessage: 'Moved asset(s) successfully', failureMessage: 'Failed to move asset(s)', data: { assets: changes, folder_id: currentFolderId }, callback: allAssets => this.setState({ allAssets }, () => { this.filterAssets() }), }) } // triggered on asset click handleAssetSelect = asset => { const { multiple, allowSelection, stickySelect } = this.props if (!allowSelection) return let selectedAssets = this.state.selectedAssets if (!multiple || (!isCtrlKeyPressed() && !isShiftKeyPressed())) { selectedAssets = selectedAssets.length === 1 && selectedAssets[0].id === asset.id && !stickySelect ? [] : [asset] } else if (isCtrlKeyPressed()) { const exists = selectedAssets.filter(item => item.id === asset.id).length > 0 selectedAssets = exists ? selectedAssets.filter(item => item.id !== asset.id) : [...selectedAssets, asset] } else if (isShiftKeyPressed()) { const { assets } = this.state const lastIndex = assets.indexOf(this.lastSelectedAsset) const nextIndex = assets.indexOf(asset) const lower = Math.min(lastIndex, nextIndex) const upper = Math.max(lastIndex, nextIndex) const lastSelectedListIds = this.lastSelectedList.map(item => item.id) selectedAssets = [ ...this.lastSelectedList, ...assets.filter((item, i) => i >= lower && i <= upper && lastSelectedListIds.indexOf(item.id) < 0), ] } this.lastSelectedAsset = asset this.lastSelectedList = selectedAssets.slice() if (!this.props.uncontrolled) { this.props.onSelectionChange(selectedAssets) } else { this.setState({ selectedAssets }) } } handleAssetEdit(asset) { openImageEditModal(asset, this.handleAssetUpdate) } handleAssetPlay(asset) { modalHandler.add({ Component: MediaPreviewModal, props: { asset } }) } handleAssetDetailsEdit(asset) { modalHandler.add({ Component: AssetEditModal, props: { formValue: createValue({ schema: null, value: { asset }, }), onSave: formValue => this.handleAssetSave(formValue.select('asset').value), onClose: () => {}, // onEdit: () => this.handleAssetEdit(asset), onDelete: callback => this.handleAssetDelete(asset, callback), onUpdate: asset => this.handleAssetUpdate(asset), }, }) } handleAssetSave(asset) { post({ url: this.props.assetUpdateUrl + '/' + asset.id + '.json', data: asset, subject: 'asset', callback: newAsset => this.handleAssetUpdate(newAsset), }) } handleAssetSelection = asset => { const { selectedAssets } = this.state if (this.props.onSelected) { if (selectedAssets.length) this.props.onSelected(selectedAssets) } else { this.handleAssetDetailsEdit(asset) } } handleAssetUpdate = asset => { let found = false let { allAssets } = this.state for (let i = i; i < allAssets.length; i++) { if (allAssets[i].id === asset.id) { allAssets = allAssets.splice(i, 1, [asset]) found = true break } } if (!found) { allAssets = [...allAssets, asset] } this.setState({ allAssets }, () => { this.filterAssets() }) } handleAssetDelete(asset, additionalCallback = () => false) { showPrompt({ title: 'Delete Asset', message: ( <div> <p>Are you sure you want to delete this asset?</p> </div> ), callback: response => { if (response) { const allAssets = this.state.allAssets.filter(item => item.id !== asset.id) this.setState({ allAssets }, () => { this.filterAssets() }) this.deleteAssets([asset.id]) additionalCallback() } }, }) } deleteSelectedAssets = () => { showPrompt({ title: 'Delete Asset', message: ( <div> <p>Are you sure you want to delete selected asset(s)?</p> </div> ), callback: response => { if (response === true) { const selectedIds = this.state.selectedAssets.map(asset => asset.id) const allAssets = this.state.allAssets.filter(item => selectedIds.indexOf(item.id) < 0) this.setState({ allAssets }, () => { this.filterAssets() }) this.deleteAssets(selectedIds) } }, }) } deleteAssets(assetIds) { post({ url: this.props.assetDeleteUrl, data: assetIds, successMessage: 'Successfully deleted asset(s)', failureMessage: 'Failed to delete asset(s)', callback: () => {}, }) } handleReceivedFiles = files => { const { currentFolderId } = this.state for (let file of files) { const fileId = addFile(file, currentFolderId) const allAssets = [ ...this.state.allAssets, { id: fileId, pending: true, folder_id: currentFolderId, }, ] this.setState({ allAssets }, () => { this.filterAssets() }) } } handleFileUploaded = (uploader, file, response) => { if (response.status !== 200) return const returnedAsset = JSON.parse(response.response) const allAssets = this.state.allAssets.map(asset => { if (asset.pending && asset.id === file.id) { return { ...returnedAsset, folder_id: asset.folder_id, } } return asset }) this.setState({ allAssets }, () => { this.filterAssets() }) } render() { const { showUnsortedFolder, folderGetUrl, folderAddUrl, folderUpdateUrl, folderDeleteUrl, onViewModeChange, onAssetTypeChange, } = this.props const { assets, selectedAssets, viewMode, contentWidth, contentHeight, loadingAssets, errorLoadingAssets, assetType, currentFolderId, globalSearch, } = this.state const sidebarProps = { showUnsortedFolder, folderGetUrl, folderAddUrl, folderUpdateUrl, folderDeleteUrl } const selectedIds = selectedAssets.map(asset => asset.id) const panelToolbarProps = { viewMode, assetType, selectedIds, onViewModeChange, onAssetTypeChange, onAssetDelete: this.deleteSelectedAssets, } const query = (this.state.query || '').trim() return ( <div className="finder"> <div className="finder-menu relative"> <AssetFinderSidebarTree defaultSelectedId={this.props.currentFolderId || this.props.defaultFolderId} onFolderChange={this.handleFolderChange} onMoveAssetsToFolder={this.handleMoveAssetsToFolder} {...sidebarProps} /> {globalSearch && <div className="cover" style={{ backgroundColor: 'rgba(255,255,255,0.8)' }} />} </div> <div className="finder-content" ref="finderContent"> <FileDropzone multiple={true} onReceivedFiles={this.handleReceivedFiles} /> <Panel title={ <div className="flex align-items-center margin-right-xsmall"> <div style={{ paddingRight: 10 }}> <Checkbox value={this.state.globalSearch} onChange={this.handleGlobalSearchToggle} text="Global" /> </div> <FilterInput value={this.props.query} onChange={this.props.onQueryChange} /> </div> } {...panelToolbarProps} PanelToolbar={AssetsToolbar}> {loadingAssets && ( <div className="loader-center"> <Spinner spinnerName="circle" /> </div> )} {!loadingAssets && errorLoadingAssets && ( <div className="center"> <p className="text-warning">An error occurred while loading assets!</p> <Button onClick={() => this.getAssetsByFolderId()}>Try again?</Button> </div> )} {!loadingAssets && !errorLoadingAssets && !assets.length && ( <div className="center"> {globalSearch ? ( query ? ( <p>No results found.</p> ) : ( <p>Type in the search box</p> ) ) : currentFolderId ? ( <p>Folder is empty.</p> ) : ( <p>You're all organized. Nice one!</p> )} </div> )} {!loadingAssets && viewMode == 'grid' && ( <AutoSizer> {({ width, height }) => { const innerWidth = width - gridSpacing * 2 const columnCount = Math.floor(innerWidth / minCellWidth) const cellWidth = Math.floor(innerWidth / columnCount) const cellHeight = Math.round((cellWidth / minCellWidth) * minCellHeight) const rowCount = Math.ceil(assets.length / columnCount) if (!rowCount) { return <div /> } return ( <Grid width={width} height={height} columnWidth={({ index }) => index === 0 || index === columnCount - 1 ? cellWidth + gridSpacing / 2 : cellWidth } rowHeight={({ index }) => index === 0 || index === rowCount - 1 ? cellHeight + gridSpacing / 2 : cellHeight } columnCount={columnCount} rowCount={rowCount} overscanRowCount={0} cellRenderer={({ key, columnIndex, rowIndex, style }) => { const asset = assets[rowIndex * columnCount + columnIndex] if (!asset) return <div key={key} /> const selected = selectedAssets.indexOf(asset) >= 0 const innerStyle = { position: 'relative', width: '100%', height: '100%', paddingTop: rowIndex === 0 ? gridSpacing : gridSpacing / 2, paddingBottom: rowIndex === rowCount - 1 ? gridSpacing : gridSpacing / 2, paddingLeft: columnIndex === 0 ? gridSpacing : gridSpacing / 2, paddingRight: columnIndex === columnCount - 1 ? gridSpacing : gridSpacing / 2, } return ( <div key={asset.id} style={style}> <div style={innerStyle}> <AssetAutoCell toolbar={false} asset={asset} cellSize="auto" selected={selected} onEdit={() => this.handleAssetEdit(asset)} onPlay={() => this.handleAssetPlay(asset)} onDetails={() => this.handleAssetDetailsEdit(asset)} onRemove={() => this.handleAssetDelete(asset)} onClick={() => this.handleAssetSelect(asset)} onDoubleClick={() => this.handleAssetSelection(asset)} /> </div> </div> ) }} /> ) }} </AutoSizer> )} {!loadingAssets && viewMode == 'list' && ( <FixedDataTable data={assets} rowHeight={65} fields={fields} tableWidth={contentWidth} tableHeight={contentHeight} selectedIds={selectedIds} onSelect={this.handleAssetSelect} onSelected={this.handleAssetSelection} /> )} </Panel> </div> </div> ) } }