Merge pull request #104 from ragestudio/rewrite-settings

Rewrite settings
This commit is contained in:
srgooglo 2023-06-14 00:43:20 +02:00 committed by GitHub
commit 2511bdc4fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1300 additions and 706 deletions

View File

@ -8,7 +8,7 @@
"longPressDelay": 600,
"autoCollapseDelay": 500,
"autoCollapseDelayEnabled": true,
"haptic_feedback": false,
"haptic_feedback": true,
"collapseOnLooseFocus": true,
"style.auto_darkMode": true,
"feed_max_fetch": 20,

View File

@ -21,6 +21,7 @@ export default {
group: "layout",
emitEvent: "app.softReload",
storaged: true,
mobile: false,
},
{
id: "style.reduceAnimations",

View File

@ -1,6 +1,4 @@
import React from "react"
import config from "config"
import { Select } from "antd"
export default {
id: "general",
@ -10,38 +8,42 @@ export default {
order: 0,
settings: [
{
"id": "language",
"storaged": true,
"group": "general",
"component": "Select",
"icon": "MdTranslate",
"title": "Language",
"description": "Choose a language for the application",
"props": {
children: config.i18n.languages.map((language) => {
return <Select.Option value={language.locale}>{language.name}</Select.Option>
id: "language",
storaged: true,
group: "general",
component: "Select",
icon: "MdTranslate",
title: "Language",
description: "Choose a language for the application",
props: {
options: config.i18n.languages.map((language) => {
return {
label: language.name,
value: language.locale
}
})
},
"emitEvent": "changeLanguage"
emitEvent: "changeLanguage"
},
{
"id": "haptic_feedback",
"storaged": true,
"group": "general",
"component": "Switch",
"icon": "MdVibration",
"title": "Haptic Feedback",
"description": "Enable haptic feedback on touch events.",
id: "haptic_feedback",
storaged: true,
group: "general",
component: "Switch",
icon: "MdVibration",
title: "Haptic Feedback",
description: "Enable haptic feedback on touch events.",
desktop: false
},
{
"id": "longPressDelay",
"storaged": true,
"group": "general",
"component": "Slider",
"icon": "MdTimer",
"title": "Long press delay",
"description": "Set the delay before long press trigger is activated.",
"props": {
id: "longPressDelay",
storaged: true,
group: "general",
component: "Slider",
icon: "MdTimer",
title: "Long press delay",
description: "Set the delay before long press trigger is activated.",
props: {
min: 300,
max: 2000,
step: 100,
@ -55,24 +57,29 @@ export default {
}
},
{
"id": "clear_internal_storage",
"storaged": false,
"group": "general",
"component": "Button",
"icon": "MdDelete",
"title": "Clear internal storage",
"description": "Clear all the data stored in the internal storage, including your current session. It will not affect the data stored in the cloud.",
"emitEvent": "app.clearInternalStorage"
id: "clear_internal_storage",
storaged: false,
group: "general",
component: "Button",
icon: "MdDelete",
title: "Clear internal storage",
description: "Clear all the data stored in the internal storage, including your current session. It will not affect the data stored in the cloud.",
emitEvent: "app.clearInternalStorage",
props: {
danger: true,
children: "Clear"
},
},
{
"id": "low_performance_mode",
"storaged": true,
"group": "general",
"component": "Switch",
"icon": "MdSlowMotionVideo",
"title": "Low performance mode",
"description": "Enable low performance mode to reduce the memory usage and improve the performance in low-end devices. This will disable some animations and other decorative features.",
"emitEvent": "app.lowPerformanceMode",
id: "low_performance_mode",
storaged: true,
group: "general",
component: "Switch",
icon: "MdSlowMotionVideo",
title: "Low performance mode",
description: "Enable low performance mode to reduce the memory usage and improve the performance in low-end devices. This will disable some animations and other decorative features.",
emitEvent: "app.lowPerformanceMode",
experimental: true,
disabled: true,
},
{
@ -83,6 +90,7 @@ export default {
icon: "MdVolumeUp",
title: "UI effects",
description: "Enable the UI effects.",
mobile: false,
},
{
id: "ui.general_volume",
@ -100,64 +108,68 @@ export default {
max: 100,
step: 10,
},
emitEvent: "change:app.general_ui_volume"
emitEvent: "change:app.general_ui_volume",
mobile: false,
},
{
"id": "notifications_sound",
"storaged": true,
"group": "notifications",
"component": "Switch",
"icon": "MdVolumeUp",
"title": "Notifications Sound",
"description": "Play a sound when a notification is received.",
id: "notifications_sound",
storaged: true,
group: "notifications",
component: "Switch",
icon: "MdVolumeUp",
title: "Notifications Sound",
description: "Play a sound when a notification is received.",
},
{
"id": "notifications_vibrate",
"storaged": true,
"group": "notifications",
"component": "Switch",
"icon": "MdVibration",
"title": "Vibration",
"description": "Vibrate the device when a notification is received.",
"emitEvent": "changeNotificationsVibrate"
id: "notifications_vibrate",
storaged: true,
group: "notifications",
component: "Switch",
icon: "MdVibration",
title: "Vibration",
description: "Vibrate the device when a notification is received.",
emitEvent: "changeNotificationsVibrate",
desktop: false,
},
{
"id": "notifications_sound_volume",
"storaged": true,
"group": "notifications",
"component": "Slider",
"icon": "MdVolumeUp",
"title": "Sound Volume",
"description": "Set the volume of the sound when a notification is received.",
"props": {
id: "notifications_sound_volume",
storaged: true,
group: "notifications",
component: "Slider",
icon: "MdVolumeUp",
title: "Sound Volume",
description: "Set the volume of the sound when a notification is received.",
props: {
tipFormatter: (value) => {
return `${value}%`
}
},
"emitEvent": "changeNotificationsSoundVolume"
emitEvent: "changeNotificationsSoundVolume",
mobile: false,
},
{
"id": "collapseOnLooseFocus",
"storaged": true,
"group": "sidebar",
"component": "Switch",
"icon": "Columns",
"title": "Auto Collapse",
"description": "Collapse the sidebar when loose focus",
"emitEvent": "settingChanged.sidebar_collapse",
id: "collapseOnLooseFocus",
storaged: true,
group: "sidebar",
component: "Switch",
icon: "Columns",
title: "Auto Collapse",
description: "Collapse the sidebar when loose focus",
emitEvent: "settingChanged.sidebar_collapse",
mobile: false,
},
{
"id": "autoCollapseDelay",
"storaged": true,
"group": "sidebar",
"component": "Slider",
"icon": "MdTimer",
"dependsOn": {
id: "autoCollapseDelay",
storaged: true,
group: "sidebar",
component: "Slider",
icon: "MdTimer",
dependsOn: {
"collapseOnLooseFocus": true
},
"title": "Auto Collapse timeout",
"description": "Set the delay before the sidebar is collapsed",
"props": {
title: "Auto Collapse timeout",
description: "Set the delay before the sidebar is collapsed",
props: {
min: 0,
max: 2000,
step: 100,
@ -168,41 +180,21 @@ export default {
1500: "1.5s",
2000: "2s",
}
}
},
mobile: false,
},
{
"id": "feed_max_fetch",
"title": "Fetch max items",
"description": "Set the maximum number of items to load per fetch in the feed list",
"component": "Slider",
"icon": "MdFormatListNumbered",
"group": "posts",
"props": {
id: "feed_max_fetch",
title: "Fetch max items",
description: "Set the maximum number of items to load per fetch in the feed list",
component: "Slider",
icon: "MdFormatListNumbered",
group: "posts",
props: {
min: 5,
max: 50,
},
"storaged": true,
},
{
"id": "postCard_carrusel_auto",
"title": "Post autoplay",
"description": "Automatically play the post medias when the post has multiple medias",
"component": "Switch",
"icon": "MdPhotoCameraBack",
"group": "posts",
"storaged": true,
"emitEvent": "router.forceUpdate",
"disabled": true
},
{
"id": "postCard_expansible_actions",
"title": "Expansible actions",
"description": "Automatically show or hide the actions bar",
"component": "Switch",
"icon": "MdCallToAction",
"group": "posts",
"storaged": true,
"emitEvent": "router.forceUpdate"
storaged: true,
},
]
}

View File

@ -1,26 +1,139 @@
const settingsPaths = import.meta.glob("/constants/settings/*/index.jsx")
async function composeSettingsByGroups() {
console.time("load settings")
export default async () => {
const settings = {}
/* @vite-ignore */
let _settings = import.meta.glob("/constants/settings/*/index.jsx")
for (const [key, value] of Object.entries(settingsPaths)) {
const path = key.split("/").slice(-2)
_settings = Object.entries(_settings).map(([route, moduleFile]) => {
const path = route.split("/").slice(-2)
const name = path[0]
if (name === "components" || name === "index") {
continue
return null
}
if (!settings[name]) {
settings[name] = {}
return moduleFile
})
_settings = _settings.filter((moduleFile) => moduleFile)
_settings = await Promise.all(_settings.map((moduleFile) => moduleFile()))
_settings = _settings.map((moduleFile) => {
return moduleFile.default || moduleFile
})
_settings = _settings.sort((a, b) => {
if (a.group === "bottom") {
return 1
}
let setting = await value()
if (b.group === "bottom") {
return -1
}
setting = setting.default || setting
return 0
})
settings[name] = setting
_settings = _settings.reduce((acc, settingModule) => {
if (typeof acc[settingModule.group] !== "object") {
acc[settingModule.group] = []
}
acc[settingModule.group].push(settingModule)
return acc
}, {})
_settings = Object.entries(_settings).map(([group, groupModule]) => {
// filter setting by platform
groupModule = groupModule.map((subGroup) => {
if (Array.isArray(subGroup.settings)) {
subGroup.settings = subGroup.settings.filter((setting) => {
if (!app.isMobile && setting.desktop === false) {
return false
}
if (app.isMobile && setting.mobile === false) {
return false
}
return true
})
}
return subGroup
})
return {
group,
groupModule: groupModule
}
})
// order groups
_settings = _settings.map((group) => {
group.groupModule = group.groupModule.sort((a, b) => {
if (typeof a.order === undefined) {
// find index
a.order = group.groupModule.indexOf(a)
}
if (typeof b.order === undefined) {
// find index
b.order = group.groupModule.indexOf(b)
}
return a.order - b.order
})
return group
})
console.timeEnd("load settings")
return _settings
}
function composeTabsFromGroups(settingsGroups) {
return settingsGroups.reduce((acc, entry) => {
entry.groupModule.forEach((item) => {
if (item.id) {
acc[item.id] = item
}
})
return acc
}, {})
}
function composeGroupsFromSettingsTab(settings) {
if (!Array.isArray(settings)) {
console.error("settings is not an array")
return []
}
return settings
return settings.reduce((acc, setting) => {
if (setting.group) {
if (typeof acc[setting.group] === "undefined") {
acc[setting.group] = []
}
acc[setting.group].push(setting)
}
return acc
}, {})
}
const composedSettingsByGroups = await composeSettingsByGroups()
const composedTabs = composeTabsFromGroups(composedSettingsByGroups)
export {
composedSettingsByGroups,
composedTabs,
composeSettingsByGroups,
composeTabsFromGroups,
composeGroupsFromSettingsTab,
}

View File

@ -92,6 +92,33 @@ export default {
"player.compressor": true
},
storaged: false,
},
{
id: "player.gain",
title: "Gain",
icon: "MdGraphicEq",
group: "general",
description: "Adjust gain for audio output",
component: "Slider",
props: {
min: 1,
max: 2,
step: 0.1,
marks: {
1: "Off",
1.5: "50%",
2: "100%"
}
},
defaultValue: () => {
return app.cores.player.gain.values().gain
},
onUpdate: (value) => {
app.cores.player.gain.modifyValues({
gain: value
})
},
storaged: false,
}
]
}

View File

@ -51,7 +51,7 @@ export default {
})
if (result) {
return result
return value
}
},
extraActions: [
@ -89,7 +89,7 @@ export default {
})
if (result) {
return result
return value
}
},
"debounced": true,
@ -105,6 +105,7 @@ export default {
UploadButton
],
"defaultValue": (ctx) => {
console.log(ctx)
return ctx.userData.avatar
},
"onUpdate": async (value) => {
@ -114,7 +115,7 @@ export default {
if (result) {
app.message.success("Avatar updated")
return result
return value
}
},
"debounced": true,
@ -139,7 +140,7 @@ export default {
if (result) {
app.message.success("Cover updated")
return result
return value
}
},
"debounced": true,
@ -166,7 +167,7 @@ export default {
})
if (result) {
return result
return value
}
},
"debounced": true,

View File

@ -34,17 +34,6 @@ export default {
"icon": "Monitor",
"component": loadable(() => import("../components/sessions")),
"storaged": false
},
{
"id": "logout",
"group": "security.other",
"component": "Button",
"icon": "LogOut",
"title": "Logout",
"description": "Logout from your account",
onUpdate: async () => {
await AuthModel.logout()
}
}
]
}

View File

@ -339,6 +339,15 @@ class ComtyApp extends React.Component {
app.eventBus.emit("layout.forceUpdate")
app.eventBus.emit("router.forceUpdate")
},
"app.logout_request": () => {
antd.Modal.confirm({
title: "Logout",
content: "Are you sure you want to logout?",
onOk: () => {
AuthModel.logout()
},
})
},
"app.no_session": async () => {
const location = window.location.pathname
@ -452,7 +461,7 @@ class ComtyApp extends React.Component {
app.eventBus.emit("statusTap")
})
StatusBar.setOverlaysWebView({ overlay: true })
StatusBar.setOverlaysWebView({ overlay: false })
CapacitorApp.addListener("backButton", ({ canGoBack }) => {
if (!canGoBack) {

View File

@ -0,0 +1,517 @@
import React from "react"
import * as antd from "antd"
import { Translation } from "react-i18next"
import { SliderPicker } from "react-color"
import { Icons, createIconRender } from "components/Icons"
class PerformanceLog {
constructor(
id,
params = {
disabled: false
}
) {
this.id = id
this.params = params
this.table = {}
return this
}
start(event) {
if (this.params.disabled) {
return false
}
if (!this.table[event]) {
this.table[event] = {}
}
return this.table[event]["start"] = performance.now()
}
end(event) {
if (this.params.disabled) {
return false
}
if (!this.table[event]) {
return
}
return this.table[event]["end"] = performance.now()
}
finally() {
if (this.params.disabled) {
return false
}
console.group(this.id)
Object.entries(this.table).forEach(([entry, value]) => {
console.log(entry, `${(value.end - value.start).toFixed(0)}ms`)
})
console.groupEnd()
}
}
export const SettingsComponents = {
button: {
component: antd.Button,
props: (_this) => {
return {
onClick: (event) => _this.onUpdateItem(event)
}
}
},
switch: {
component: antd.Switch,
props: (_this) => {
return {
onChange: (event) => _this.onUpdateItem(event)
}
}
},
slider: {
component: antd.Slider,
props: (_this) => {
return {
onAfterChange: (event) => _this.onUpdateItem(event)
}
}
},
input: {
component: antd.Input,
props: (_this) => {
return {
defaultValue: _this.state.value,
onChange: (event) => _this.onUpdateItem(event.target.value),
onPressEnter: (event) => _this.dispatchUpdate(event.target.value)
}
}
},
textarea: {
component: antd.Input.TextArea,
props: (_this) => {
return {
defaultValue: _this.state.value,
onChange: (event) => _this.onUpdateItem(event.target.value),
onPressEnter: (event) => _this.dispatchUpdate(event.target.value)
}
}
},
inputnumber: {
component: antd.InputNumber,
},
select: {
component: antd.Select,
props: (_this) => {
return {
onChange: (event) => {
console.log(event)
_this.onUpdateItem(event)
}
}
}
},
slidercolorpicker: {
component: SliderPicker,
props: (_this) => {
return {
onChange: (color) => {
_this.setState({
componentProps: {
..._this.state.componentProps,
color
}
})
},
onChangeComplete: (color) => {
_this.onUpdateItem(color.hex)
},
color: _this.state.value
}
}
},
}
export default class SettingItemComponent extends React.PureComponent {
state = {
value: null,
debouncedValue: null,
componentProps: Object(),
loading: true,
}
perf = new PerformanceLog(`Init ${this.props.setting.id}`, {
disabled: true
})
componentType = null
componentRef = React.createRef()
componentDidMount = async () => {
if (typeof this.props.setting.component === "string") {
this.componentType = String(this.props.setting.component).toLowerCase()
}
await this.initialize()
}
componentWillUnmount = () => {
this.setState({
value: null,
componentProps: Object(),
})
if (typeof this.props.setting.dependsOn === "object") {
for (const key in this.props.setting.dependsOn) {
window.app.eventBus.off(`setting.update.${key}`)
}
}
}
generateInhertedProps = () => {
if (!SettingsComponents[this.componentType]) {
return {}
}
if (typeof SettingsComponents[this.componentType].props === "function") {
const inhertedProps = SettingsComponents[this.componentType].props(this)
return inhertedProps
}
return {}
}
toogleLoading = (to) => {
if (typeof to === "undefined") {
to = !this.state.loading
}
this.setState({
loading: to
})
}
initialize = async () => {
this.perf.start(`init tooks`)
this.toogleLoading(true)
if (this.props.setting.storaged) {
this.perf.start(`get value from storaged`)
await this.setState({
value: window.app.cores.settings.get(this.props.setting.id),
})
this.perf.end(`get value from storaged`)
}
if (typeof this.props.setting.defaultValue === "function") {
this.perf.start(`execute default value fn`)
this.toogleLoading(true)
this.setState({
value: await this.props.setting.defaultValue(this.props.ctx)
})
this.toogleLoading(false)
this.perf.end(`execute default value fn`)
}
if (typeof this.props.setting.dependsOn === "object") {
this.perf.start(`register dependsOn events`)
Object.keys(this.props.setting.dependsOn).forEach((key) => {
// create a event handler to watch changes
window.app.eventBus.on(`setting.update.${key}`, () => {
this.setState({
componentProps: {
...this.state.componentProps,
disabled: this.checkDependsValidation()
}
})
})
})
this.perf.end(`register dependsOn events`)
this.perf.start(`check depends validation`)
// by default check depends validation
this.setState({
componentProps: {
...this.state.componentProps,
disabled: this.checkDependsValidation()
}
})
this.perf.end(`check depends validation`)
}
if (typeof this.props.setting.listenUpdateValue === "string") {
this.perf.start(`listen "on update" value`)
window.app.eventBus.on(`setting.update.${this.props.setting.listenUpdateValue}`, () => {
this.setState({
value: window.app.cores.settings.get(this.props.setting.id)
})
})
this.perf.end(`listen "on update" value`)
}
if (this.props.setting.reloadValueOnUpdateEvent) {
this.perf.start(`Reinitializing setting [${this.props.setting.id}]`)
window.app.eventBus.on(this.props.setting.reloadValueOnUpdateEvent, () => {
console.log(`Reinitializing setting [${this.props.setting.id}]`)
this.initialize()
})
this.perf.end(`Reinitializing setting [${this.props.setting.id}]`)
}
this.toogleLoading(false)
this.perf.end(`init tooks`)
this.perf.finally()
}
dispatchUpdate = async (updateValue) => {
if (typeof this.props.setting.onUpdate === "function") {
try {
const result = await this.props.setting.onUpdate(updateValue)
if (result) {
updateValue = result
}
} catch (error) {
console.error(error)
if (error.response.data.error) {
app.message.error(error.response.data.error)
} else {
app.message.error(error.message)
}
return false
}
}
const storagedValue = window.app.cores.settings.get(this.props.setting.id)
if (typeof updateValue === "undefined") {
updateValue = !storagedValue
}
if (this.props.setting.storaged) {
await window.app.cores.settings.set(this.props.setting.id, updateValue)
if (typeof this.props.setting.beforeSave === "function") {
await this.props.setting.beforeSave(updateValue)
}
}
if (typeof this.props.setting.emitEvent !== "undefined") {
if (typeof this.props.setting.emitEvent === "string") {
this.props.setting.emitEvent = [this.props.setting.emitEvent]
}
let emissionPayload = updateValue
if (typeof this.props.setting.emissionValueUpdate === "function") {
emissionPayload = this.props.setting.emissionValueUpdate(emissionPayload)
}
for await (const event of this.props.setting.emitEvent) {
window.app.eventBus.emit(event, emissionPayload)
}
}
if (this.props.setting.noUpdate) {
return false
}
// reset debounced value
if (this.props.setting.debounced) {
await this.setState({
debouncedValue: null
})
}
if (this.componentRef.current) {
if (typeof this.componentRef.current.onDebounceSave === "function") {
await this.componentRef.current.onDebounceSave(updateValue)
}
}
// finaly update value
await this.setState({
value: updateValue
})
return updateValue
}
onUpdateItem = async (updateValue) => {
this.setState({
value: updateValue
})
if (this.props.setting.debounced) {
return await this.setState({
debouncedValue: updateValue
})
}
return await this.dispatchUpdate(updateValue)
}
checkDependsValidation = () => {
return !Boolean(Object.keys(this.props.setting.dependsOn).every((key) => {
const storagedValue = window.app.cores.settings.get(key)
console.debug(`Checking validation for [${key}] with now value [${storagedValue}]`)
if (typeof this.props.setting.dependsOn[key] === "function") {
return this.props.setting.dependsOn[key](storagedValue)
}
return storagedValue === this.props.setting.dependsOn[key]
}))
}
render() {
if (!this.props.setting) {
console.error(`Item [${this.props.setting.id}] has no an setting!`)
return null
}
if (!this.props.setting.component) {
console.error(`Item [${this.props.setting.id}] has no an setting component!`)
return null
}
let finalProps = {
...this.state.componentProps,
...this.props.setting.props,
ctx: {
updateCurrentValue: (updateValue) => this.setState({
value: updateValue
}),
getCurrentValue: () => this.state.value,
currentValue: this.state.value,
dispatchUpdate: this.dispatchUpdate,
onUpdateItem: this.onUpdateItem,
},
ref: this.componentRef,
...this.generateInhertedProps(),
// set values
checked: this.state.value,
value: this.state.value,
size: app.isMobile ? "large" : "default"
}
if (this.props.setting.children) {
finalProps.children = this.props.setting.children
}
if (app.isMobile) {
finalProps.size = "large"
}
const Component = SettingsComponents[String(this.props.setting.component).toLowerCase()]?.component ?? this.props.setting.component
return <div className="setting_item" id={this.props.setting.id} key={this.props.setting.id}>
<div className="setting_item_header">
<div className="setting_item_info">
<div className="setting_item_header_title">
<h1>
{
createIconRender(this.props.setting.icon)
}
<Translation>
{(t) => t(this.props.setting.title ?? this.props.setting.id)}
</Translation>
</h1>
{this.props.setting.experimental && <antd.Tag> Experimental </antd.Tag>}
</div>
<div className="setting_item_header_description">
<p>
<Translation>
{(t) => t(this.props.setting.description)}
</Translation>
</p>
</div>
</div>
{
this.props.setting.extraActions && <div className="setting_item_header_actions">
{
this.props.setting.extraActions.map((action, index) => {
if (typeof action === "function") {
return React.createElement(action)
}
const handleOnClick = () => {
if (action.onClick) {
action.onClick(finalProps.ctx)
}
}
return <antd.Button
key={action.id}
id={action.id}
onClick={handleOnClick}
icon={action.icon && createIconRender(action.icon)}
type={action.type ?? "round"}
disabled={this.props.setting.disabled}
>
{action.title}
</antd.Button>
})
}
</div>
}
</div>
<div className="setting_item_content">
<>
{
!this.state.loading && React.createElement(Component, finalProps)
}
{
this.state.loading && <antd.Spin />
}
{
this.state.debouncedValue && <antd.Button
type="round"
icon={<Icons.Save />}
onClick={async () => await this.dispatchUpdate(this.state.debouncedValue)}
>
Save
</antd.Button>
}
</>
</div>
</div>
}
}

View File

@ -0,0 +1,117 @@
import React from "react"
import * as antd from "antd"
import { Translation } from "react-i18next"
import { Icons } from "components/Icons"
import {
composedTabs,
composeGroupsFromSettingsTab,
} from "schemas/settings"
import groupsDecorators from "schemas/settingsGroupsDecorators"
import SettingItemComponent from "../SettingItemComponent"
export default class SettingTab extends React.Component {
state = {
loading: true,
processedCtx: {}
}
tab = composedTabs[this.props.activeKey]
processCtx = async () => {
if (typeof this.tab.ctxData === "function") {
this.setState({ loading: true })
const resultCtx = await this.tab.ctxData()
console.log(resultCtx)
this.setState({
loading: false,
processedCtx: resultCtx
})
}
}
// check if props.activeKey change
componentDidUpdate = async (prevProps) => {
if (prevProps.activeKey !== this.props.activeKey) {
this.tab = composedTabs[this.props.activeKey]
this.setState({
loading: !!this.tab.ctxData,
processedCtx: {}
})
await this.processCtx()
}
}
componentDidMount = async () => {
this.setState({
loading: !!this.tab.ctxData,
})
await this.processCtx()
this.setState({
loading: false
})
}
render() {
if (this.state.loading) {
return <antd.Skeleton active />
}
if (this.tab.render) {
return React.createElement(this.tab.render, {
ctx: this.state.processedCtx
})
}
if (this.props.withGroups) {
const group = composeGroupsFromSettingsTab(this.tab.settings)
return Object.entries(group).map(([groupKey, settings], index) => {
const fromDecoratorIcon = groupsDecorators[groupKey]?.icon
const fromDecoratorTitle = groupsDecorators[groupKey]?.title
return <div id={groupKey} key={index} className="settings_content_group">
<div className="settings_content_group_header">
<h1>
{
fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null
}
<Translation>
{
t => t(fromDecoratorTitle ?? groupKey)
}
</Translation>
</h1>
</div>
<div className="settings_list">
{
settings.map((setting) => <SettingItemComponent
setting={setting}
ctx={this.state.processedCtx}
/>)
}
</div>
</div>
})
}
return this.tab.settings.map((setting, index) => {
return <SettingItemComponent
key={index}
setting={setting}
ctx={this.state.processedCtx}
/>
})
}
}

564
packages/app/src/pages/settings/index.jsx Executable file → Normal file
View File

@ -6,34 +6,35 @@ import classnames from "classnames"
import config from "config"
import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey"
import AuthModel from "models/auth"
import { Icons, createIconRender } from "components/Icons"
import getSettingsList from "schemas/settings"
import {
composedSettingsByGroups as settings
} from "schemas/settings"
import menuGroupsDecorators from "schemas/settingsMenuGroupsDecorators"
import groupsDecorators from "schemas/settingsGroupsDecorators"
import SettingTab from "./components/SettingTab"
import "./index.less"
const SettingsList = await getSettingsList()
const extraMenuItems = [
{
id: "donate",
label: "Support us",
icon: "Heart",
props: {
style: {
color: "#f72585"
}
}
key: "donate",
label: <div style={{
color: "#f72585"
}}>
{createIconRender("Heart")}
Support us
</div>,
},
{
id: "logout",
label: "Logout",
icon: "MdOutlineLogout",
danger: true
key: "logout",
label: <div>
{createIconRender("MdOutlineLogout")}
Logout
</div>,
danger: true,
}
]
@ -44,493 +45,16 @@ const menuEvents = {
}
},
"logout": () => {
antd.Modal.confirm({
title: "Logout",
content: "Are you sure you want to logout?",
onOk: () => {
AuthModel.logout()
},
})
app.eventBus.emit("app.logout_request")
}
}
const ItemTypes = {
Button: antd.Button,
Switch: antd.Switch,
Slider: antd.Slider,
Checkbox: antd.Checkbox,
Input: antd.Input,
TextArea: antd.Input.TextArea,
InputNumber: antd.InputNumber,
Select: antd.Select,
SliderColorPicker: SliderPicker,
}
const SettingItem = (props) => {
let { item } = props
const [loading, setLoading] = React.useState(true)
const [value, setValue] = React.useState(null)
const [delayedValue, setDelayedValue] = React.useState(null)
const [disabled, setDisabled] = React.useState(false)
const componentRef = React.useRef(null)
let SettingComponent = item.component
if (!SettingComponent) {
console.error(`Item [${item.id}] has no an component!`)
return null
}
if (typeof item.props === "undefined") {
item.props = {}
}
const dispatchUpdate = async (updateValue) => {
if (typeof item.onUpdate === "function") {
try {
const result = await item.onUpdate(updateValue)
if (result) {
updateValue = result
}
} catch (error) {
console.error(error)
if (error.response.data.error) {
app.message.error(error.response.data.error)
} else {
app.message.error(error.message)
}
return false
}
} else {
const storagedValue = await window.app.cores.settings.get(item.id)
if (typeof updateValue === "undefined") {
updateValue = !storagedValue
}
}
if (item.storaged) {
await window.app.cores.settings.set(item.id, updateValue)
}
if (item.storaged && typeof item.beforeSave === "function") {
await item.beforeSave(updateValue)
}
if (typeof item.emitEvent !== "undefined") {
let emissionPayload = updateValue
if (typeof item.emissionValueUpdate === "function") {
emissionPayload = item.emissionValueUpdate(emissionPayload)
}
if (Array.isArray(item.emitEvent)) {
window.app.eventBus.emit(...item.emitEvent, emissionPayload)
} else if (typeof item.emitEvent === "string") {
window.app.eventBus.emit(item.emitEvent, emissionPayload)
}
}
if (item.noUpdate) {
return false
}
if (item.debounced) {
setDelayedValue(null)
}
if (componentRef.current) {
if (typeof componentRef.current.onDebounceSave === "function") {
await componentRef.current.onDebounceSave(updateValue)
}
}
setValue(updateValue)
}
const onUpdateItem = async (updateValue) => {
setValue(updateValue)
if (!item.debounced) {
await dispatchUpdate(updateValue)
} else {
setDelayedValue(updateValue)
}
}
const checkDependsValidation = () => {
return !Boolean(Object.keys(item.dependsOn).every((key) => {
const storagedValue = window.app.cores.settings.get(key)
console.debug(`Checking validation for [${key}] with now value [${storagedValue}]`)
if (typeof item.dependsOn[key] === "function") {
return item.dependsOn[key](storagedValue)
}
return storagedValue === item.dependsOn[key]
}))
}
const settingInitialization = async () => {
if (item.storaged) {
const storagedValue = window.app.cores.settings.get(item.id)
setValue(storagedValue)
}
if (typeof item.defaultValue === "function") {
setLoading(true)
setValue(await item.defaultValue(props.ctx))
setLoading(false)
}
if (item.disabled === true) {
setDisabled(true)
}
if (typeof item.dependsOn === "object") {
// create a event handler to watch changes
Object.keys(item.dependsOn).forEach((key) => {
window.app.eventBus.on(`setting.update.${key}`, () => {
setDisabled(checkDependsValidation())
})
})
// by default check depends validation
setDisabled(checkDependsValidation())
}
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)
}
React.useEffect(() => {
settingInitialization()
return () => {
if (typeof item.dependsOn === "object") {
for (let key in item.dependsOn) {
window.app.eventBus.off(`setting.update.${key}`, onUpdateItem)
}
}
}
}, [])
if (typeof SettingComponent === "string") {
if (typeof ItemTypes[SettingComponent] === "undefined") {
console.error(`Item [${item.id}] has an invalid component: ${item.component}`)
return null
}
switch (SettingComponent.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 "textarea": {
item.props.defaultValue = value
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
item.props.onChange = (event) => onUpdateItem(event.target.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
}
}
// override with default item component
SettingComponent = ItemTypes[SettingComponent]
}
item.props["disabled"] = disabled
const elementsCtx = {
updateCurrentValue: (value) => setValue(value),
currentValue: value,
dispatchUpdate,
onUpdateItem,
...props.ctx,
}
return <div key={item.id} className="settings_content_group_item">
<div className="settings_content_group_item_header">
<div className="settings_content_group_item_header_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="settings_content_group_item_header_actions">
{item.extraActions.map((action, index) => {
if (typeof action === "function") {
return React.createElement(action, {
ctx: elementsCtx,
})
}
const handleOnClick = () => {
if (action.onClick) {
action.onClick(elementsCtx)
}
}
return <antd.Button
key={action.id}
id={action.id}
onClick={handleOnClick}
icon={action.icon && createIconRender(action.icon)}
type={action.type ?? "round"}
disabled={item.props.disabled}
>
{action.title}
</antd.Button>
})}
</div>
}
</div>
<div className="settings_content_group_item_component">
<div>
{
loading
? <div> Loading... </div>
: React.createElement(SettingComponent, {
...item.props,
ctx: elementsCtx,
ref: componentRef,
})}
</div>
{
delayedValue && <div>
<antd.Button
type="round"
icon={<Icons.Save />}
onClick={async () => await dispatchUpdate(value)}
>
Save
</antd.Button>
</div>
}
</div>
</div>
}
const SettingGroup = React.memo((props) => {
const {
ctx,
groupKey,
settings,
loading,
disabled
} = props
const fromDecoratorIcon = groupsDecorators[groupKey]?.icon
const fromDecoratorTitle = groupsDecorators[groupKey]?.title
if (loading) {
return <antd.Skeleton active />
}
if (disabled) {
return null
}
return <div index={groupKey} key={groupKey} className="settings_content_group">
<div className="settings_content_group_header">
<h1>
{
fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null
}
<Translation>
{
t => t(fromDecoratorTitle ?? groupKey)
}
</Translation>
</h1>
</div>
<div className="settings_content_group_settings">
{
settings.map((item) => <SettingItem
item={item}
ctx={ctx}
/>)
}
</div>
</div>
})
const SettingTab = (props) => {
const [groups, setGroups] = React.useState({})
const [loading, setLoading] = React.useState(true)
const [ctxData, setCtxData] = React.useState({})
const processCtx = async () => {
setLoading(true)
if (typeof props.tab.ctxData === "function") {
const resultCtx = await props.tab.ctxData()
setCtxData(resultCtx)
}
setLoading(false)
}
React.useEffect(() => {
if (!Array.isArray(props.tab.settings)) {
console.error("Cannot generate settings from non-array")
return []
}
let groupsSettings = {}
props.tab.settings.forEach((item) => {
if (!groupsSettings[item.group]) {
groupsSettings[item.group] = []
}
groupsSettings[item.group].push(item)
})
setGroups(groupsSettings)
processCtx()
}, [props.tab])
if (loading) {
return <antd.Skeleton active />
}
return Object.keys(groups).map((groupKey) => {
return <SettingGroup
groupKey={groupKey}
settings={groups[groupKey]}
loading={loading}
ctx={ctxData}
/>
})
}
const generateMenuItems = () => {
const groups = {}
Object.keys(SettingsList).forEach((tabKey) => {
const tab = SettingsList[tabKey]
if (!tab.group) {
tab.group = "Others"
}
groups[tab.group] = groups[tab.group] ?? []
groups[tab.group].push(tab)
})
if (typeof groups["bottom"] === undefined) {
groups["bottom"] = []
}
// add extra menu items
extraMenuItems.forEach((item) => {
groups["bottom"].push(item)
})
let groupsKeys = Object.keys(groups)
// make "bottom" group last
groupsKeys = groupsKeys.sort((a, b) => {
if (a === "bottom") {
return 1
}
if (b === "bottom") {
return -1
}
return 0
})
return groupsKeys.map((groupKey, index) => {
const ordererItems = groups[groupKey].sort((a, b) => {
if (typeof a.order === "undefined") {
a.order = groups[groupKey].indexOf(a)
}
if (typeof b.order === "undefined") {
b.order = groups[groupKey].indexOf(b)
}
// if value is close to 0, more to the top
return a.order - b.order
})
const children = ordererItems.map((item) => {
return settings.map((entry, index) => {
const children = entry.groupModule.map((item) => {
return {
key: item.id,
type: "item",
label: <div {...item.props}>
{createIconRender(item.icon ?? "Settings")}
{item.label}
@ -541,38 +65,37 @@ const generateMenuItems = () => {
}
})
if (index !== groupsKeys.length - 1) {
if (index !== settings.length - 1) {
children.push({
type: "divider",
})
}
return {
key: groupKey,
label: groupKey === "bottom" ? null : <>
key: entry.group,
type: "group",
children: children,
label: entry.group === "bottom" ? null : <>
{
menuGroupsDecorators[groupKey]?.icon && createIconRender(menuGroupsDecorators[groupKey]?.icon ?? "Settings")
menuGroupsDecorators[entry.group]?.icon && createIconRender(menuGroupsDecorators[groupKey]?.icon ?? "Settings")
}
<Translation>
{
t => t(menuGroupsDecorators[groupKey]?.label ?? groupKey)
t => t(menuGroupsDecorators[entry.group]?.label ?? entry.group)
}
</Translation>
</>,
type: "group",
children: children,
</>
}
})
}
export default () => {
const [activeKey, setActiveKey] = useUrlQueryActiveKey({
defaultKey: "general",
queryKey: "tab"
})
const [menuItems, setMenuItems] = React.useState([])
const onChangeTab = (event) => {
if (typeof menuEvents[event.key] === "function") {
return menuEvents[event.key]()
@ -583,16 +106,19 @@ export default () => {
setActiveKey(event.key)
}
React.useEffect(() => {
setMenuItems(generateMenuItems())
const menuItems = React.useMemo(() => {
const items = generateMenuItems()
extraMenuItems.forEach((item) => {
items[settings.length - 1].children.push(item)
})
return items
}, [])
return <div
className={classnames(
"settings_wrapper",
{
["mobile"]: app.isMobile,
}
)}
>
<div className="settings_menu">
@ -605,12 +131,10 @@ export default () => {
</div>
<div className="settings_content">
{
SettingsList[activeKey] &&
React.createElement(SettingsList[activeKey].render ?? SettingTab, {
tab: SettingsList[activeKey],
})
}
<SettingTab
activeKey={activeKey}
withGroups
/>
</div>
</div>
}

View File

@ -6,6 +6,8 @@
justify-content: center;
width: 100%;
padding-bottom: 20px;
.settings_menu {
@ -21,8 +23,8 @@
align-items: center;
width: 30%;
max-width: 300px;
width: 250px;
min-width: 250px;
padding: 0 30px;
@ -41,8 +43,7 @@
display: flex;
flex-direction: column;
width: 100%;
max-width: 700px;
width: 700px;
gap: 20px;
@ -62,13 +63,10 @@
gap: 20px;
.settings_content_group_header {
position: absolute;
position: relative;
width: 100%;
top: 0;
left: 0;
h1,
h2,
h3,
@ -78,65 +76,68 @@
margin: 0;
color: var(--background-color-contrast);
}
//-webkit-box-shadow: @card-shadow;
//-moz-box-shadow: @card-shadow;
//box-shadow: @card-shadow;
padding: 20px;
border-radius: 12px;
}
.settings_content_group_settings {
.settings_list {
display: flex;
flex-direction: column;
margin-top: 50px;
gap: 30px;
.settings_content_group_item {
width: 100%;
.setting_item {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 20px;
.settings_content_group_item_header {
.setting_item_header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.settings_content_group_item_header_title {
.setting_item_info {
display: flex;
align-items: center;
color: var(--background-color-contrast);
flex-direction: column;
width: 100%;
.setting_item_header_title {
display: 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);
h1 {
font-size: 1rem;
margin: 0;
color: var(--background-color-contrast);
}
}
p {
font-size: 11px;
color: var(--background-color-contrast);
margin: 0;
.setting_item_header_description {
p {
color: var(--background-color-contrast);
font-size: 0.7rem;
margin: 0;
}
}
>div {
margin-right: 10px;
}
}
.settings_content_group_item_header_actions {
.setting_item_header_actions {
display: inline-flex;
align-items: center;
@ -155,16 +156,28 @@
}
}
.settings_content_group_item_component {
.setting_item_content {
display: flex;
flex-direction: column;
--ignore-dragger: true;
padding: 6px 20px;
width: 100%;
span {
color: var(--background-color-contrast);
}
button {
width: min-content;
}
.ant-btn.ant-btn-icon-only {
width: 32px;
}
.ant-select {}
}
}
}

View File

@ -0,0 +1,135 @@
import React from "react"
import * as antd from "antd"
import { Translation } from "react-i18next"
import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey"
import { Icons, createIconRender } from "components/Icons"
import {
composedSettingsByGroups as settingsGroups,
composedTabs,
} from "schemas/settings"
import menuGroupsDecorators from "schemas/settingsMenuGroupsDecorators"
import SettingTab from "./components/SettingTab"
import "./index.mobile.less"
const SettingsHeader = ({
activeKey,
back = () => { }
} = {}) => {
if (activeKey) {
const currentTab = composedTabs[activeKey]
return <div className="__mobile__settings_header nav">
<antd.Button
icon={<Icons.MdChevronLeft />}
onClick={back}
size="large"
type="ghost"
/>
<h1>
{
createIconRender(currentTab?.icon)
}
<Translation>
{(t) => t(currentTab?.label ?? activeKey)}
</Translation>
</h1>
</div>
}
return <div className="__mobile__settings_header">
<h1>
{
createIconRender("Settings")
}
<Translation>
{(t) => t("Settings")}
</Translation>
</h1>
</div>
}
export default (props) => {
let lastKey = null
const [activeKey, setActiveKey] = useUrlQueryActiveKey({
queryKey: "tab",
defaultKey: null,
})
const handleTabChange = (key) => {
// star page transition using new chrome transition api
if (document.startViewTransition) {
return document.startViewTransition(() => {
changeTab(key)
})
}
return changeTab(key)
}
const goBack = () => {
handleTabChange(lastKey)
}
const changeTab = (key) => {
lastKey = key
setActiveKey(key)
}
return <div className="__mobile__settings">
<SettingsHeader
activeKey={activeKey}
back={goBack}
/>
<div className="settings_list">
{
!activeKey && settingsGroups.map((entry) => {
const groupDecorator = menuGroupsDecorators[entry.group]
return <div className="settings_list_group">
<span >
<Translation>
{(t) => t(groupDecorator?.label ?? entry.group)}
</Translation>
</span>
<div className="settings_list_group_items">
{
entry.groupModule.map((settingsModule, index) => {
return <antd.Button
size="large"
key={settingsModule.id}
id={settingsModule.id}
icon={createIconRender(settingsModule.icon)}
onClick={() => {
handleTabChange(settingsModule.id)
}}
>
<Translation>
{(t) => t(settingsModule.label)}
</Translation>
</antd.Button>
})
}
</div>
</div>
})
}
{
activeKey && <div className="settings_list_render">
<SettingTab
activeKey={activeKey}
/>
</div>
}
</div>
</div>
}

View File

@ -0,0 +1,156 @@
@top_nav_height: 52px;
.__mobile__settings {
display: flex;
flex-direction: column;
color: var(--text-color);
gap: 30px;
.__mobile__settings_header {
position: sticky;
top: 0;
left: 0;
z-index: 200;
width: 100%;
height: @top_nav_height;
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
padding: 5px 20px;
border-radius: 12px;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-color-accent);
h1 {
margin: 0;
view-transition-name: main-header-text;
width: fit-content;
}
svg {
color: var(--colorPrimary);
}
.ant-btn {
font-size: 2rem;
svg {
color: var(--text-color);
}
}
}
.settings_list {
view-transition-name: settings-list;
display: flex;
flex-direction: column;
color: var(--text-color);
gap: 20px;
.settings_list_group {
display: flex;
flex-direction: column;
span {
font-size: 0.8rem;
font-weight: 600;
margin: 0;
}
.settings_list_group_items {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
padding: 10px 15px;
.ant-btn {
align-items: center;
justify-content: flex-start;
width: 100%;
gap: 10vw;
padding: 30px 20px;
svg {
font-size: 1.2rem;
}
}
}
}
.settings_list_render {
display: flex;
flex-direction: column;
overflow-x: hidden;
gap: 20px;
.setting_item {
display: flex;
flex-direction: column;
gap: 10px;
.setting_item_header {
display: flex;
flex-direction: column;
gap: 5px;
.setting_item_info {
display: flex;
flex-direction: column;
gap: 5px;
.setting_item_header_title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
h1 {
font-size: 1rem;
margin: 0;
}
}
.setting_item_header_description {
display: flex;
flex-direction: column;
p {
margin: 0;
}
}
}
}
.setting_item_content {
width: 100%;
padding: 5px 20px;
}
}
}
}
}