From 1f6802548b01a49c1643bfd4612eb373aa00f0f3 Mon Sep 17 00:00:00 2001 From: someCatInTheWorld <162684669+someCatInTheWorld@users.noreply.github.com> Date: Sat, 24 Aug 2024 11:24:42 +1000 Subject: [PATCH] Port PenguinMod's library UI. --- src/components/library/library.css | 94 +++- src/components/library/library.jsx | 430 +++++++++++++----- src/components/tag-checkbox/tag-button.css | 62 +++ src/components/tag-checkbox/tag-button.jsx | 63 +++ .../tw-extension-separator/separator.css | 8 + .../tw-extension-separator/separator.jsx | 8 + src/containers/tag-checkbox.jsx | 32 ++ 7 files changed, 589 insertions(+), 108 deletions(-) create mode 100644 src/components/tag-checkbox/tag-button.css create mode 100644 src/components/tag-checkbox/tag-button.jsx create mode 100644 src/components/tw-extension-separator/separator.css create mode 100644 src/components/tw-extension-separator/separator.jsx create mode 100644 src/containers/tag-checkbox.jsx diff --git a/src/components/library/library.css b/src/components/library/library.css index 51287daa721..e57b02dc83b 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -1,7 +1,62 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +.library-content-wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + height: calc(100% - ($library-header-height)); +} +.library-filter-bar { + width: 342px; + height: calc(100%); + padding: 6px; + margin-left: 4px; + border-radius: 8px; + background: white; + overflow: auto; + border: 2px solid $ui-black-transparent; +} +[theme="dark"] .library-filter-bar { + background: $ui-primary; +} + +.library-header { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 6px; +} +.library-item-count { + opacity: 0.5; + margin-block: 0; + margin-left: 4px; +} +[theme="dark"] .library-header { + color: white; +} +[dir="rtl"] .library-header { + margin-left: initial; + margin-right: 6px; +} +[dir="rtl"] .library-item-count { + margin-left: initial; + margin-right: 4px; +} + +.library-tag-count { + margin-right: 2.5%; + text-align: right; +} +[dir="rtl"] .library-tag-count { + margin-right: initial; + margin-left: 2.5%; + text-align: left; +} + .library-scroll-grid { + width: calc(100% - 346px); display: flex; justify-content: flex-start; align-content: flex-start; @@ -12,7 +67,7 @@ overflow-y: auto; height: auto; padding: 0.5rem; - height: calc(100% - $library-header-height); + height: calc(100%); } .library-scroll-grid.withFilterBar { @@ -31,7 +86,7 @@ } .filter-bar-item { - margin-right: .75rem; + border: 1px solid $ui-black-transparent; } .filter { @@ -48,9 +103,40 @@ width: 18.75rem; } +.tag-checkbox-wrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} +[theme="dark"] .tag-checkbox-wrapper { + color: white; +} + +[theme="dark"] .white-text-in-dark-mode { + color: white; +} + +.library-filter-collapse { + width: 35px; + height: 40px; + transform: scaleX(-0.65); + background: transparent; + border: 0; + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTVweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSItNC41IC02IDEwIDI0IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1MC4yICg1NTA0NykgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+cG9seWdvbi1leHBhbmQ8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZGVmcz48L2RlZnM+CiAgICA8ZyBpZD0icG9seWdvbi1leHBhbmQiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGQ9Ik0zLDZjMCwwLjM2NjM5IC0wLjEzNzQsMC43MzExNiAtMC40MTM4LDEuMDExNjNsLTMuODQzOTksMy45MDIyMmMtMC41NTEyLDAuNTU5MzEgLTEuNDQyNjksMC41NTkzMSAtMS45OTU0OSwwYy0wLjU1MTIsLTAuNTU3NjkgLTAuNTUxMiwtOS4yNzAwMSAwLC05LjgyNzdjMC41NTEyLC0wLjU1OTMxIDEuNDQ0MjksLTAuNTU5MzEgMS45OTU0OSwwbDMuODQzOTksMy45MDA2YzAuMjc2NCwwLjI4MDQ3IDAuNDEzOCwwLjY0NTI0IDAuNDEzOCwxLjAxMzI1IiBpZD0iZXhwYW5kIiBmaWxsPSIjNTc1RTc1Ij48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPgo='); + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} +[theme="dark"] .library-filter-collapse { + filter: brightness(50); +} + .divider { - transform: scaleY(1.39); - height: $library-filter-bar-height; + width: 100%; + border: 0; + border-bottom: 1px dashed $ui-black-transparent; + margin: 6px 0; } .tag-wrapper { diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 1a3c5e0d0f9..c3d652a0493 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -2,6 +2,7 @@ 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'; @@ -9,7 +10,9 @@ 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'; @@ -26,8 +29,26 @@ const messages = defineMessages({ } }); +const PM_LIBRARY_API = "https://library.penguinmod.com/"; + const ALL_TAG = {tag: 'all', intlLabel: messages.allTag}; -const tagListPrefix = [ALL_TAG]; +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) { @@ -41,38 +62,116 @@ class LibraryComponent extends React.Component { 'handlePlayingEnd', 'handleSelect', 'handleTagClick', - 'setFilteredDataRef' + 'setFilteredDataRef', + 'loadLibraryData', + 'loadLibraryFavorites', + 'waitForLoading', + 'handleFavoritesUpdate', + 'createFilteredData', + 'getFilteredData' ]); this.state = { playingItem: null, filterQuery: '', - selectedTag: ALL_TAG.tag, + 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" + ]; } - componentDidMount () { - 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 + + 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(() => { - this.setState({loaded: true}); - }); + } 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.selectedTag !== this.state.selectedTag) { + 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({ @@ -80,25 +179,34 @@ class LibraryComponent extends React.Component { }); } } - handleSelect (id) { - this.handleClose(); + handleSelect (id, event) { + if (event.shiftKey !== true) { + this.handleClose(); + } this.props.onItemSelected(this.getFilteredData()[id]); } handleClose () { this.props.onRequestClose(); } - handleTagClick (tag) { + handleTagClick (tag, enabled) { + // console.log(tag, enabled); if (this.state.playingItem === null) { this.setState({ filterQuery: '', - selectedTag: tag.toLowerCase() + selectedTags: this.state.selectedTags.concat([tag.toLowerCase()]) }); } else { this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]); this.setState({ filterQuery: '', playingItem: null, - selectedTag: tag.toLowerCase() + selectedTags: this.state.selectedTags.concat([tag.toLowerCase()]) + }); + } + if (!enabled) { + const tags = this.state.selectedTags.filter(t => (t !== tag)); + this.setState({ + selectedTags: tags }); } } @@ -130,22 +238,22 @@ class LibraryComponent extends React.Component { if (this.state.playingItem === null) { this.setState({ filterQuery: event.target.value, - selectedTag: ALL_TAG.tag + selectedTags: [] }); } else { this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]); this.setState({ filterQuery: event.target.value, playingItem: null, - selectedTag: ALL_TAG.tag + selectedTags: [] }); } } handleFilterClear () { this.setState({filterQuery: ''}); } - getFilteredData () { - if (this.state.selectedTag === 'all') { + createFilteredData () { + if (this.state.selectedTags.length <= 0) { if (!this.state.filterQuery) return this.state.data; return this.state.data.filter(dataItem => ( (dataItem.tags || []) @@ -161,12 +269,29 @@ class LibraryComponent extends React.Component { .indexOf(this.state.filterQuery.toLowerCase()) !== -1 )); } - return this.state.data.filter(dataItem => ( + return this.state.data.filter(dataItem => (arrayIncludesItemsFrom( dataItem.tags && dataItem.tags - .map(String.prototype.toLowerCase.call, String.prototype.toLowerCase) - .indexOf(this.state.selectedTag) !== -1 - )); + .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; @@ -182,89 +307,186 @@ class LibraryComponent extends React.Component { id={this.props.id} onRequestClose={this.handleClose} > - {(this.props.filterable || this.props.tags) && ( -
- {this.props.filterable && ( - - )} - {this.props.filterable && this.props.tags && ( - + {/* + todo: translation support? + */} + {this.props.header ? ( +

- {tagListPrefix.concat(this.props.tags).map((tagProps, id) => ( - +

+ ) : 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) => ( -
); diff --git a/src/components/tag-checkbox/tag-button.css b/src/components/tag-checkbox/tag-button.css new file mode 100644 index 00000000000..c73f6208aa2 --- /dev/null +++ b/src/components/tag-checkbox/tag-button.css @@ -0,0 +1,62 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.tag-button { + padding: .625rem 1rem; + margin-bottom: 6px; + background: $motion-primary; + border-radius: 1.375rem; + color: $ui-white; + height: $library-filter-bar-height; +} + +.tag-button-icon { + max-width: 1rem; + max-height: 1rem; +} + +.active { + background: $data-primary; +} + +.checkbox-label { + display: flex; + flex-direction: row; + align-items: center; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.checkbox { + -webkit-appearance: none; + appearance: none; + background: transparent; + width: 24px; + height: 24px; + margin: 4px 0; + margin-right: 4px; + border: 2px solid $ui-black-transparent; + border-radius: 4px; + cursor: pointer; +} +.checkbox:focus, +.checkbox:hover { + border: 2px solid $motion-primary; +} +[dir="rtl"] .checkbox { + margin-right: initial; + margin-left: 4px; +} + +.checkbox:checked { + border: 2px solid $motion-primary; + background: $motion-primary; + background-image: url('data:image/svg+xml;base64,PCEtLSBodHRwczovL2FrYXJpY29ucy5jb20vIC0tPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGFyaWEtaGlkZGVuPSJ0cnVlIiBmb2N1c2FibGU9ImZhbHNlIiB3aWR0aD0iMWVtIiBoZWlnaHQ9IjFlbSIgc3R5bGU9Ii1tcy10cmFuc2Zvcm06IHJvdGF0ZSgzNjBkZWcpOyAtd2Via2l0LXRyYW5zZm9ybTogcm90YXRlKDM2MGRlZyk7IHRyYW5zZm9ybTogcm90YXRlKDM2MGRlZyk7IiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBtZWV0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxnIGZpbGw9Im5vbmUiPjxwYXRoIGQ9Ik00IDEybDYgNkwyMCA2IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvZz48L3N2Zz4='); + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} diff --git a/src/components/tag-checkbox/tag-button.jsx b/src/components/tag-checkbox/tag-button.jsx new file mode 100644 index 00000000000..9bc557c512d --- /dev/null +++ b/src/components/tag-checkbox/tag-button.jsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Button from '../button/button.jsx'; + +import styles from './tag-button.css'; + +const TagButtonComponent = ({ + active, + iconClassName, + className, + tag, // eslint-disable-line no-unused-vars + intlLabel, + ...props +}) => ( + + // +); + +TagButtonComponent.propTypes = { + ...Button.propTypes, + active: PropTypes.bool, + intlLabel: PropTypes.oneOfType([ + PropTypes.shape({ + defaultMessage: PropTypes.string, + description: PropTypes.string, + id: PropTypes.string + }), + PropTypes.string + ]).isRequired, + tag: PropTypes.string.isRequired +}; + +TagButtonComponent.defaultProps = { + active: false +}; + +export default TagButtonComponent; diff --git a/src/components/tw-extension-separator/separator.css b/src/components/tw-extension-separator/separator.css new file mode 100644 index 00000000000..0175f33d85e --- /dev/null +++ b/src/components/tw-extension-separator/separator.css @@ -0,0 +1,8 @@ +@import "../../css/colors.css"; + +.separator { + width: 100%; + border: none; + border-top: 2px solid $ui-black-transparent; + margin: 0.5rem 0; +} \ No newline at end of file diff --git a/src/components/tw-extension-separator/separator.jsx b/src/components/tw-extension-separator/separator.jsx new file mode 100644 index 00000000000..b2521ab6952 --- /dev/null +++ b/src/components/tw-extension-separator/separator.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import styles from './separator.css'; + +const Separator = () => ( +
+); + +export default Separator; \ No newline at end of file diff --git a/src/containers/tag-checkbox.jsx b/src/containers/tag-checkbox.jsx new file mode 100644 index 00000000000..34a2097b2c4 --- /dev/null +++ b/src/containers/tag-checkbox.jsx @@ -0,0 +1,32 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import TagCheckboxComponent from '../components/tag-checkbox/tag-button.jsx'; + +class TagCheckbox extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClick' + ]); + } + handleClick (event) { + this.props.onClick(this.props.tag, event.target.checked); + } + render () { + return ( + + ); + } +} + +TagCheckbox.propTypes = { + ...TagCheckboxComponent.propTypes, + onClick: PropTypes.func +}; + +export default TagCheckbox; \ No newline at end of file