soiz1's picture
Upload 2891 files
6bcb42f verified
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);