added tabs to settings

This commit is contained in:
srgooglo 2022-03-11 01:04:47 +01:00
parent 751cb23683
commit dd7524385e
5 changed files with 455 additions and 140 deletions

View File

@ -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",
},
]

View File

@ -5,6 +5,7 @@ import { Select } from "antd"
export default [ export default [
{ {
"id": "language", "id": "language",
"storaged": true,
"group": "general", "group": "general",
"type": "Select", "type": "Select",
"icon": "MdTranslate", "icon": "MdTranslate",
@ -19,6 +20,7 @@ export default [
}, },
{ {
"id": "forceMobileMode", "id": "forceMobileMode",
"storaged": true,
"group": "general", "group": "general",
"type": "Switch", "type": "Switch",
"icon": "MdSmartphone", "icon": "MdSmartphone",
@ -28,6 +30,7 @@ export default [
}, },
{ {
"id": "haptic_feedback", "id": "haptic_feedback",
"storaged": true,
"group": "general", "group": "general",
"type": "Switch", "type": "Switch",
"icon": "MdVibration", "icon": "MdVibration",
@ -36,6 +39,7 @@ export default [
}, },
{ {
"id": "selection_longPress_timeout", "id": "selection_longPress_timeout",
"storaged": true,
"group": "general", "group": "general",
"type": "Slider", "type": "Slider",
"icon": "MdTimer", "icon": "MdTimer",
@ -56,6 +60,7 @@ export default [
}, },
{ {
"id": "notifications_sound", "id": "notifications_sound",
"storaged": true,
"group": "notifications", "group": "notifications",
"type": "Switch", "type": "Switch",
"icon": "MdVolumeUp", "icon": "MdVolumeUp",
@ -64,6 +69,7 @@ export default [
}, },
{ {
"id": "notifications_vibrate", "id": "notifications_vibrate",
"storaged": true,
"group": "notifications", "group": "notifications",
"type": "Switch", "type": "Switch",
"icon": "MdVibration", "icon": "MdVibration",
@ -73,6 +79,7 @@ export default [
}, },
{ {
"id": "notifications_sound_volume", "id": "notifications_sound_volume",
"storaged": true,
"group": "notifications", "group": "notifications",
"type": "Slider", "type": "Slider",
"icon": "MdVolumeUp", "icon": "MdVolumeUp",
@ -87,6 +94,7 @@ export default [
}, },
{ {
"id": "edit_sidebar", "id": "edit_sidebar",
"storaged": true,
"group": "sidebar", "group": "sidebar",
"type": "Button", "type": "Button",
"icon": "Edit", "icon": "Edit",
@ -96,6 +104,7 @@ export default [
}, },
{ {
"id": "collapseOnLooseFocus", "id": "collapseOnLooseFocus",
"storaged": true,
"group": "sidebar", "group": "sidebar",
"type": "Switch", "type": "Switch",
"icon": "Columns", "icon": "Columns",
@ -105,6 +114,7 @@ export default [
}, },
{ {
"id": "autoCollapseDelay", "id": "autoCollapseDelay",
"storaged": true,
"group": "sidebar", "group": "sidebar",
"type": "Slider", "type": "Slider",
"icon": "Wh", "icon": "Wh",
@ -128,6 +138,7 @@ export default [
}, },
{ {
"id": "reduceAnimations", "id": "reduceAnimations",
"storaged": true,
"group": "aspect", "group": "aspect",
"type": "Switch", "type": "Switch",
"icon": "MdOutlineAnimation", "icon": "MdOutlineAnimation",
@ -136,6 +147,7 @@ export default [
}, },
{ {
"id": "darkMode", "id": "darkMode",
"storaged": true,
"group": "aspect", "group": "aspect",
"type": "Switch", "type": "Switch",
"icon": "Moon", "icon": "Moon",
@ -145,11 +157,13 @@ export default [
}, },
{ {
"id": "primaryColor", "id": "primaryColor",
"storaged": true,
"group": "aspect", "group": "aspect",
"type": "SliderColorPicker", "type": "SliderColorPicker",
"title": "Primary color", "title": "Primary color",
"description": "Change primary color of the application.", "description": "Change primary color of the application.",
"emitEvent": "modifyTheme", "emitEvent": "modifyTheme",
"reloadValueOnUpdateEvent": "resetTheme",
"emissionValueUpdate": (value) => { "emissionValueUpdate": (value) => {
return { return {
primaryColor: value primaryColor: value
@ -158,6 +172,7 @@ export default [
}, },
{ {
"id": "resetTheme", "id": "resetTheme",
"storaged": true,
"group": "aspect", "group": "aspect",
"type": "Button", "type": "Button",
"title": "Reset theme", "title": "Reset theme",
@ -165,6 +180,6 @@ export default [
"children": "Default Theme" "children": "Default Theme"
}, },
"emitEvent": "resetTheme", "emitEvent": "resetTheme",
"noStorage": true "noUpdate": true,
} }
] ]

View File

@ -14,5 +14,9 @@
"aspect": { "aspect": {
"title": "Aspect", "title": "Aspect",
"icon": "Eye" "icon": "Eye"
},
"account.basicInfo": {
"title": "Basic Information",
"icon": "Info"
} }
} }

View File

@ -2,10 +2,14 @@ import React from "react"
import * as antd from "antd" import * as antd from "antd"
import { SliderPicker } from "react-color" import { SliderPicker } from "react-color"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
import classnames from "classnames"
import config from "config" import config from "config"
import { Icons } from "components/Icons" import { Icons, createIconRender } from "components/Icons"
import settingList from "schemas/settings"
import AppSettings from "schemas/settings/app"
import AccountSettings from "schemas/settings/account"
import groupsDecorator from "schemas/settingsGroupsDecorator.json" import groupsDecorator from "schemas/settingsGroupsDecorator.json"
import { AboutApp } from ".." import { AboutApp } from ".."
@ -23,25 +27,47 @@ const ItemTypes = {
SliderColorPicker: SliderPicker, SliderColorPicker: SliderPicker,
} }
export default class SettingsMenu extends React.Component { const SettingItem = (props) => {
state = { let { item } = props
settings: window.app.settings.get() ?? {}, 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.props === "undefined") {
if (typeof item.id === "undefined") { item.props = {}
console.error("[Settings] Cannot handle update, item has no id") }
return false
}
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") { if (!result) {
update = !currentValue 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") { if (typeof item.emitEvent === "string") {
let emissionPayload = update let emissionPayload = updateValue
if (typeof item.emissionValueUpdate === "function") { if (typeof item.emissionValueUpdate === "function") {
emissionPayload = item.emissionValueUpdate(emissionPayload) emissionPayload = item.emissionValueUpdate(emissionPayload)
@ -50,110 +76,200 @@ export default class SettingsMenu extends React.Component {
window.app.eventBus.emit(item.emitEvent, emissionPayload) window.app.eventBus.emit(item.emitEvent, emissionPayload)
} }
if (!item.noStorage) { if (item.noUpdate) {
window.app.settings.set(item.id, update) 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) => { const onUpdateItem = async (updateValue) => {
if (!item.type) { setValue(updateValue)
console.error(`Item [${item.id}] has no an type!`)
return null 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") { if (typeof item.defaultValue === "function") {
item.props = {} 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") { if (typeof item.dependsOn === "object") {
const dependsOptionsKeys = Object.keys(item.dependsOn) const dependsOptionsKeys = Object.keys(item.dependsOn)
item.props.disabled = !Boolean(dependsOptionsKeys.every((key) => { item.props.disabled = !Boolean(dependsOptionsKeys.every((key) => {
const storagedValue = window.app.settings.get(key)
if (typeof item.dependsOn[key] === "function") { 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 ( if (typeof item.listenUpdateValue === "string") {
<div key={item.id} className="settingItem"> window.app.eventBus.on(`setting.update.${item.listenUpdateValue}`, (value) => setValue(value))
<div className="header"> }
<div>
<h4> if (item.reloadValueOnUpdateEvent) {
{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null} window.app.eventBus.on(item.reloadValueOnUpdateEvent, () => {
<Translation>{ console.log(`Reloading value for item [${item.id}]`)
t => t(item.title ?? item.id) settingInitialization()
}</Translation> })
</h4> }
<p> <Translation>{
t => t(item.description) setLoading(false)
}</Translation></p>
</div>
<div>
{item.experimental && <antd.Tag> Experimental </antd.Tag>}
</div>
</div>
<div className="component">
{React.createElement(ItemTypes[item.type], item.props)}
</div>
</div>
)
} }
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 <div key={item.id} className="settingItem">
<div className="header">
<div className="title">
<div>
<h4>
{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null}
<Translation>{
t => t(item.title ?? item.id)
}</Translation>
</h4>
<p> <Translation>{
t => t(item.description)
}</Translation></p>
</div>
<div>
{item.experimental && <antd.Tag> Experimental </antd.Tag>}
</div>
</div>
{item.extraActions &&
<div className="extraActions">
{item.extraActions.map((action, index) => {
return <div>
<antd.Button
key={action.id}
id={action.id}
onClick={action.onClick}
icon={action.icon && createIconRender(action.icon)}
type={action.type ?? "round"}
>
{action.title}
</antd.Button>
</div>
})}
</div>
}
</div>
<div className="component">
<div>
{loading ? <div> Loading... </div> : React.createElement(ItemTypes[item.type], item.props)}
</div>
{delayedValue && <div>
<antd.Button
type="round"
icon={<Icons.Save />}
onClick={async () => await dispatchUpdate(value)}
>
Save
</antd.Button>
</div>}
</div>
</div>
}
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 fromDecoratorIcon = groupsDecorator[key]?.icon
const fromDecoratorTitle = groupsDecorator[key]?.title const fromDecoratorTitle = groupsDecorator[key]?.title
return ( return <div className={classnames("fade-opacity-active", { "fade-opacity-leave": this.state.transitionActive })}>
<div key={key} className="group"> <div key={key} className="group">
<h1> <h1>
{fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null} {fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null}
@ -162,13 +278,13 @@ export default class SettingsMenu extends React.Component {
}</Translation> }</Translation>
</h1> </h1>
<div className="content"> <div className="content">
{group.map((item) => this.renderItem(item))} {group.map((item) => <SettingItem item={item} />)}
</div> </div>
</div> </div>
) </div>
} }
generateSettings = (data) => { generateSettingsGroups = (data) => {
let groups = {} let groups = {}
data.forEach((item) => { data.forEach((item) => {
@ -180,7 +296,7 @@ export default class SettingsMenu extends React.Component {
}) })
return Object.keys(groups).map((groupKey) => { 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 ( return (
<div className="settings"> <div className="settings">
{this.generateSettings(settingList)} <antd.Tabs
<div className="footer"> activeKey={this.state.activeKey}
<div> centered
<div>{config.app?.siteName}</div> destroyInactiveTabPane
<div> onTabClick={this.handlePageTransition}
<antd.Tag> >
<Icons.Tag />v{window.app.version} <antd.Tabs.TabPane
</antd.Tag> key="app"
tab={
<span>
<Icons.Command />
App
</span>
}
>
{this.generateSettingsGroups(AppSettings)}
<div className="footer">
<div>
<div>{config.app?.siteName}</div>
<div>
<antd.Tag>
<Icons.Tag />v{window.app.version}
</antd.Tag>
</div>
<div>
<antd.Tag color={isDevMode ? "magenta" : "green"}>
{isDevMode ? <Icons.Triangle /> : <Icons.Box />}
{isDevMode ? "development" : "stable"}
</antd.Tag>
</div>
</div>
<div>
<antd.Button type="link" onClick={() => AboutApp.openModal()}>
<Translation>
{t => t("about")}
</Translation>
</antd.Button>
</div>
</div> </div>
<div> </antd.Tabs.TabPane>
<antd.Tag color={isDevMode ? "magenta" : "green"}> <antd.Tabs.TabPane
{isDevMode ? <Icons.Triangle /> : <Icons.Box />} key="account"
{isDevMode ? "development" : "stable"} tab={
</antd.Tag> <span>
</div> <Icons.User />
</div> Account
<div> </span>
<antd.Button type="link" onClick={() => AboutApp.openModal()}> }
<Translation> >
{t => t("about")} {this.generateSettingsGroups(AccountSettings)}
</Translation> </antd.Tabs.TabPane>
</antd.Button> <antd.Tabs.TabPane
</div> key="security"
</div> tab={
<span>
<Icons.Shield />
Security
</span>
}
>
</antd.Tabs.TabPane>
<antd.Tabs.TabPane
key="privacy"
tab={
<span>
<Icons.Eye />
Privacy
</span>
}
>
</antd.Tabs.TabPane>
</antd.Tabs>
</div> </div>
) )
} }

View File

@ -35,38 +35,62 @@
} }
.header { .header {
display : flex; display : inline-flex;
align-items: center; flex-direction: row;
color : var(--background-color-contrast); align-items : center;
justify-content: space-between;
width: 100%;
h1, .title {
h2, display : flex;
h3, align-items: center;
h4, color : var(--background-color-contrast);
h5,
h6 { h1,
margin: 0; h2,
color : var(--background-color-contrast); 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 { .extraActions {
font-size: 11px; display: inline-flex;
color : var(--background-color-contrast); align-items: center;
margin : 0;
}
>div { > div {
margin-right: 10px; margin-right: 10px;
}
} }
} }
.component { .component {
display : flex;
flex-direction: column;
--ignore-dragger: true; --ignore-dragger: true;
padding : 0 20px; padding : 0 20px;
span { span {
color: var(--background-color-contrast); color: var(--background-color-contrast);
} }
> div {
margin-bottom: 10px;
}
} }
} }
@ -106,4 +130,9 @@
} }
} }
} }
.ant-tabs-nav-list {
width : 100%;
justify-content: space-evenly;
}
} }