Spaces:
Runtime error
Runtime error
| 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} | |
| extensionWarningOnImport={dataItem.extensionWarningOnImport} | |
| 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); | |