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 ( {/* todo: translation support? */} {this.props.header ? (

) : null} {/* filter bar & stuff */}
{/* todo: translation? */}

Filters

{this.props.filterable && (
)} {this.props.tags &&
{tagListPrefix.concat(this.props.tags).map((tagProps, id) => { let onclick = this.handleTagClick; if (tagProps.type === 'divider') { return (); } if (tagProps.type === 'title') { return (

{tagProps.intlLabel}

); } if (tagProps.type === 'subtitle') { return (
{tagProps.intlLabel}
); } 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 ( ); } return (
{this.state.loaded && ( this.state.data.filter(dataItem => (arrayIncludesItemsFrom( dataItem.tags && dataItem.tags .map(String.prototype.toLowerCase.call, String.prototype.toLowerCase), [tagProps.tag]))).length ) }
); })}
}
{this.state.loaded ? this.getFilteredData().map((dataItem, index) => (
); } } 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);