@bigfishtv/cockpit
Version:
752 lines (691 loc) • 22.3 kB
JavaScript
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.
*/
(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>
)
}
}