diff --git a/packages/app/constants/settings/account.jsx b/packages/app/constants/settings/account.jsx new file mode 100644 index 00000000..2324a982 --- /dev/null +++ b/packages/app/constants/settings/account.jsx @@ -0,0 +1,103 @@ +import React from "react" +import { User } from "models" + +export default [ + { + "id": "username", + "group": "account.basicInfo", + "type": "Button", + "icon": "AtSign", + "title": "Username", + "description": "Your username is the name you use to log in to your account.", + "props": { + "disabled": true, + "children": "Change username", + }, + }, + { + "id": "fullName", + "group": "account.basicInfo", + "type": "Input", + "icon": "Edit3", + "title": "Name", + "description": "Change your public name", + "props": { + "placeholder": "Enter your name. e.g. John Doe", + }, + "defaultValue": async () => { + const userData = await User.data() + return userData.fullName + }, + "onUpdate": async (value) => { + const selfId = await User.selfUserId() + + const result = window.app.request.post.updateUser({ + _id: selfId, + update: { + fullName: value + } + }) + + if (result) { + return result + } + }, + "extraActions": [ + { + "id": "unset", + "icon": "Delete", + "title": "Unset", + "onClick": async () => { + window.app.request.post.unsetPublicName() + } + } + ], + "debounced": true, + }, + { + "id": "email", + "group": "account.basicInfo", + "type": "Input", + "icon": "Mail", + "title": "Email", + "description": "Change your email address", + "props": { + "placeholder": "Enter your email address", + }, + "defaultValue": async () => { + const userData = await User.data() + return userData.email + }, + "onUpdate": async (value) => { + const selfId = await User.selfUserId() + + const result = window.app.request.post.updateUser({ + _id: selfId, + update: { + email: value + } + }) + + if (result) { + return result + } + }, + "debounced": true, + }, + { + "id": "Avatar", + "group": "account.basicInfo", + "type": "ImageUpload", + "icon": "Image", + "title": "Avatar", + "description": "Change your avatar", + }, + { + "id": "primaryBadge", + "group": "account.basicInfo", + "type": "Select", + "icon": "Tag", + "title": "Primary badge", + "description": "Change your primary badge", + }, +] \ No newline at end of file diff --git a/packages/app/constants/settings.jsx b/packages/app/constants/settings/app.jsx similarity index 91% rename from packages/app/constants/settings.jsx rename to packages/app/constants/settings/app.jsx index 3abf1634..9924605d 100644 --- a/packages/app/constants/settings.jsx +++ b/packages/app/constants/settings/app.jsx @@ -5,6 +5,7 @@ import { Select } from "antd" export default [ { "id": "language", + "storaged": true, "group": "general", "type": "Select", "icon": "MdTranslate", @@ -19,6 +20,7 @@ export default [ }, { "id": "forceMobileMode", + "storaged": true, "group": "general", "type": "Switch", "icon": "MdSmartphone", @@ -28,6 +30,7 @@ export default [ }, { "id": "haptic_feedback", + "storaged": true, "group": "general", "type": "Switch", "icon": "MdVibration", @@ -36,6 +39,7 @@ export default [ }, { "id": "selection_longPress_timeout", + "storaged": true, "group": "general", "type": "Slider", "icon": "MdTimer", @@ -56,6 +60,7 @@ export default [ }, { "id": "notifications_sound", + "storaged": true, "group": "notifications", "type": "Switch", "icon": "MdVolumeUp", @@ -64,6 +69,7 @@ export default [ }, { "id": "notifications_vibrate", + "storaged": true, "group": "notifications", "type": "Switch", "icon": "MdVibration", @@ -73,6 +79,7 @@ export default [ }, { "id": "notifications_sound_volume", + "storaged": true, "group": "notifications", "type": "Slider", "icon": "MdVolumeUp", @@ -87,6 +94,7 @@ export default [ }, { "id": "edit_sidebar", + "storaged": true, "group": "sidebar", "type": "Button", "icon": "Edit", @@ -96,6 +104,7 @@ export default [ }, { "id": "collapseOnLooseFocus", + "storaged": true, "group": "sidebar", "type": "Switch", "icon": "Columns", @@ -105,6 +114,7 @@ export default [ }, { "id": "autoCollapseDelay", + "storaged": true, "group": "sidebar", "type": "Slider", "icon": "Wh", @@ -128,6 +138,7 @@ export default [ }, { "id": "reduceAnimations", + "storaged": true, "group": "aspect", "type": "Switch", "icon": "MdOutlineAnimation", @@ -136,6 +147,7 @@ export default [ }, { "id": "darkMode", + "storaged": true, "group": "aspect", "type": "Switch", "icon": "Moon", @@ -145,11 +157,13 @@ export default [ }, { "id": "primaryColor", + "storaged": true, "group": "aspect", "type": "SliderColorPicker", "title": "Primary color", "description": "Change primary color of the application.", "emitEvent": "modifyTheme", + "reloadValueOnUpdateEvent": "resetTheme", "emissionValueUpdate": (value) => { return { primaryColor: value @@ -158,6 +172,7 @@ export default [ }, { "id": "resetTheme", + "storaged": true, "group": "aspect", "type": "Button", "title": "Reset theme", @@ -165,6 +180,6 @@ export default [ "children": "Default Theme" }, "emitEvent": "resetTheme", - "noStorage": true + "noUpdate": true, } ] \ No newline at end of file diff --git a/packages/app/constants/settingsGroupsDecorator.json b/packages/app/constants/settingsGroupsDecorator.json index 33d706d9..4ae53ad4 100644 --- a/packages/app/constants/settingsGroupsDecorator.json +++ b/packages/app/constants/settingsGroupsDecorator.json @@ -14,5 +14,9 @@ "aspect": { "title": "Aspect", "icon": "Eye" + }, + "account.basicInfo": { + "title": "Basic Information", + "icon": "Info" } } \ No newline at end of file diff --git a/packages/app/src/components/Settings/index.jsx b/packages/app/src/components/Settings/index.jsx index a8458457..ed1eaa49 100644 --- a/packages/app/src/components/Settings/index.jsx +++ b/packages/app/src/components/Settings/index.jsx @@ -2,10 +2,14 @@ import React from "react" import * as antd from "antd" import { SliderPicker } from "react-color" import { Translation } from "react-i18next" +import classnames from "classnames" import config from "config" -import { Icons } from "components/Icons" -import settingList from "schemas/settings" +import { Icons, createIconRender } from "components/Icons" + +import AppSettings from "schemas/settings/app" +import AccountSettings from "schemas/settings/account" + import groupsDecorator from "schemas/settingsGroupsDecorator.json" import { AboutApp } from ".." @@ -23,25 +27,47 @@ const ItemTypes = { SliderColorPicker: SliderPicker, } -export default class SettingsMenu extends React.Component { - state = { - settings: window.app.settings.get() ?? {}, +const SettingItem = (props) => { + let { item } = props + const [loading, setLoading] = React.useState(true) + const [value, setValue] = React.useState(item.defaultValue ?? false) + const [delayedValue, setDelayedValue] = React.useState(null) + + if (!item.type) { + console.error(`Item [${item.id}] has no an type!`) + return null + } + if (typeof ItemTypes[item.type] === "undefined") { + console.error(`Item [${item.id}] has an invalid type: ${item.type}`) + return null } - handleUpdate = (item, update) => { - if (typeof item.id === "undefined") { - console.error("[Settings] Cannot handle update, item has no id") - return false - } + if (typeof item.props === "undefined") { + item.props = {} + } - const currentValue = window.app.settings.get(item.id) + const dispatchUpdate = async (updateValue) => { + if (typeof item.onUpdate === "function") { + const result = await item.onUpdate(updateValue).catch((error) => { + console.error(error) + antd.message.error(error.message) + return false + }) - if (typeof update === "undefined") { - update = !currentValue + if (!result) { + return false + } + updateValue = result + } else { + const storagedValue = await window.app.settings.get(item.id) + + if (typeof updateValue === "undefined") { + updateValue = !storagedValue + } } if (typeof item.emitEvent === "string") { - let emissionPayload = update + let emissionPayload = updateValue if (typeof item.emissionValueUpdate === "function") { emissionPayload = item.emissionValueUpdate(emissionPayload) @@ -50,110 +76,200 @@ export default class SettingsMenu extends React.Component { window.app.eventBus.emit(item.emitEvent, emissionPayload) } - if (!item.noStorage) { - window.app.settings.set(item.id, update) + if (item.noUpdate) { + return false } - this.setState({ settings: { ...this.state.settings, [item.id]: update } }) + if (item.storaged) { + await window.app.settings.set(item.id, updateValue) + } + + if (item.debounced) { + setDelayedValue(null) + } + + setValue(updateValue) } - renderItem = (item) => { - if (!item.type) { - console.error(`Item [${item.id}] has no an type!`) - return null + const onUpdateItem = async (updateValue) => { + setValue(updateValue) + + if (!item.debounced) { + await dispatchUpdate(updateValue) + } else { + setDelayedValue(updateValue) } - if (typeof ItemTypes[item.type] === "undefined") { - console.error(`Item [${item.id}] has an invalid type: ${item.type}`) - return null + } + + const settingInitialization = async () => { + if (item.storaged) { + const storagedValue = window.app.settings.get(item.id) + setValue(storagedValue) } - if (typeof item.props === "undefined") { - item.props = {} + if (typeof item.defaultValue === "function") { + setValue(await item.defaultValue()) } - // fix handlers - switch (item.type.toLowerCase()) { - case "slidercolorpicker": { - item.props.onChange = (color) => { - item.props.color = color.hex - } - item.props.onChangeComplete = (color) => { - this.handleUpdate(item, color.hex) - } - break - } - case "switch": { - item.props.checked = this.state.settings[item.id] - item.props.onClick = (event) => this.handleUpdate(item, event) - break - } - case "select": { - item.props.onChange = (value) => this.handleUpdate(item, value) - item.props.defaultValue = this.state.settings[item.id] - break - } - case "slider":{ - item.props.defaultValue = this.state.settings[item.id] - item.props.onAfterChange = (value) => this.handleUpdate(item, value) - break - } - default: { - if (!item.props.children) { - item.props.children = item.title ?? item.id - } - item.props.value = this.state.settings[item.id] - item.props.onClick = (event) => this.handleUpdate(item, event) - break - } - } - - // TODO: Support async children - // if (typeof item.children === "function") { - - // } - if (typeof item.dependsOn === "object") { const dependsOptionsKeys = Object.keys(item.dependsOn) item.props.disabled = !Boolean(dependsOptionsKeys.every((key) => { + const storagedValue = window.app.settings.get(key) + if (typeof item.dependsOn[key] === "function") { - return item.dependsOn[key](this.state.settings[key]) + return item.dependsOn[key](storagedValue) } - return this.state.settings[key] === item.dependsOn[key] + return storagedValue === item.dependsOn[key] })) } - return ( -
-
-
-

- {Icons[item.icon] ? React.createElement(Icons[item.icon]) : null} - { - t => t(item.title ?? item.id) - } -

-

{ - t => t(item.description) - }

-
-
- {item.experimental && Experimental } -
-
-
- {React.createElement(ItemTypes[item.type], item.props)} -
-
- ) + if (typeof item.listenUpdateValue === "string") { + window.app.eventBus.on(`setting.update.${item.listenUpdateValue}`, (value) => setValue(value)) + } + + if (item.reloadValueOnUpdateEvent) { + window.app.eventBus.on(item.reloadValueOnUpdateEvent, () => { + console.log(`Reloading value for item [${item.id}]`) + settingInitialization() + }) + } + + setLoading(false) } - renderGroup = (key, group) => { + React.useEffect(() => { + settingInitialization() + }, []) + + switch (item.type.toLowerCase()) { + case "slidercolorpicker": { + item.props.onChange = (color) => { + item.props.color = color.hex + } + item.props.onChangeComplete = (color) => { + onUpdateItem(color.hex) + } + + item.props.color = value + + break + } + case "input": { + item.props.defaultValue = value + item.props.onPressEnter = (event) => dispatchUpdate(event.target.value) + item.props.onChange = (event) => onUpdateItem(event.target.value) + break + } + case "switch": { + item.props.checked = value + item.props.onClick = (event) => onUpdateItem(event) + break + } + case "select": { + item.props.onChange = (value) => onUpdateItem(value) + item.props.defaultValue = value + break + } + case "slider": { + item.props.defaultValue = value + item.props.onAfterChange = (value) => onUpdateItem(value) + break + } + default: { + if (!item.props.children) { + item.props.children = item.title ?? item.id + } + item.props.value = item.defaultValue + item.props.onClick = (event) => onUpdateItem(event) + break + } + } + + return
+
+
+
+

+ {Icons[item.icon] ? React.createElement(Icons[item.icon]) : null} + { + t => t(item.title ?? item.id) + } +

+

{ + t => t(item.description) + }

+
+
+ {item.experimental && Experimental } +
+
+ {item.extraActions && +
+ {item.extraActions.map((action, index) => { + return
+ + {action.title} + +
+ })} +
+ } +
+
+
+ {loading ?
Loading...
: React.createElement(ItemTypes[item.type], item.props)} +
+ + {delayedValue &&
+ } + onClick={async () => await dispatchUpdate(value)} + > + Save + +
} +
+
+} + +export default class SettingsMenu extends React.PureComponent { + state = { + transitionActive: false, + activeKey: "app" + } + + handlePageTransition = (key) => { + this.setState({ + transitionActive: true, + }) + + setTimeout(() => { + this.setState({ + activeKey: key + }) + + setTimeout(() => { + this.setState({ + transitionActive: false, + }) + }, 100) + }, 100) + } + + renderSettings = (key, group) => { const fromDecoratorIcon = groupsDecorator[key]?.icon const fromDecoratorTitle = groupsDecorator[key]?.title - return ( + return

{fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null} @@ -162,13 +278,13 @@ export default class SettingsMenu extends React.Component { }

- {group.map((item) => this.renderItem(item))} + {group.map((item) => )}
- ) +
} - generateSettings = (data) => { + generateSettingsGroups = (data) => { let groups = {} data.forEach((item) => { @@ -180,7 +296,7 @@ export default class SettingsMenu extends React.Component { }) return Object.keys(groups).map((groupKey) => { - return this.renderGroup(groupKey, groups[groupKey]) + return this.renderSettings(groupKey, groups[groupKey]) }) } @@ -189,30 +305,78 @@ export default class SettingsMenu extends React.Component { return (
- {this.generateSettings(settingList)} -
-
-
{config.app?.siteName}
-
- - v{window.app.version} - + + + + App + + } + > + {this.generateSettingsGroups(AppSettings)} +
+
+
{config.app?.siteName}
+
+ + v{window.app.version} + +
+
+ + {isDevMode ? : } + {isDevMode ? "development" : "stable"} + +
+
+
+ AboutApp.openModal()}> + + {t => t("about")} + + +
-
- - {isDevMode ? : } - {isDevMode ? "development" : "stable"} - -
-
-
- AboutApp.openModal()}> - - {t => t("about")} - - -
-
+ + + + Account + + } + > + {this.generateSettingsGroups(AccountSettings)} + + + + Security + + } + > + + + + Privacy + + } + > + +
) } diff --git a/packages/app/src/components/Settings/index.less b/packages/app/src/components/Settings/index.less index a09b712d..b9bf23b9 100644 --- a/packages/app/src/components/Settings/index.less +++ b/packages/app/src/components/Settings/index.less @@ -35,38 +35,62 @@ } .header { - display : flex; - align-items: center; - color : var(--background-color-contrast); + display : inline-flex; + flex-direction: row; + align-items : center; + justify-content: space-between; + width: 100%; - h1, - h2, - h3, - h4, - h5, - h6 { - margin: 0; - color : var(--background-color-contrast); + .title { + display : flex; + align-items: center; + color : var(--background-color-contrast); + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + color : var(--background-color-contrast); + } + + p { + font-size: 11px; + color : var(--background-color-contrast); + margin : 0; + } + + >div { + margin-right: 10px; + } } - p { - font-size: 11px; - color : var(--background-color-contrast); - margin : 0; - } + .extraActions { + display: inline-flex; + align-items: center; - >div { - margin-right: 10px; + > div { + margin-right: 10px; + } } } .component { + display : flex; + flex-direction: column; + --ignore-dragger: true; padding : 0 20px; span { color: var(--background-color-contrast); } + + > div { + margin-bottom: 10px; + } } } @@ -106,4 +130,9 @@ } } } + + .ant-tabs-nav-list { + width : 100%; + justify-content: space-evenly; + } } \ No newline at end of file