({
+ key: section.key,
+ label: section.name
+ }))}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
@@ -257,163 +317,51 @@ const Settings = () => {
handleUpdate={handleSave}
cancelEditing={cancelEditing}
startEditing={startEditing}
- formValid={
- Boolean(draftSettings.theme && draftSettings.density) &&
- (!isElectron ||
- (Boolean(draftSettings.appUpdateBranch) &&
- Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
- }
- disabled={settingsLoading || (!isElectron && !userProfile)}
+ formValid={areSettingsValid(draftSettings, { isElectron })}
+ disabled={settingsLoading || !userSettingsLoaded}
loading={saving}
/>
-
- }>
-
- }
- active={collapseState.appearance}
- onToggle={(expanded) =>
- updateCollapseState('appearance', expanded)
- }
- collapseKey='appearance'
- >
-
-
- {isEditing ? (
-
- ) : (
- {currentThemeValue}
- )}
-
-
- {isEditing ? (
-
- ) : (
- {isCompact ? 'Compact' : 'Comfortable'}
- )}
-
-
- {isEditing ? (
-
- ) : (
-
- {currentShowNavigationLabels ? 'Show' : 'Hide'}
-
- )}
-
-
-
- {isElectron && (
+
+
+ {visibleSections.map((section) => {
+ const Icon = section.icon
+ return (
}
- active={collapseState.appUpdates}
+ key={section.key}
+ title={section.name}
+ icon={}
+ active={collapseState[section.key]}
onToggle={(expanded) =>
- updateCollapseState('appUpdates', expanded)
+ updateCollapseState(section.key, expanded)
}
- collapseKey='appUpdates'
+ collapseKey={section.key}
>
-
-
- {isEditing ? (
-
- ) : (
- {currentBranch}
- )}
-
-
- {isEditing ? (
-
- ) : (
- {engineLabel(currentEngine)}
- )}
-
-
+ : undefined
+ }
+ {...(section.column ? { column: section.column } : {})}
+ />
- )}
-
-
-
+ )
+ })}
+
+
)
}
diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx
index 102d7cb6..b7bd183a 100644
--- a/src/components/Dashboard/context/ApiServerContext.jsx
+++ b/src/components/Dashboard/context/ApiServerContext.jsx
@@ -38,7 +38,8 @@ const createEmptyUserSettings = () => ({
sortSidebarVisibility: {},
columnVisibility: {},
collapseState: {},
- pageLayout: {}
+ pageLayout: {},
+ appearance: {}
})
const normalizeUserSettingsCategory = (category) =>
@@ -76,7 +77,8 @@ const normalizeUserSettings = (settings = {}) => ({
),
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
collapseState: normalizeUserSettingsCategory(settings?.collapseState),
- pageLayout: normalizeUserSettingsCategory(settings?.pageLayout)
+ pageLayout: normalizeUserSettingsCategory(settings?.pageLayout),
+ appearance: normalizeUserSettingsCategory(settings?.appearance)
})
const emitWithAcknowledgement = (socket, eventName, data) =>
diff --git a/src/database/Settings.js b/src/database/Settings.js
new file mode 100644
index 00000000..0eff8601
--- /dev/null
+++ b/src/database/Settings.js
@@ -0,0 +1,306 @@
+import PersonIcon from '../components/Icons/PersonIcon'
+import OpenAppIcon from '../components/Icons/OpenAppIcon'
+import SoftwareUpdateIcon from '../components/Icons/SoftwareUpdateIcon'
+
+export const DEFAULT_DATE_TIME_FORMAT = 'MM/DD/YYYY HH:mm:ss'
+
+const DATE_FORMAT_TOKENS = [
+ 'YYYY',
+ 'YY',
+ 'MMMM',
+ 'MMM',
+ 'MM',
+ 'M',
+ 'dddd',
+ 'ddd',
+ 'dd',
+ 'd',
+ 'DD',
+ 'Do',
+ 'D',
+ 'WW',
+ 'W',
+ 'ww',
+ 'w',
+ 'Q'
+]
+const TIME_FORMAT_TOKENS = [
+ 'HH',
+ 'H',
+ 'hh',
+ 'h',
+ 'mm',
+ 'm',
+ 'ss',
+ 's',
+ 'SSS',
+ 'SS',
+ 'S',
+ 'A',
+ 'a',
+ 'ZZ',
+ 'Z',
+ 'X',
+ 'x'
+]
+const FORMAT_TOKEN_PATTERN = new RegExp(
+ `\\[[^\\]]*\\]|${[...DATE_FORMAT_TOKENS, ...TIME_FORMAT_TOKENS]
+ .sort((left, right) => right.length - left.length)
+ .join('|')}`,
+ 'g'
+)
+const DATE_FORMAT_TOKEN_SET = new Set(DATE_FORMAT_TOKENS)
+const TIME_FORMAT_TOKEN_SET = new Set(TIME_FORMAT_TOKENS)
+
+const lastPartIndex = (parts, type) => {
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
+ if (parts[index].type === type) return index
+ }
+ return -1
+}
+
+const extractFormatPart = (parts, type) => {
+ const first = parts.findIndex((part) => part.type === type)
+ const last = lastPartIndex(parts, type)
+ if (first === -1) return ''
+ return parts
+ .slice(first, last + 1)
+ .map((part) => part.value)
+ .join('')
+ .trim()
+}
+
+export const splitDateTimeFormat = (
+ format = DEFAULT_DATE_TIME_FORMAT
+) => {
+ const source =
+ typeof format === 'string' && format.trim()
+ ? format
+ : DEFAULT_DATE_TIME_FORMAT
+ const parts = []
+ let cursor = 0
+
+ for (const match of source.matchAll(FORMAT_TOKEN_PATTERN)) {
+ if (match.index > cursor) {
+ parts.push({ type: 'sep', value: source.slice(cursor, match.index) })
+ }
+
+ const token = match[0]
+ if (token.startsWith('[')) {
+ parts.push({ type: 'sep', value: token })
+ } else if (DATE_FORMAT_TOKEN_SET.has(token)) {
+ parts.push({ type: 'date', value: token })
+ } else if (TIME_FORMAT_TOKEN_SET.has(token)) {
+ parts.push({ type: 'time', value: token })
+ } else {
+ parts.push({ type: 'sep', value: token })
+ }
+
+ cursor = match.index + token.length
+ }
+
+ if (cursor < source.length) {
+ parts.push({ type: 'sep', value: source.slice(cursor) })
+ }
+
+ return {
+ dateFormat: extractFormatPart(parts, 'date') || 'MM/DD/YYYY',
+ timeFormat: extractFormatPart(parts, 'time') || 'HH:mm:ss'
+ }
+}
+
+export const USER_SETTING_NAMES = [
+ 'theme',
+ 'density',
+ 'showNavigationLabels',
+ 'dateTimeFormat',
+ 'timezone'
+]
+
+export const APP_SETTING_NAMES = [
+ 'appTheme',
+ 'appShowNavigationLabels',
+ 'appUpdateBranch',
+ 'appUpdateEngine'
+]
+
+const themeOptions = [
+ { label: 'Light', value: 'light' },
+ { label: 'Dark', value: 'dark' },
+ { label: 'System', value: 'system' }
+]
+
+const settings = {
+ sections: [
+ {
+ key: 'appearance',
+ name: 'User Settings',
+ visible: true,
+ icon: PersonIcon,
+ properties: [
+ {
+ name: 'theme',
+ label: 'Theme',
+ type: 'select',
+ required: true,
+ options: (_objectData, parentData) => [
+ ...themeOptions,
+ ...(parentData?.isElectron ? [{ label: 'App', value: 'app' }] : [])
+ ]
+ },
+ {
+ name: 'density',
+ label: 'UI Density',
+ type: 'select',
+ required: true,
+ options: [
+ { label: 'Comfortable', value: 'comfortable' },
+ { label: 'Compact', value: 'compact' }
+ ]
+ },
+ {
+ name: 'showNavigationLabels',
+ label: 'Navigation Labels',
+ type: 'select',
+ required: true,
+ options: [
+ { label: 'Show', value: true },
+ { label: 'Hide', value: false }
+ ]
+ },
+ {
+ name: 'dateTimeFormat',
+ label: 'Date and Time Format',
+ type: 'text',
+ required: true,
+ defaultValue: DEFAULT_DATE_TIME_FORMAT
+ },
+ {
+ name: 'timezone',
+ label: 'Timezone',
+ type: 'text',
+ required: true,
+ defaultValue: 'UTC'
+ }
+ ]
+ },
+ {
+ key: 'app',
+ name: 'App Settings',
+ visible: ({ isElectron } = {}) => Boolean(isElectron),
+ icon: OpenAppIcon,
+ properties: [
+ {
+ name: 'appTheme',
+ label: 'Theme',
+ type: 'select',
+ required: true,
+ defaultValue: 'system',
+ options: themeOptions
+ },
+ {
+ name: 'appShowNavigationLabels',
+ label: 'Navigation Labels',
+ type: 'select',
+ required: true,
+ defaultValue: false,
+ options: [
+ { label: 'Show', value: true },
+ { label: 'Hide', value: false }
+ ]
+ }
+ ]
+ },
+ {
+ key: 'appUpdates',
+ name: 'App Update Settings',
+ visible: ({ isElectron } = {}) => Boolean(isElectron),
+ icon: SoftwareUpdateIcon,
+ column: 1,
+ properties: [
+ {
+ name: 'appUpdateBranch',
+ label: 'Branch',
+ type: 'select',
+ required: true,
+ defaultValue: 'main',
+ options: (_objectData, parentData) =>
+ (parentData?.branches || []).map((branch) => ({
+ label: branch,
+ value: branch
+ }))
+ },
+ {
+ name: 'appUpdateEngine',
+ label: 'Engine',
+ type: 'select',
+ required: true,
+ defaultValue: 'native',
+ options: [
+ { label: 'Native', value: 'native' },
+ { label: 'Chromium', value: 'chromium' }
+ ]
+ }
+ ]
+ }
+ ]
+}
+
+export const getVisibleSettingsSections = (context = {}) =>
+ settings.sections.filter((section) => {
+ if (typeof section.visible === 'function') {
+ return section.visible(context)
+ }
+ return section.visible !== false
+ })
+
+export const getSettingsDefaults = (context = {}) => {
+ const defaults = {}
+ for (const section of getVisibleSettingsSections(context)) {
+ for (const property of section.properties || []) {
+ if (property.defaultValue !== undefined) {
+ defaults[property.name] = property.defaultValue
+ }
+ }
+ }
+ return defaults
+}
+
+export const areSettingsValid = (values = {}, context = {}) =>
+ getVisibleSettingsSections(context).every((section) =>
+ (section.properties || []).every((property) => {
+ if (!property.required) return true
+ const value = values[property.name]
+ return value !== undefined && value !== null && value !== ''
+ })
+ )
+
+export const pickSettings = (values = {}, names = []) =>
+ names.reduce((picked, name) => {
+ if (values[name] !== undefined) {
+ picked[name] = values[name]
+ }
+ return picked
+ }, {})
+
+export const getEffectiveAppearance = ({
+ isElectron = false,
+ userAppearance = {},
+ electronSettings = {}
+} = {}) => {
+ const overrideTheme = Boolean(isElectron && electronSettings.overrideTheme)
+ const appTheme = electronSettings.appTheme || electronSettings.theme
+ const appShowNavigationLabels =
+ electronSettings.appShowNavigationLabels ??
+ electronSettings.showNavigationLabels
+
+ return {
+ theme: overrideTheme ? appTheme : userAppearance.theme,
+ showNavigationLabels: isElectron
+ ? (appShowNavigationLabels ?? userAppearance.showNavigationLabels)
+ : userAppearance.showNavigationLabels,
+ density: userAppearance.density
+ }
+}
+
+export default settings