Spaces:
Running
Running
import classNames from 'classnames'; | |
import bindAll from 'lodash.bindall'; | |
import PropTypes from 'prop-types'; | |
import React from 'react'; | |
import localforage from 'localforage'; | |
import {defineMessages, injectIntl, intlShape} from 'react-intl'; | |
import LibraryItem from '../../containers/library-item.jsx'; | |
import Modal from '../../containers/modal.jsx'; | |
import Divider from '../divider/divider.jsx'; | |
import Filter from '../filter/filter.jsx'; | |
import TagButton from '../../containers/tag-button.jsx'; | |
import TagCheckbox from '../../containers/tag-checkbox.jsx'; | |
import Spinner from '../spinner/spinner.jsx'; | |
import Separator from '../tw-extension-separator/separator.jsx'; | |
import styles from './library.css'; | |
const messages = defineMessages({ | |
filterPlaceholder: { | |
id: 'gui.library.filterPlaceholder', | |
defaultMessage: 'Search', | |
description: 'Placeholder text for library search field' | |
}, | |
allTag: { | |
id: 'gui.library.allTag', | |
defaultMessage: 'All', | |
description: 'Label for library tag to revert to all items after filtering by tag.' | |
} | |
}); | |
const PM_LIBRARY_API = "https://library.penguinmod.com/"; | |
const ALL_TAG = {tag: 'all', intlLabel: messages.allTag}; | |
const tagListPrefix = []; | |
/** | |
* Returns true if the array includes items from the other array. | |
* @param {Array} array The array to check | |
* @param {Array} from The array with the items that need to be included | |
* @returns {boolean} | |
*/ | |
const arrayIncludesItemsFrom = (array, from) => { | |
if (!Array.isArray(array)) array = []; | |
if (!Array.isArray(from)) from = []; | |
const value = from.every((value) => { | |
return array.indexOf(value) >= 0; | |
}); | |
// console.log(array, from, value); | |
return value; | |
}; | |
class LibraryComponent extends React.Component { | |
constructor (props) { | |
super(props); | |
bindAll(this, [ | |
'handleClose', | |
'handleFilterChange', | |
'handleFilterClear', | |
'handleMouseEnter', | |
'handleMouseLeave', | |
'handlePlayingEnd', | |
'handleSelect', | |
'handleTagClick', | |
'setFilteredDataRef', | |
'loadLibraryData', | |
'loadLibraryFavorites', | |
'waitForLoading', | |
'handleFavoritesUpdate', | |
'createFilteredData', | |
'getFilteredData' | |
]); | |
this.state = { | |
playingItem: null, | |
filterQuery: '', | |
selectedTags: [], | |
favorites: [], | |
collapsed: false, | |
loaded: false, | |
data: props.data | |
}; | |
// used for actor libraries | |
// they have special things like favorited items | |
// the way they load though breaks stuff | |
this.usesSpecialLoading = [ | |
"ExtensionLibrary" | |
]; | |
} | |
loadLibraryData () { | |
return new Promise((resolve) => { | |
if (this.state.data.then) { | |
// If data is a promise, wait for the promise to resolve | |
this.state.data.then(data => { | |
resolve({ key: "data", value: data }); | |
}); | |
} else { | |
// Allow the spinner to display before loading the content | |
setTimeout(() => { | |
const data = this.state.data; | |
resolve({ key: "data", value: data }); | |
}); | |
} | |
}); | |
} | |
async loadLibraryFavorites () { | |
const favorites = await localforage.getItem("pm:favorited_extensions"); | |
return { key: "favorites", value: favorites ? favorites : [] }; | |
} | |
async handleFavoritesUpdate () { | |
const favorites = await localforage.getItem("pm:favorited_extensions"); | |
this.setState({ | |
favorites | |
}); | |
} | |
async waitForLoading (processes) { | |
// we store values in here | |
const packet = {}; | |
for (const process of processes) { | |
// result = { key: "data", value: ... } | |
const result = await process(); | |
packet[result.key] = result.value; | |
} | |
return packet; | |
} | |
componentDidMount() { | |
if (!this.usesSpecialLoading.includes(this.props.actor)) { | |
// regular loading | |
if (this.state.data.then) { | |
// If data is a promise, wait for the promise to resolve | |
this.state.data.then(data => { | |
this.setState({ | |
loaded: true, | |
data | |
}); | |
}); | |
} else { | |
// Allow the spinner to display before loading the content | |
setTimeout(() => { | |
this.setState({ loaded: true }); | |
}); | |
} | |
} | |
if (this.props.setStopHandler) this.props.setStopHandler(this.handlePlayingEnd); | |
if (!this.usesSpecialLoading.includes(this.props.actor)) return; | |
// special loading | |
const spinnerProcesses = [this.loadLibraryData]; | |
// pm: actors can load extra stuff | |
// pm: if we are acting as the extension library, load favorited extensions | |
if (this.props.actor === "ExtensionLibrary") { | |
spinnerProcesses.push(this.loadLibraryFavorites); | |
} | |
// wait for spinner stuff | |
this.waitForLoading(spinnerProcesses).then((packet) => { | |
const data = { loaded: true, ...packet }; | |
this.setState(data); | |
}); | |
} | |
// uncomment this if favorites start exploding the website lol! | |
// componentWillUnmount () { | |
// // pm: clear favorites from.... memory idk | |
// this.setState({ | |
// favorites: [] | |
// }); | |
// } | |
componentDidUpdate (prevProps, prevState) { | |
if (prevState.filterQuery !== this.state.filterQuery || | |
prevState.selectedTags.length !== this.state.selectedTags.length) { | |
this.scrollToTop(); | |
} | |
if (prevProps.data !== this.props.data) { | |
// eslint-disable-next-line react/no-did-update-set-state | |
this.setState({ | |
data: this.props.data | |
}); | |
} | |
} | |
handleSelect (id, event) { | |
if (event.shiftKey !== true) { | |
this.handleClose(); | |
} | |
this.props.onItemSelected(this.getFilteredData()[id]); | |
} | |
handleClose () { | |
this.props.onRequestClose(); | |
} | |
handleTagClick (tag, enabled) { | |
// console.log(tag, enabled); | |
if (this.state.playingItem === null) { | |
this.setState({ | |
filterQuery: '', | |
selectedTags: this.state.selectedTags.concat([tag.toLowerCase()]) | |
}); | |
} else { | |
this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]); | |
this.setState({ | |
filterQuery: '', | |
playingItem: null, | |
selectedTags: this.state.selectedTags.concat([tag.toLowerCase()]) | |
}); | |
} | |
if (!enabled) { | |
const tags = this.state.selectedTags.filter(t => (t !== tag)); | |
this.setState({ | |
selectedTags: tags | |
}); | |
} | |
} | |
handleMouseEnter (id) { | |
// don't restart if mouse over already playing item | |
if (this.props.onItemMouseEnter && this.state.playingItem !== id) { | |
this.props.onItemMouseEnter(this.getFilteredData()[id]); | |
this.setState({ | |
playingItem: id | |
}); | |
} | |
} | |
handleMouseLeave (id) { | |
if (this.props.onItemMouseLeave) { | |
this.props.onItemMouseLeave(this.getFilteredData()[id]); | |
this.setState({ | |
playingItem: null | |
}); | |
} | |
} | |
handlePlayingEnd () { | |
if (this.state.playingItem !== null) { | |
this.setState({ | |
playingItem: null | |
}); | |
} | |
} | |
handleFilterChange (event) { | |
if (this.state.playingItem === null) { | |
this.setState({ | |
filterQuery: event.target.value, | |
selectedTags: [] | |
}); | |
} else { | |
this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]); | |
this.setState({ | |
filterQuery: event.target.value, | |
playingItem: null, | |
selectedTags: [] | |
}); | |
} | |
} | |
handleFilterClear () { | |
this.setState({filterQuery: ''}); | |
} | |
createFilteredData () { | |
if (this.state.selectedTags.length <= 0) { | |
if (!this.state.filterQuery) return this.state.data; | |
return this.state.data.filter(dataItem => ( | |
(dataItem.tags || []) | |
// Second argument to map sets `this` | |
.map(String.prototype.toLowerCase.call, String.prototype.toLowerCase) | |
.concat(dataItem.name ? | |
(typeof dataItem.name === 'string' ? | |
// Use the name if it is a string, else use formatMessage to get the translated name | |
dataItem.name : this.props.intl.formatMessage(dataItem.name.props) | |
).toLowerCase() : | |
null) | |
.join('\n') // unlikely to partially match newlines | |
.indexOf(this.state.filterQuery.toLowerCase()) !== -1 | |
)); | |
} | |
return this.state.data.filter(dataItem => (arrayIncludesItemsFrom( | |
dataItem.tags && | |
dataItem.tags | |
.map(String.prototype.toLowerCase.call, String.prototype.toLowerCase), | |
this.state.selectedTags))); | |
} | |
getFilteredData () { | |
const filtered = this.createFilteredData(); | |
if (this.props.actor !== "ExtensionLibrary") { | |
return filtered; | |
} | |
const final = [].concat( | |
this.state.favorites | |
.filter(item => (typeof item !== "string")) | |
.map(item => ({ ...item, custom: true })) | |
.reverse(), | |
filtered.filter(item => (this.state.favorites.includes(item.extensionId))), | |
filtered.filter(item => (!this.state.favorites.includes(item.extensionId))) | |
).map(item => ({ ...item, custom: typeof item.custom === "boolean" ? item.custom : false })); | |
return final; | |
} | |
scrollToTop () { | |
this.filteredDataRef.scrollTop = 0; | |
} | |
setFilteredDataRef (ref) { | |
this.filteredDataRef = ref; | |
} | |
render () { | |
return ( | |
<Modal | |
fullScreen | |
contentLabel={this.props.title} | |
id={this.props.id} | |
onRequestClose={this.handleClose} | |
> | |
{/* | |
todo: translation support? | |
*/} | |
{this.props.header ? ( | |
<h1 | |
className={classNames( | |
styles.libraryHeader, | |
styles.whiteTextInDarkMode | |
)} | |
> | |
<button | |
style={this.state.collapsed ? { transform: "scaleX(0.65)" } : null} | |
className={classNames(styles.libraryFilterCollapse)} | |
onClick={() => { | |
this.setState({ | |
collapsed: !this.state.collapsed | |
}); | |
}} | |
/> | |
{this.props.header} | |
<p | |
className={classNames(styles.libraryItemCount)} | |
> | |
{this.state.data.length} | |
</p> | |
</h1> | |
) : null} | |
{/* filter bar & stuff */} | |
<div className={classNames(styles.libraryContentWrapper)}> | |
<div | |
className={classNames(styles.libraryFilterBar)} | |
style={this.state.collapsed ? { display: "none" } : null} | |
> | |
{/* | |
todo: translation? | |
*/} | |
<h3 className={classNames(styles.whiteTextInDarkMode)}>Filters</h3> | |
{this.props.filterable && ( | |
<div> | |
<Filter | |
className={classNames( | |
styles.filterBarItem, | |
styles.filter | |
)} | |
filterQuery={this.state.filterQuery} | |
inputClassName={styles.filterInput} | |
placeholderText={this.props.intl.formatMessage(messages.filterPlaceholder)} | |
onChange={this.handleFilterChange} | |
onClear={this.handleFilterClear} | |
/> | |
<Divider className={classNames(styles.filterBarItem, styles.divider)} /> | |
</div> | |
)} | |
{this.props.tags && | |
<div> | |
{tagListPrefix.concat(this.props.tags).map((tagProps, id) => { | |
let onclick = this.handleTagClick; | |
if (tagProps.type === 'divider') { | |
return (<Divider className={classNames(styles.filterBarItem, styles.divider)} />); | |
} | |
if (tagProps.type === 'title') { | |
return (<h3 className={classNames(styles.whiteTextInDarkMode)}>{tagProps.intlLabel}</h3>); | |
} | |
if (tagProps.type === 'subtitle') { | |
return (<h5 className={classNames(styles.whiteTextInDarkMode)}>{tagProps.intlLabel}</h5>); | |
} | |
if (tagProps.type === 'custom') { | |
onclick = () => { | |
const api = {}; | |
api.useTag = this.handleTagClick; | |
api.close = this.handleClose; | |
api.select = (id) => { | |
const items = this.state.data; | |
for (const item of items) { | |
if (item.extensionId === id) { | |
this.handleClose(); | |
this.props.onItemSelected(item); | |
return; | |
}; | |
} | |
}; | |
tagProps.func(api); | |
}; | |
return ( | |
<TagButton | |
active={false} | |
className={classNames( | |
styles.filterBarItem, | |
styles.tagButton, | |
tagProps.className | |
)} | |
key={`tag-button-${id}`} | |
onClick={onclick} | |
{...tagProps} | |
/> | |
); | |
} | |
return ( | |
<div className={classNames(styles.tagCheckboxWrapper)}> | |
<div style={{ width: "90%" }}> | |
<TagCheckbox | |
active={false} | |
key={`tag-button-${id}`} | |
onClick={onclick} | |
{...tagProps} | |
/> | |
</div> | |
<div className={styles.libraryTagCount}> | |
{this.state.loaded && | |
( | |
this.state.data.filter(dataItem => (arrayIncludesItemsFrom( | |
dataItem.tags && | |
dataItem.tags | |
.map(String.prototype.toLowerCase.call, String.prototype.toLowerCase), | |
[tagProps.tag]))).length | |
) | |
} | |
</div> | |
</div> | |
); | |
})} | |
</div> | |
} | |
</div> | |
<div | |
className={classNames(styles.libraryScrollGrid)} | |
ref={this.setFilteredDataRef} | |
> | |
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => ( | |
<LibraryItem | |
bluetoothRequired={dataItem.bluetoothRequired} | |
collaborator={dataItem.collaborator} | |
extDeveloper={dataItem.extDeveloper} | |
credits={dataItem.credits} | |
twDeveloper={dataItem.twDeveloper} | |
eventSubmittor={dataItem.eventSubmittor} | |
customInsetColor={dataItem.customInsetColor} | |
description={dataItem.description} | |
disabled={dataItem.disabled} | |
extensionId={dataItem.extensionId} | |
featured={dataItem.featured} | |
hidden={dataItem.hidden} | |
isNew={dataItem.tags && dataItem.tags.includes("new")} | |
href={dataItem.href} | |
iconMd5={dataItem.costumes ? dataItem.costumes[0].md5ext : dataItem.md5ext} | |
iconRawURL={this.props.actor === "CostumeLibrary" ? `${PM_LIBRARY_API}files/${dataItem.libraryFilePage}` : dataItem.rawURL} | |
overlayURL={dataItem.overlayURL} | |
icons={dataItem.costumes} | |
id={index} | |
_id={dataItem._id} | |
styleForSound={this.props.actor === "SoundLibrary"} | |
soundType={dataItem.soundType} | |
soundLength={dataItem.soundLength} | |
incompatibleWithScratch={dataItem.incompatibleWithScratch} | |
insetIconURL={dataItem.insetIconURL} | |
internetConnectionRequired={dataItem.internetConnectionRequired} | |
isPlaying={this.state.playingItem === index} | |
key={typeof dataItem.name === 'string' ? dataItem.name : dataItem.rawURL} | |
name={dataItem.name} | |
showPlayButton={this.props.showPlayButton} | |
onMouseEnter={this.handleMouseEnter} | |
onMouseLeave={this.handleMouseLeave} | |
onSelect={this.handleSelect} | |
favoritable={this.props.actor === "ExtensionLibrary" && dataItem.extensionId} | |
favorited={this.state.favorites.includes(dataItem.extensionId)} | |
deletable={dataItem.deletable} | |
custom={dataItem.custom} | |
onFavoriteUpdated={() => this.handleFavoritesUpdate()} | |
_unsandboxed={dataItem._unsandboxed} | |
/> | |
)) : ( | |
<div className={styles.spinnerWrapper}> | |
<Spinner | |
large | |
level="primary" | |
/> | |
</div> | |
)} | |
</div> | |
</div> | |
</Modal> | |
); | |
} | |
} | |
LibraryComponent.propTypes = { | |
data: PropTypes.oneOfType([PropTypes.arrayOf( | |
/* eslint-disable react/no-unused-prop-types, lines-around-comment */ | |
// An item in the library | |
PropTypes.shape({ | |
// @todo remove md5/rawURL prop from library, refactor to use storage | |
md5: PropTypes.string, | |
name: PropTypes.oneOfType([ | |
PropTypes.string, | |
PropTypes.node | |
]), | |
rawURL: PropTypes.string | |
}) | |
/* eslint-enable react/no-unused-prop-types, lines-around-comment */ | |
), PropTypes.instanceOf(Promise)]), | |
filterable: PropTypes.bool, | |
id: PropTypes.string.isRequired, | |
intl: intlShape.isRequired, | |
onItemMouseEnter: PropTypes.func, | |
onItemMouseLeave: PropTypes.func, | |
onItemSelected: PropTypes.func, | |
onRequestClose: PropTypes.func, | |
setStopHandler: PropTypes.func, | |
showPlayButton: PropTypes.bool, | |
tags: PropTypes.arrayOf(PropTypes.shape(TagButton.propTypes)), | |
title: PropTypes.string.isRequired | |
}; | |
LibraryComponent.defaultProps = { | |
filterable: true, | |
showPlayButton: false | |
}; | |
export default injectIntl(LibraryComponent); | |