Restructure store data flow (see comments)

- Moves all `AddonManager` and `Toasts` usage out of `BdWebApi` to prevent circular dependencies.
-Replaces `BdWebApi.installAddon` with `getAddonContents` and an `AddonManager.installAddon`
- Move store-specific strings into `Strings.Store` (rather than `Strings.Addons`).
- TabBar component that's actually accessible. Replaces the previous inaccessible one in the store and `AddonErrorModal`.
- StoreCard is now Pure.
- InstallationModal is now consistent with StoreCard's props terminolgy.
- search.js => searchbar.js for consistent naming.
- Better error handling for everything.
- lint
This commit is contained in:
Tropical 2022-08-06 03:04:03 -04:00
parent 63ef11614a
commit 402d25af32
19 changed files with 314 additions and 276 deletions

View File

@ -259,19 +259,7 @@
"compileError": "Could not be compiled. See console for details.",
"wasUnloaded": "{{name}} was unloaded.",
"blankSlateHeader": "You don't have any {{type}}s!",
"blankSlateMessage": "Download some from the store to get started.",
"installConfirmation": "Do you want to add this to your installed {{type}}s?",
"new": "New",
"likes": "Likes",
"downloads": "Downloads",
"likesAmount": "{{amount}} likes",
"downloadsAmount": "{{amount}} downloads",
"uploadDate": "Uploaded on {{date}}",
"back": "Back",
"next": "Next",
"connectError": "Failed to connect to Store: {{error}}",
"downloadError": "Failed to download {{type}}: {{error}}",
"writeError": "Failed to write {{type}} to disk: {{error}}"
"blankSlateMessage": "Download some from the store to get started."
},
"CustomCSS": {
"confirmationText": "You have unsaved changes to your Custom CSS. Closing this window will lose all those changes.",
@ -280,7 +268,22 @@
"openNative": "Open in System Editor",
"openDetached": "Detach Window",
"settings": "Editor Settings",
"editorTitle": "Custom CSS Editor"
"editorTitle": "Custom CSS Editor",
"writeError": "Failed to write {{type}} to disk."
},
"Store": {
"connectionError": "Failed to connect to Store.",
"connectionErrorMessage": "There was an error connecting to the store, it's possible our website/api is down. Would you like to retry?",
"downloadError": "Failed to download {{type}}.",
"installConfirmation": "Do you want to add this to your installed {{type}}s?",
"back": "Back",
"next": "Next",
"new": "New",
"likes": "Likes",
"likesAmount": "{{amount}} likes",
"downloads": "Downloads",
"downloadsAmount": "{{amount}} downloads",
"uploadDate": "Uploaded on {{date}}"
},
"Emotes": {
"loading": "Loading emotes in the background do not reload.",
@ -318,6 +321,7 @@
"cancel": "Cancel",
"nevermind": "Nevermind",
"close": "Close",
"retry": "Retry",
"name": "Name",
"message": "Message",
"error": "Error",

View File

@ -1,12 +1,12 @@
import {React, WebpackModules, BdWebApi, Strings} from "modules";
import {Web} from "data";
import Builtin from "../../structs/builtin";
import {React, WebpackModules} from "modules";
import PluginManager from "../../modules/pluginmanager";
import ThemeManager from "../../modules/thememanager";
import BdWebApi from "../../modules/bdwebapi";
import StoreCard from "../../ui/settings/addonlist/storecard";
// import openStoreDetail from "../../ui/settings/addonlist/storedetail";
import Modals from "../../ui/modals";
import Toasts from "../../ui/toasts";
import {URL} from "url";
@ -37,7 +37,7 @@ export default new class Store extends Builtin {
this.instead(MessageAccessories.prototype, "renderEmbeds", (thisObject, methodArguments, renderEmbeds) => {
const embeds = Reflect.apply(renderEmbeds, thisObject, methodArguments);
const matchedProtocols = methodArguments[0]?.content.match(PROTOCOL_REGEX)?.map(match => {
const url = new URL(match.replace(/\s+/g, ' ').replaceAll(/[<>]+/g, "").trim());
const url = new URL(match.replace(/\s+/g, " ").replaceAll(/[<>]+/g, "").trim());
if (url.hostname === "addon" && url.protocol === BD_PROTOCOL) return url;
}).filter(m => m != null);
@ -104,7 +104,7 @@ export default new class Store extends Builtin {
event.preventDefault();
window.open((Web.PAGES[addon.type] + (typeof(addonId) === "number" ? "?id=" : "/") + addonId), "_blank");
}
}
};
}
return link;
@ -125,34 +125,40 @@ class EmbeddedStoreCard extends React.Component {
if (data?.id) this.setState({addon: data});
});
}
isInstalled = (filename) => {
return this.manager.isLoaded(filename);
}
get manager() {
return this.state.addon?.type === "theme" ? ThemeManager : PluginManager;
}
async install(id, filename) {
await BdWebApi.getAddonContents(id).then(contents => {
return this.manager.installAddon(contents, filename);
}).catch(err => {
Toasts.error(Strings.Store.downloadError.format({type: this.state.addon.type}), err);
});
}
render() {
const {addon} = this.state;
const {manager} = this;
return [
addon ? React.createElement(StoreCard, {
return addon ? React.createElement(StoreCard, {
...addon,
key: addon.id,
className: "bd-store-card-embedded",
thumbnail: Web.ENDPOINTS.thumbnail(addon.thumbnail_url),
filename: addon.file_name,
releaseDate: new Date(addon.release_date),
isInstalled: this.manager.isLoaded(addon.file_name),
onInstall: () => Modals.showInstallationModal({
...addon,
thumbnail: Web.ENDPOINTS.thumbnail(addon.thumbnail_url),
folder: manager.addonfolder,
installAddon: BdWebApi.installAddon.bind(BdWebApi),
reload: addon.type === "theme" ? ThemeManager.reloadTheme.bind(ThemeManager) : PluginManager.reloadPlugin.bind(PluginManager),
deleteAddon: manager.deleteAddon.bind(manager),
confirmAddonDelete: manager.confirmAddonDelete.bind(manager),
isInstalled: this.isInstalled.bind(this),
className: "bd-store-card-embedded",
// onDetailsView: () => {
// openStoreDetail(addon);
// }
}) : null
]
filename: addon.file_name,
releaseDate: new Date(addon.release_date),
onInstall: () => this.install(addon.id, addon.file_name)
}),
onForceInstall: () => this.install(addon.id, addon.file_name),
onDelete: () => this.manager.confirmAddonDelete(addon.file_name),
onForceDelete: () => this.manager.deleteAddon(addon.file_name)
}) : null;
}
}

View File

@ -267,6 +267,22 @@ export default class AddonManager {
else this.enableAddon(id);
}
installAddon(text, filename, shouldToast = true) {
const enable = (id) => {
const installation = this.getAddon(id);
this.enableAddon(installation);
Events.off(`${this.prefix}-loaded`, enable);
};
fs.writeFileSync(path.resolve(this.addonFolder, filename), text, (error) => {
if (error) {
Logger.stacktrace(this.name, Strings.Addons.writeError.format({type: this.prefix}), error);
if (shouldToast) Toasts.error(Strings.Addons.writeError.format({type: this.prefix}));
}
});
if (!Settings.get("settings", "addons", "autoReload")) this.reloadAddon(filename);
if (Settings.get("settings", "addons", "autoEnable")) Events.on(`${this.prefix}-loaded`, enable);
}
loadNewAddons() {
const files = fs.readdirSync(this.addonFolder);
const removed = this.addonList.filter(t => !files.includes(t.filename)).map(c => c.id);

View File

@ -1,18 +1,10 @@
import {Web} from "data";
import Logger from "common/Logger";
import Utilities from "./utilities";
import Settings from "./settingsmanager";
import ThemeManager from "./thememanager";
import PluginManager from "./pluginmanager";
import Strings from "./strings";
import Events from "./emitter";
import Logger from "common/logger";
import Toasts from "../ui/toasts";
import https from "https";
import path from "path";
import fs from "fs";
const API_CACHE = {plugins: [], themes: [], addon: []};
// const README_CACHE = {plugins: {}, themes: {}};
@ -25,52 +17,15 @@ export default new class BdWebApi {
get pages() {return Web.PAGES;}
get tags() {return Web.TAGS;}
/**
* Fetches an addon by ID and adds writes it to it's respective folder. Enables the addon if the setting is on.
* @param {number} id - The ID of the addon to fetch.
* @param {string} filename - The name of the file that the addon will be written to.
* @param {"theme" | "plugin"} type - The type of the addon (theme or plugin).
* @returns {Promise<Object>}
*/
installAddon(id, filename, type) {
const manager = type === "theme" ? ThemeManager : PluginManager;
const enable = installationId => {
const installation = manager.getAddon(installationId);
if (manager.enableAddon && installation.filename === filename) manager.enableAddon(installation);
Events.off(`${type}-loaded`, enable);
};
return new Promise(resolve => {
https.get(Web.ENDPOINTS.download(id), response => {
const chunks = [];
response.on("data", chunk => chunks.push(chunk));
response.on("end", () => {
const data = chunks.join("");
fs.writeFileSync(path.resolve(manager.addonFolder, filename), data, error => {
if (error) Toasts.show(Strings.Addons.writeError.format({type, error}), {type: "error"});
});
if (!Settings.get("settings", "addons", "autoReload")) type === "theme" ? ThemeManager.reloadTheme(filename) : PluginManager.reloadPlugin(filename);
if (Settings.get("settings", "addons", "autoEnable")) Events.on(`${type}-loaded`, enable);
resolve(data);
});
response.on("error", (error) => {
Logger.error("Addon Store", Strings.Addons.downloadError.format({type, error}));
Toasts.show(Strings.Addons.downloadError.format({type, error}), {type: "error"});
});
});
});
}
/**
* Fetches a list of all addons from the site.
* @param {"themes" | "plugins"} type - The type of the addon (theme or plugin).
* @returns {Promise<Array<Object>>}
*/
getAddons(type) {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
if (API_CACHE[type].length) resolve(API_CACHE[type]);
https.get(Web.ENDPOINTS.store(type), res => {
https.get(Web.ENDPOINTS.store(type), (res) => {
const chunks = [];
res.on("data", chunk => chunks.push(chunk));
@ -85,8 +40,8 @@ export default new class BdWebApi {
});
res.on("error", (error) => {
Logger.error("Addon Store", Strings.Addons.connectError.format({error}));
Toasts.show(Strings.Addons.connectError.format({error}), {type: "error"});
Logger.stacktrace("BdWebApi", Strings.Store.connectionError, error);
reject(error);
});
});
});
@ -98,11 +53,11 @@ export default new class BdWebApi {
* @returns {Promise<Object>}
*/
getAddon(addon) {
return new Promise(resolve => {
return new Promise((resolve, reject) => {
const cacheMatch = API_CACHE.addon.find(a => a[typeof addon === "number" ? "id" : "name"] === addon);
if (cacheMatch) resolve(cacheMatch);
https.get(Web.ENDPOINTS.addon(addon), res => {
https.get(Web.ENDPOINTS.addon(addon), (res) => {
const chunks = [];
res.on("data", chunk => chunks.push(chunk));
@ -117,8 +72,32 @@ export default new class BdWebApi {
});
res.on("error", (error) => {
Logger.error("Addon Store", Strings.Addons.connectError.format({error}));
Toasts.show(Strings.Addons.connectError.format({error}), {type: "error"});
Logger.stacktrace("BdWebApi", Strings.Store.connectionError, error);
reject(error);
});
});
});
}
/**
* Fetches and return's an addon's raw text content.
* @param {number} id - The ID of the addon to fetch.
* @returns {Promise<Object>}
*/
getAddonContents(id) {
return new Promise((resolve, reject) => {
https.get(Web.ENDPOINTS.download(id), (res) => {
const chunks = [];
res.on("data", chunk => chunks.push(chunk));
res.on("end", () => {
const data = chunks.join("");
resolve(data);
});
res.on("error", (error) => {
Logger.stacktrace("BdWebApi", Strings.Store.connectionError, error);
reject(error);
});
});
});

View File

@ -17,4 +17,5 @@ export {default as LocaleManager} from "./localemanager";
export {default as Strings} from "./strings";
export {default as IPC} from "./ipc";
export {default as Logger} from "common/logger";
export {default as DiscordClasses} from "./discordclasses";
export {default as DiscordClasses} from "./discordclasses";
export {default as BdWebApi} from "./bdwebapi";

View File

@ -53,7 +53,7 @@ export default new class PluginManager extends AddonManager {
deleteAddon: this.deleteAddon.bind(this),
confirmAddonDelete: this.confirmAddonDelete.bind(this),
isLoaded: this.isLoaded.bind(this),
prefix: this.prefix
installAddon: this.installAddon.bind(this)
})});
return errors;
}

View File

@ -32,7 +32,7 @@ export default new class ThemeManager extends AddonManager {
deleteAddon: this.deleteAddon.bind(this),
confirmAddonDelete: this.confirmAddonDelete.bind(this),
isLoaded: this.isLoaded.bind(this),
prefix: this.prefix
installAddon: this.installAddon.bind(this)
})});
return errors;
}

View File

@ -1,9 +1,13 @@
.bd-tab-bar {
display: flex;
flex-direction: row;
list-style-type: none;
margin: 0;
padding: 0;
}
.bd-tab-item {
border: none;
justify-content: center;
align-items: center;
text-align: center;
@ -18,6 +22,7 @@
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
background-color: transparent;
color: var(--interactive-normal);
flex-shrink: 0;
font-weight: 500;
@ -46,3 +51,13 @@
.bd-tab-item:active {
background-color: var(--background-modifier-active);
}
.bd-tab-item.disabled {
cursor: default;
background-color: transparent;
color: var(--interactive-muted);
}
.bd-tab-item:focus-visible {
box-shadow: 0 0 0 4px var(--focus-primary);
}

View File

@ -2,6 +2,7 @@ import {React, Strings, WebpackModules, DiscordClasses, Utilities} from "modules
import Extension from "./icons/extension";
import ThemeIcon from "./icons/theme";
import Divider from "./divider";
import TabBar from "./settings/components/tabbar";
const Parser = Object(WebpackModules.getByProps("defaultRules", "parse")).defaultRules;
@ -104,9 +105,7 @@ export default class AddonErrorModal extends React.Component {
return <>
<div className={`bd-error-modal-header ${DiscordClasses.Modal.header} ${DiscordClasses.Modal.separator}`}>
<h4 className={`${DiscordClasses.Titles.defaultColor} ${DiscordClasses.Text.size14} ${DiscordClasses.Titles.h4} ${DiscordClasses.Margins.marginBottom8}`}>{Strings.Modals.addonErrors}</h4>
<div className="bd-tab-bar">
{tabs.map(tab => <div onClick={() => {this.switchToTab(tab.id);}} className={Utilities.joinClassNames("bd-tab-item", tab.id === selectedTab.id && "selected")}>{tab.name}</div>)}
</div>
<TabBar items={tabs.map(({id, name}) => ({value: id, name}))} value={selectedTab.id} onChange={value => this.switchToTab(value)} />
</div>
<div className={`bd-error-modal-content ${DiscordClasses.Modal.content} ${DiscordClasses.Scrollers.thin}`}>
<div className="bd-addon-errors">

View File

@ -18,6 +18,7 @@ export default class InstallationModal extends React.Component {
constructor() {
super(...arguments);
this.onKeyDown = this.onKeyDown.bind(this);
this.state = {
isInstalling: false
};
@ -25,24 +26,23 @@ export default class InstallationModal extends React.Component {
async install(id, filename) {
this.setState({isInstalling: true});
await this.props.installAddon(id, filename, this.props.type);
await this.props.onInstall?.(id, filename, this.props.type);
this.setState({isInstalling: false});
if (typeof this.props.onInstall === "function") this.props.onInstall();
this.props.onClose();
}
onKeyDown = event => {
onKeyDown(event) {
const {key} = event;
if (key === "Escape" || key === "Enter" || key === " ") event.stopPropagation();
if (key === "Escape") this.props.onClose();
if (key === "Enter" || key === " ") {
this.install(this.props.id, this.props.file_name);
this.install(this.props.id, this.props.filename);
}
}
render() {
const {name, id, description, author, release_date, type, version, file_name} = this.props;
const {name, id, description, author, releaseDate, type, version, filename} = this.props;
return <ModalRoot {...this.props} onKeyDown={this.onKeyDown} size="small" className="bd-installation-modal">
<ModalHeader className="bd-installation-header">
@ -55,7 +55,7 @@ export default class InstallationModal extends React.Component {
<ModalContent className="bd-installation-content">
<h5 className={Utilities.joinClassNames("bd-installation-name", DiscordClasses.Text.size16, DiscordClasses.Text.colorHeaderPrimary)}>{name}</h5>
<div className={Utilities.joinClassNames(DiscordClasses.Text.size14, DiscordClasses.Text.colorHeaderSecondary)}>
{Strings.Addons.installConfirmation.format({type})}
{Strings.Store.installConfirmation.format({type})}
</div>
<ul className="bd-installation-info">
<InfoItem icon={<Description aria-label={Strings.Addons.description} />} id="bd-info-description" label={Strings.Addons.description}>
@ -66,12 +66,12 @@ export default class InstallationModal extends React.Component {
{version}
</InfoItem>
<div className="bd-info-divider" role="separator"></div>
<InfoItem icon={<Clock aria-label={Strings.Addons.uploadDate.format({date: new Date(release_date).toLocaleString()})} />} id="bd-info-upload-date" label={Strings.Addons.uploadDate.format({date: new Date(release_date).toLocaleString()})}>
{Strings.Addons.uploadDate.format({date: new Date(release_date).toLocaleString()})}
<InfoItem icon={<Clock aria-label={Strings.Store.uploadDate.format({date: releaseDate.toLocaleString()})} />} id="bd-info-upload-date" label={Strings.Store.uploadDate.format({date: releaseDate.toLocaleString()})}>
{Strings.Store.uploadDate.format({date: releaseDate.toLocaleString()})}
</InfoItem>
<div className="bd-info-divider" role="separator"></div>
<InfoItem icon={<Github aria-label={Strings.Addons.source} />} id="bd-info-source" label={Strings.Addons.source}>
<Anchor href={Web.ENDPOINTS.githubRedirect(id)} target="_blank" rel="noreferrer noopener">{file_name}</Anchor>
<Anchor href={Web.ENDPOINTS.githubRedirect(id)} target="_blank" rel="noreferrer noopener">{filename}</Anchor>
</InfoItem>
<div className="bd-info-divider" role="separator"></div>
<InfoItem icon={<Author aria-label={Strings.Addons.author} />} id="bd-info-author" label={Strings.Addons.uploaded}>
@ -80,7 +80,7 @@ export default class InstallationModal extends React.Component {
</ul>
</ModalContent>
<ModalFooter>
<Button onClick={() => this.install(id, file_name)} color={Button.Colors.GREEN} disabled={this.state.isInstalling}>
<Button onClick={() => this.install(id, filename)} color={Button.Colors.GREEN} disabled={this.state.isInstalling}>
{this.state.isInstalling ? <Spinner type={Spinner.Type.PULSING_ELLIPSIS} /> : (Strings.Modals.install ?? "Install")}
</Button>
</ModalFooter>

View File

@ -110,11 +110,11 @@ export default class Modals {
className: "bd-image-modal"
}), React.createElement(ImageModal, Object.assign({
className: "bd-image-modal-image",
src: src,
src,
width,
height,
placeholder: src,
original: src,
width: width,
height: height,
onClickUntrusted: link => link.openHref(),
renderLinkComponent: () => React.createElement(MaskedLink, props)
}, props)));

View File

@ -4,7 +4,7 @@ import SettingsTitle from "../settings/title";
import ServerCard from "./card";
import EmptyResults from "../blankslates/noresults";
import Connection from "../../structs/psconnection";
import SearchBar from "../settings/components/search";
import SearchBar from "../settings/components/searchbar";
import Previous from "../icons/previous";
import Next from "../icons/next";

View File

@ -4,7 +4,8 @@ import {Web} from "data";
import {shell} from "electron";
import Dropdown from "../components/dropdown";
import SearchBar from "../components/search";
import SearchBar from "../components/searchbar";
import TabBar from "../components/tabbar";
import Divider from "../../divider";
import SettingsTitle from "../title";
import Reload from "../../icons/reload";
@ -36,8 +37,8 @@ const CONTROLS = {
store: {
sortOptions: [
{get label() {return Strings.Addons.name;}, value: "name"},
{get label() {return Strings.Addons.likes;}, value: "likes"},
{get label() {return Strings.Addons.downloads;}, value: "downloads"},
{get label() {return Strings.Store.Likes;}, value: "likes"},
{get label() {return Strings.Store.downloads;}, value: "downloads"},
{get label() {return Strings.Addons.added;}, value: "release_date"}
],
directions: [
@ -86,11 +87,11 @@ const PAGES = {
};
export default class AddonList extends React.Component {
events = [`${this.props.prefix}-loaded`, `${this.props.prefix}-unloaded`];
constructor(props) {
super(props);
this.update = this.update.bind(this);
this.events = [`${this.props.type}-loaded`, `${this.props.type}-unloaded`];
this.state = {
query: "",
selectedTag: "all",
@ -151,7 +152,7 @@ export default class AddonList extends React.Component {
/>;
}
update = () => {this.forceUpdate();}
update() {this.forceUpdate();}
getControlState(control, defaultValue) {
const {type} = this.props;
@ -176,16 +177,6 @@ export default class AddonList extends React.Component {
DataStore.setBDData(id, controls);
}
makeTab({label, selected, onSelect = () => {}}) {
return <div
className={Utilities.joinClassNames("bd-tab-item", {selected})}
role="tab"
aria-disabled="false"
aria-selected={selected}
onClick={onSelect}
>{label}</div>;
}
reload() {
if (this.props.refreshList) this.props.refreshList();
this.forceUpdate();
@ -221,16 +212,16 @@ export default class AddonList extends React.Component {
return <React.Fragment>
<div className="bd-addon-list-title">
{storeEnabled ?
<div className="bd-tab-bar">
{Object.entries(PAGES).map(([id, props]) => {
return this.makeTab({
label: props.label,
selected: this.state.page === id,
onSelect: () => this.setState({page: id})
});
})}
<TabBar
value={this.state.page}
onChange={value => this.setState({ page: value })}
items={Object.entries(PAGES).map(([id, props]) => ({
name: props.label,
value: id
}))}
>
{showReloadIcon && <Reload className="bd-reload" onClick={this.reload.bind(this)} />}
</div>
</TabBar>
: <SettingsTitle key="title" text={this.props.title} otherChildren={showReloadIcon && <Reload className="bd-reload" onClick={this.reload} />} />
}
<div className="bd-addon-list-filters">
@ -295,6 +286,7 @@ export default class AddonList extends React.Component {
refreshList={this.props.refreshList}
isLoaded={this.props.isLoaded}
deleteAddon={this.props.deleteAddon}
installAddon={this.props.installAddon}
editAddon={this.editAddon}
confirmAddonDelete={this.props.confirmAddonDelete}
view={this.viewStyle}

View File

@ -9,17 +9,23 @@ import NoResults from "../../blankslates/noresults";
import EmptyImage from "../../blankslates/emptyimage";
export default class InstalledPage extends React.Component {
constructor() {
super();
this.update = this.update.bind(this);
}
componentDidMount() {
Events.on(`${this.props.prefix}-loaded`, this.update);
Events.on(`${this.props.prefix}-unloaded`, this.update);
Events.on(`${this.props.type}-loaded`, this.update);
Events.on(`${this.props.type}-unloaded`, this.update);
}
componentWillUnmount() {
Events.off(`${this.props.prefix}-loaded`, this.update);
Events.off(`${this.props.prefix}-unloaded`, this.update);
Events.off(`${this.props.type}-loaded`, this.update);
Events.off(`${this.props.type}-unloaded`, this.update);
}
update = () => {
update() {
this.forceUpdate();
}

View File

@ -1,13 +1,12 @@
import {React, Strings, Utilities, WebpackModules, DiscordClasses} from "modules";
import {React, Strings, Utilities, WebpackModules, DiscordClasses, BdWebApi} from "modules";
import {Web} from "data";
import Next from "../../icons/next";
import Previous from "../../icons/previous";
import NoResults from "../../blankslates/noresults";
import StoreCard from "./storecard";
// import openStoreDetail from "./storedetail";
import BdWebApi from "../../../modules/bdwebapi";
import Modals from "../../modals";
import Toasts from "../../toasts";
const Button = WebpackModules.getByProps("DropdownSizes");
const Spinner = WebpackModules.getByDisplayName("Spinner");
@ -16,6 +15,8 @@ export default class StorePage extends React.Component {
constructor(props) {
super(props);
this.matchAddon = this.matchAddon.bind(this);
this.filterTags = this.filterTags.bind(this);
this.state = {
isLoaded: false,
addons: null,
@ -24,22 +25,38 @@ export default class StorePage extends React.Component {
}
componentDidMount() {
this.connect();
}
connect() {
BdWebApi.getAddons(`${this.props.type}s`).then(data => {
this.setState({
isLoaded: true,
addons: data
});
}).catch((err) => Modals.showConfirmationModal(Strings.Store.connectionError, Strings.Store.connectionErrorMessage, {
cancelText: Strings.Modals.close,
confirmText: Strings.Modals.retry,
onConfirm: () => this.connect()
}));
}
async install(id, filename) {
await BdWebApi.getAddonContents(id).then(contents => {
return this.props.installAddon(contents, filename);
}).catch(err => {
Toasts.error(Strings.Store.downloadError.format({ type: this.props.type }), err);
});
}
matchAddon = (addon, query) => {
matchAddon(addon, query) {
let matches = ~addon.name.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
matches = matches || ~addon.author.display_name.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
matches = matches || ~addon.description.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
return matches;
}
filterTags = (addon) => {
filterTags(addon) {
const matches = this.matchAddon(addon, this.props.query);
const tagMatches = this.props.state.selectedTag === "all" || addon.tags.some(tag => tag === this.props.state.selectedTag);
return tagMatches && matches;
@ -68,14 +85,11 @@ export default class StorePage extends React.Component {
return Utilities.splitArray(final, 16);
}
isInstalled = (filename) => {
return this.props.isLoaded(filename);
}
render() {
if (this.props.query !== this.latestSearchQuery || this.props.state.selectedTag !== this.latestSelectedTag) this.setState({selectedPage: 0});
const containerState = this.props.state;
if (this.props.query !== this.latestSearchQuery || containerState.selectedTag !== this.latestSelectedTag) this.setState({selectedPage: 0});
this.latestSearchQuery = this.props.query;
this.latestSelectedTag = this.props.state.selectedTag;
this.latestSelectedTag = containerState.selectedTag;
const addons = this.addons;
const canGoForward = addons && (addons.length > 1 && this.state.selectedPage < addons.length - 1);
const canGoBackward = this.state.selectedPage > 0;
@ -92,19 +106,26 @@ export default class StorePage extends React.Component {
{(this.state.isLoaded && addons?.length && addons[this.state.selectedPage])
? <div className={Utilities.joinClassNames("bd-store-addons", this.props.view + "-view")}>
{addons[this.state.selectedPage].map(addon => {
const thumbnail = Web.ENDPOINTS.thumbnail(addon.thumbnail_url);
return <StoreCard
{...addon}
deleteAddon={this.props.deleteAddon}
installAddon={BdWebApi.installAddon.bind(this)}
thumbnail={Web.ENDPOINTS.thumbnail(addon.thumbnail_url)}
reload={this.props.reload}
confirmAddonDelete={this.props.confirmAddonDelete}
isInstalled={this.isInstalled.bind(this)}
selectedTag={this.props.state.selectedTag}
folder={this.props.folder}
// onDetailsView={() => {
// openStoreDetail(addon);
// }}
key={addon.id}
filename={addon.file_name}
releaseDate={new Date(addon.release_date)}
thumbnail={thumbnail}
selectedTag={containerState.selectedTag}
isInstalled={this.props.isLoaded(addon.file_name)}
onInstall={() => Modals.showInstallationModal({
...addon,
thumbnail,
filename: addon.file_name,
releaseDate: new Date(addon.release_date),
onInstall: () => this.install(addon.id, addon.file_name)
})}
onForceInstall={() => this.install(addon.id, addon.file_name)}
onDelete={() => this.props.confirmAddonDelete(addon.file_name)}
onForceDelete={() => manager.deleteAddon(addon.file_name)}
/>;
})}
</div>
@ -117,7 +138,13 @@ export default class StorePage extends React.Component {
</Button>
<div class={`bd-page-buttons ${DiscordClasses.Scrollers.thin}`}>
{addons.length
? addons.map((_, index) => <div role="button" aria-current="page" tabindex="0" className={Utilities.joinClassNames("bd-page-item bd-page-button", {selected: index === this.state.selectedPage})} onClick={handleSelect(() => index)}>
? addons.map((_, index) => <div
role="button"
aria-current="page"
tabIndex="0"
className={Utilities.joinClassNames("bd-page-item bd-page-button", {selected: index === this.state.selectedPage})}
onClick={handleSelect(() => index)}
>
<span>{index + 1}</span>
</div>)
: null

View File

@ -7,30 +7,22 @@ import Download from "../../icons/download";
const Tooltip = WebpackModules.getByDisplayName("Tooltip");
const Button = WebpackModules.getByProps("DropdownSizes");
export default class StoreCard extends React.Component {
constructor(props) {
super(props);
this.state = {
isInstalled: props.isInstalled(this.props.file_name)
};
}
get monthsAgo() {
const current = new Date();
const release = new Date(this.props.release_date);
let months = (((current.getFullYear() - release.getFullYear()) * 12) - release.getMonth()) + current.getMonth();
return Math.max(months, 0);
}
export default class StoreCard extends React.PureComponent {
abbreviateStat(n) {
if (n < 1e3) return n;
if (n >= 1e3 && n < 1e6) return +(n / 1e3).toFixed(1) + "K";
if (n >= 1e6 && n < 1e9) return +(n / 1e6).toFixed(1) + "M";
}
preview = (event) => {
monthsAgo(date) {
const current = new Date();
const release = new Date(date);
const months = (((current.getFullYear() - release.getFullYear()) * 12) - release.getMonth()) + current.getMonth();
return Math.max(months, 0);
}
preview(event) {
event.preventDefault();
event.stopPropagation();
@ -40,43 +32,32 @@ export default class StoreCard extends React.Component {
});
}
onClick = event => {
const {onDetailsView} = this.props;
if (typeof(onDetailsView) === "function") onDetailsView();
}
onButtonClick = async (event) => {
async onButtonClick(event) {
event.stopPropagation();
event.preventDefault();
if (event.shiftKey) {
if (this.state.isInstalled) {
this.props.deleteAddon(this.props.file_name);
this.setState({isInstalled: false});
} else {
await this.props.installAddon(this.props.id, this.props.file_name, this.props.type);
this.setState({isInstalled: true});
}
} else {
if (this.state.isInstalled) {
this.props.confirmAddonDelete(this.props.file_name, {
onDelete: () => this.setState({isInstalled: false})
});
} else {
Modals.showInstallationModal({...this.props, onInstall: () => {
this.setState({isInstalled: true});
}});
}
}
if (this.props.isInstalled) {
if (event.shiftKey) this.props.onForceDelete?.();
else this.props.onDelete?.();
}
else {
if (event.shiftKey) this.props.onForceInstall?.();
else this.props.onInstall?.();
}
}
render() {
const {name, description, author, selectedTag, tags, likes, downloads, release_date, className} = this.props;
const {name, description, author, tags, selectedTag, likes, downloads, releaseDate, thumbnail, className} = this.props;
return <div className={"bd-store-card" + (className ? ` ${className}` : "")} data-addon-name={name} onClick={this.onClick}>
return <div className={"bd-store-card" + (className ? ` ${className}` : "")} data-addon-name={name}>
<div className="bd-store-card-header">
<div className="bd-store-card-splash">
<img key={this.props.thumbnail} onClick={this.preview} alt={name} src={this.props.thumbnail} />
<img
key={thumbnail}
onClick={this.preview.bind(this)}
alt={name}
src={thumbnail}
/>
</div>
<div className="bd-store-card-icon">
<Tooltip color="primary" position="top" text={author.display_name}>
@ -87,11 +68,11 @@ export default class StoreCard extends React.Component {
</div>
</div>
<div className="bd-store-card-body">
<div class="bd-store-card-title">
<div className="bd-store-card-title">
<h5>{name}</h5>
{this.monthsAgo <= 3
? <Tooltip color="primary" position="top" text={Strings.Addons.uploadDate.format({date: new Date(release_date).toLocaleString()})}>
{props => <span {...props} className="bd-store-card-new-badge">{Strings.Addons.new}</span>}
{this.monthsAgo(releaseDate) <= 3
? <Tooltip color="primary" position="top" text={Strings.Store.uploadDate.format({date: new Date(releaseDate).toLocaleString()})}>
{props => <span {...props} className="bd-store-card-new-badge">{Strings.Store.new}</span>}
</Tooltip>
: null
}
@ -104,7 +85,7 @@ export default class StoreCard extends React.Component {
</div>
<div className="bd-store-card-footer">
<div className="bd-store-card-stats">
<Tooltip color="primary" position="top" text={Strings.Addons.likesAmount.format({amount: likes})}>
<Tooltip color="primary" position="top" text={Strings.Store.likesAmount.format({amount: likes})}>
{props =>
<div {...props} className="bd-store-card-stat">
<Heart />
@ -112,7 +93,7 @@ export default class StoreCard extends React.Component {
</div>
}
</Tooltip>
<Tooltip color="primary" position="top" text={Strings.Addons.downloadsAmount.format({amount: downloads})}>
<Tooltip color="primary" position="top" text={Strings.Store.downloadsAmount.format({amount: downloads})}>
{props =>
<div {...props} className="bd-store-card-stat">
<Download />
@ -122,11 +103,11 @@ export default class StoreCard extends React.Component {
</Tooltip>
</div>
<Button
color={this.state.isInstalled ? Button.Colors.RED : Button.Colors.GREEN}
color={this.props.isInstalled ? Button.Colors.RED : Button.Colors.GREEN}
size={Button.Sizes.SMALL}
onClick={this.onButtonClick}
onClick={this.onButtonClick.bind(this)}
>
{this.state.isInstalled ? Strings.Addons.deleteAddon : Strings.Addons.install}
{this.props.isInstalled ? Strings.Addons.deleteAddon : Strings.Addons.install}
</Button>
</div>
</div>

View File

@ -1,53 +0,0 @@
import {React, WebpackModules} from "modules";
import BdWebApi from "../../../modules/bdwebapi";
const Spinner = WebpackModules.getByDisplayName("Spinner");
const {ScrollerAuto: Scroller} = WebpackModules.getByProps("ScrollerAuto");
const {pushLayer} = WebpackModules.getByProps("pushLayer");
export default function openStoreDetail(addon) {
pushLayer(() => <StoreDetail {...addon} />);
}
export class StoreDetail extends React.Component {
constructor(props) {
super(props);
this.scrollerRef = React.createRef();
}
componentDidMount() {
// dirty hack for customizing layers created by pushLayer
this.scrollerRef.current.parentElement.classList.add("bd-store-details");
}
render() {
return <>
<header class="bd-store-details-title">
</header>
<Scroller ref={this.scrollerRef}>
<header class="bd-store-details-title"></header>
<div class="bd-store-details-content">
<Readme type={this.props.type} addonId={this.props.id} />
<aside class="bd-store-details-sidebar"></aside>
</div>
</Scroller>
</>;
}
}
class Readme extends React.Component {
state = {readme: null};
async componentDidMount() {
const readme = await BdWebApi.getReadme(this.props.type, this.props.addonId);
this.setState({readme});
}
render() {
const {readme} = this.state;
return readme ? <article class="bd-store-details-readme bd-markdown" dangerouslySetInnerHTML={{__html: readme}} /> : <Spinner type={Spinner.Type.SPINNING_CIRCLE} />;
}
}

View File

@ -13,6 +13,7 @@ export default class SearchBar extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.state = {
hasContent: !!props.value,
value: props.value || ""
@ -28,7 +29,7 @@ export default class SearchBar extends React.Component {
};
}
onChange = ({target: {value}}) => {
onChange({target: {value}}) {
this.setState({value, hasContent: !!value});
if (typeof(this.props.onChange) === "function") this.props.onChange(value);
}

View File

@ -0,0 +1,64 @@
import {React, Utilities} from "modules";
export default class TabBar extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.listRef = React.createRef();
this.state = {
selected: this.props.items.find(item => item.value === this.props.value)
};
}
// getDeriveredStateFromProps(props, state) {
// if (props.value !== state.selected) {
// return {selected: props.value};
// }
// return null;
// }
onChange(item) {
this.setState({selected: item});
if (typeof(this.props.onChange) === "function") this.props.onChange(item.value);
}
onKeyDown(event) {
const children = this.listRef.current.children;
const keyMap = {
ArrowRight: Array.from(children).indexOf(event.currentTarget) + 1,
ArrowLeft: Array.from(children).indexOf(event.currentTarget) - 1,
Home: 0,
End: children.length - 1
};
if (keyMap.hasOwnProperty(event.key)) {
event.preventDefault();
children[keyMap[event.key]]?.focus();
}
}
render() {
const {selected} = this.state;
return <ul role="tablist" className="bd-tab-bar" aria-orientation="horizontal" ref={this.listRef}>
{this.props.items.map(item => (
<button
role="tab"
key={item.value}
tabIndex={((item.value === selected.value) && !item.disabled) ? "0" : "-1"}
className={Utilities.joinClassNames("bd-tab-item", {selected: item.value === selected.value}, {disabled: item.disabled})}
onClick={() => this.onChange(item)}
onKeyDown={this.onKeyDown}
aria-disabled={item.disabled}
aria-selected={item.value === selected.value}
>
{item.name}
</button>
))}
{this.props.children}
</ul>;
}
}