Franknoirot/settings search (#2270)
* Search and highlight, no scroll yet * Tweak toggle look * Style search, fix state changes * Separate out settings components * Include description in results, minor style tweaks * Fix tsc import * Remove unused imports in Settings * fmt
This commit is contained in:
152
src/components/Settings/SettingsFieldInput.tsx
Normal file
152
src/components/Settings/SettingsFieldInput.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Toggle } from 'components/Toggle/Toggle'
|
||||||
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
|
import {
|
||||||
|
SetEventTypes,
|
||||||
|
SettingsLevel,
|
||||||
|
WildcardSetEvent,
|
||||||
|
} from 'lib/settings/settingsTypes'
|
||||||
|
import { getSettingInputType } from 'lib/settings/settingsUtils'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Event } from 'xstate'
|
||||||
|
|
||||||
|
interface SettingsFieldInputProps {
|
||||||
|
// We don't need the fancy types here,
|
||||||
|
// it doesn't help us with autocomplete or anything
|
||||||
|
category: string
|
||||||
|
settingName: string
|
||||||
|
settingsLevel: SettingsLevel
|
||||||
|
setting: Setting<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsFieldInput({
|
||||||
|
category,
|
||||||
|
settingName,
|
||||||
|
settingsLevel,
|
||||||
|
setting,
|
||||||
|
}: SettingsFieldInputProps) {
|
||||||
|
const {
|
||||||
|
settings: { context, send },
|
||||||
|
} = useSettingsAuthContext()
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return setting.commandConfig &&
|
||||||
|
'options' in setting.commandConfig &&
|
||||||
|
setting.commandConfig.options
|
||||||
|
? setting.commandConfig.options instanceof Array
|
||||||
|
? setting.commandConfig.options
|
||||||
|
: setting.commandConfig.options(
|
||||||
|
{
|
||||||
|
argumentsToSubmit: {
|
||||||
|
level: settingsLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
}, [setting, settingsLevel, context])
|
||||||
|
const inputType = getSettingInputType(setting)
|
||||||
|
|
||||||
|
switch (inputType) {
|
||||||
|
case 'component':
|
||||||
|
return (
|
||||||
|
setting.Component && (
|
||||||
|
<setting.Component
|
||||||
|
value={setting[settingsLevel] || setting.getFallback(settingsLevel)}
|
||||||
|
updateValue={(newValue) => {
|
||||||
|
send({
|
||||||
|
type: `set.${category}.${settingName}`,
|
||||||
|
data: {
|
||||||
|
level: settingsLevel,
|
||||||
|
value: newValue,
|
||||||
|
},
|
||||||
|
} as unknown as Event<WildcardSetEvent>)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<Toggle
|
||||||
|
offLabel="Off"
|
||||||
|
onLabel="On"
|
||||||
|
onChange={(e) =>
|
||||||
|
send({
|
||||||
|
type: `set.${category}.${settingName}`,
|
||||||
|
data: {
|
||||||
|
level: settingsLevel,
|
||||||
|
value: Boolean(e.target.checked),
|
||||||
|
},
|
||||||
|
} as SetEventTypes)
|
||||||
|
}
|
||||||
|
checked={Boolean(
|
||||||
|
setting[settingsLevel] !== undefined
|
||||||
|
? setting[settingsLevel]
|
||||||
|
: setting.getFallback(settingsLevel)
|
||||||
|
)}
|
||||||
|
name={`${category}-${settingName}`}
|
||||||
|
data-testid={`${category}-${settingName}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'options':
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
name={`${category}-${settingName}`}
|
||||||
|
data-testid={`${category}-${settingName}`}
|
||||||
|
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
|
||||||
|
value={String(
|
||||||
|
setting[settingsLevel] || setting.getFallback(settingsLevel)
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
send({
|
||||||
|
type: `set.${category}.${settingName}`,
|
||||||
|
data: {
|
||||||
|
level: settingsLevel,
|
||||||
|
value: e.target.value,
|
||||||
|
},
|
||||||
|
} as unknown as Event<WildcardSetEvent>)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{options &&
|
||||||
|
options.length > 0 &&
|
||||||
|
options.map((option) => (
|
||||||
|
<option key={option.name} value={String(option.value)}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
case 'string':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
name={`${category}-${settingName}`}
|
||||||
|
data-testid={`${category}-${settingName}`}
|
||||||
|
type="text"
|
||||||
|
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
|
||||||
|
defaultValue={String(
|
||||||
|
setting[settingsLevel] || setting.getFallback(settingsLevel)
|
||||||
|
)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (
|
||||||
|
setting[settingsLevel] === undefined
|
||||||
|
? setting.getFallback(settingsLevel) !== e.target.value
|
||||||
|
: setting[settingsLevel] !== e.target.value
|
||||||
|
) {
|
||||||
|
send({
|
||||||
|
type: `set.${category}.${settingName}`,
|
||||||
|
data: {
|
||||||
|
level: settingsLevel,
|
||||||
|
value: e.target.value,
|
||||||
|
},
|
||||||
|
} as unknown as Event<WildcardSetEvent>)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="text-destroy-70 dark:text-destroy-20">
|
||||||
|
No component or input type found for setting {settingName} in category{' '}
|
||||||
|
{category}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
110
src/components/Settings/SettingsSearchBar.tsx
Normal file
110
src/components/Settings/SettingsSearchBar.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Combobox } from '@headlessui/react'
|
||||||
|
import { CustomIcon } from 'components/CustomIcon'
|
||||||
|
import decamelize from 'decamelize'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function SettingsSearchBar() {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
useHotkeys(
|
||||||
|
'Ctrl+.',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
inputRef.current?.focus()
|
||||||
|
},
|
||||||
|
{ enableOnFormTags: true }
|
||||||
|
)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const { settings } = useSettingsAuthContext()
|
||||||
|
const settingsAsSearchable = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(settings.state.context).flatMap(
|
||||||
|
([category, categorySettings]) =>
|
||||||
|
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
||||||
|
const s = setting as Setting
|
||||||
|
return ['project', 'user']
|
||||||
|
.filter((l) => s.hideOnLevel !== l)
|
||||||
|
.map((l) => ({
|
||||||
|
category: decamelize(category, { separator: ' ' }),
|
||||||
|
settingName: settingName,
|
||||||
|
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
|
||||||
|
setting: s,
|
||||||
|
level: l,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
),
|
||||||
|
[settings.state.context]
|
||||||
|
)
|
||||||
|
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
|
||||||
|
|
||||||
|
const fuse = new Fuse(settingsAsSearchable, {
|
||||||
|
keys: ['category', 'settingNameDisplay', 'setting.description'],
|
||||||
|
includeScore: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const results = fuse.search(query).map((result) => result.item)
|
||||||
|
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
function handleSelection({
|
||||||
|
level,
|
||||||
|
settingName,
|
||||||
|
}: {
|
||||||
|
category: string
|
||||||
|
settingName: string
|
||||||
|
setting: Setting<unknown>
|
||||||
|
level: string
|
||||||
|
}) {
|
||||||
|
navigate(`?tab=${level}#${settingName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox onChange={handleSelection}>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="flex items-center gap-2 py-0.5 pr-1 pl-2 rounded border-solid border border-primary/10 dark:border-chalkboard-80 focus-within:border-primary dark:focus-within:border-chalkboard-30">
|
||||||
|
<Combobox.Input
|
||||||
|
ref={inputRef}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="w-full bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
|
||||||
|
placeholder="Search settings (^.)"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<CustomIcon
|
||||||
|
name="search"
|
||||||
|
className="w-5 h-5 rounded-sm bg-primary/10 text-primary group-focus-within:bg-primary group-focus-within:text-chalkboard-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
|
||||||
|
{searchResults?.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={`${option.category}-${option.settingName}-${option.level}`}
|
||||||
|
value={option}
|
||||||
|
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
||||||
|
>
|
||||||
|
<p className="flex-grow text-base capitalize m-0 leading-none">
|
||||||
|
{option.level} ·{' '}
|
||||||
|
{decamelize(option.category, { separator: ' ' })} ·{' '}
|
||||||
|
{option.settingNameDisplay}
|
||||||
|
</p>
|
||||||
|
{option.setting.description && (
|
||||||
|
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
||||||
|
{option.setting.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
)
|
||||||
|
}
|
60
src/components/Settings/SettingsSection.tsx
Normal file
60
src/components/Settings/SettingsSection.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { CustomIcon } from 'components/CustomIcon'
|
||||||
|
import Tooltip from 'components/Tooltip'
|
||||||
|
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||||
|
|
||||||
|
interface SettingsSectionProps extends React.HTMLProps<HTMLDivElement> {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
parentLevel?: SettingsLevel | 'default'
|
||||||
|
onFallback?: () => void
|
||||||
|
settingHasChanged?: boolean
|
||||||
|
headingClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSection({
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
parentLevel,
|
||||||
|
settingHasChanged,
|
||||||
|
onFallback,
|
||||||
|
headingClassName = 'text-lg font-normal capitalize tracking-wide',
|
||||||
|
}: SettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className={
|
||||||
|
'group p-2 pl-0 grid grid-cols-2 gap-6 items-start ' +
|
||||||
|
className +
|
||||||
|
(settingHasChanged ? ' border-0 border-l-2 -ml-0.5 border-primary' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="ml-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className={headingClassName}>{title}</h2>
|
||||||
|
{onFallback && parentLevel && settingHasChanged && (
|
||||||
|
<button
|
||||||
|
onClick={onFallback}
|
||||||
|
className="hidden group-hover:block group-focus-within:block border-none p-0 hover:bg-warn-10 dark:hover:bg-warn-80 focus:bg-warn-10 dark:focus:bg-warn-80 focus:outline-none"
|
||||||
|
>
|
||||||
|
<CustomIcon name="refresh" className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Roll back {title}</span>
|
||||||
|
<Tooltip position="right">
|
||||||
|
Roll back to match {parentLevel}
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-xs text-chalkboard-80 dark:text-chalkboard-30">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
28
src/components/Settings/SettingsTabButton.tsx
Normal file
28
src/components/Settings/SettingsTabButton.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
|
||||||
|
|
||||||
|
interface SettingsTabButtonProps {
|
||||||
|
checked: boolean
|
||||||
|
icon: CustomIconName
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTabButton(props: SettingsTabButtonProps) {
|
||||||
|
const { checked, icon, text } = props
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer select-none flex items-center gap-1 p-1 pr-2 -mb-[1px] border-0 border-b ${
|
||||||
|
checked
|
||||||
|
? 'border-primary'
|
||||||
|
: 'border-chalkboard-20 dark:border-chalkboard-30 hover:bg-primary/20 dark:hover:bg-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CustomIcon
|
||||||
|
name={icon}
|
||||||
|
className={
|
||||||
|
'w-5 h-5 ' + (checked ? 'bg-primary !text-chalkboard-10' : '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
39
src/components/Settings/SettingsTabs.tsx
Normal file
39
src/components/Settings/SettingsTabs.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { RadioGroup } from '@headlessui/react'
|
||||||
|
import { SettingsTabButton } from './SettingsTabButton'
|
||||||
|
|
||||||
|
interface SettingsTabButtonProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
showProjectTab: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
showProjectTab,
|
||||||
|
}: SettingsTabButtonProps) {
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90"
|
||||||
|
>
|
||||||
|
<RadioGroup.Option value="user">
|
||||||
|
{({ checked }) => (
|
||||||
|
<SettingsTabButton checked={checked} icon="person" text="User" />
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
{showProjectTab && (
|
||||||
|
<RadioGroup.Option value="project">
|
||||||
|
{({ checked }) => (
|
||||||
|
<SettingsTabButton
|
||||||
|
checked={checked}
|
||||||
|
icon="folder"
|
||||||
|
text="This project"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
)}
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,13 @@
|
|||||||
.toggle {
|
.toggle {
|
||||||
@apply flex items-center gap-2 w-fit;
|
@apply flex items-center gap-2 w-fit;
|
||||||
--toggle-size: 1.25rem;
|
@apply text-chalkboard-110;
|
||||||
|
--toggle-size: 0.75rem;
|
||||||
--padding: 0.25rem;
|
--padding: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .toggle {
|
||||||
|
@apply text-chalkboard-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle:focus-within > span {
|
.toggle:focus-within > span {
|
||||||
@ -13,9 +19,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggle > span {
|
.toggle > span {
|
||||||
@apply relative rounded border border-chalkboard-110 hover:border-chalkboard-100 cursor-pointer;
|
@apply relative rounded border border-chalkboard-70 hover:border-chalkboard-80 cursor-pointer;
|
||||||
width: calc(2 * (var(--toggle-size) + var(--padding)));
|
border-width: var(--border);
|
||||||
height: calc(var(--toggle-size) + var(--padding));
|
width: calc(
|
||||||
|
2 * (var(--toggle-size) + var(--padding) * 2 - var(--border) * 2)
|
||||||
|
);
|
||||||
|
height: calc(var(--toggle-size) + var(--padding) * 2 - var(--border) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .toggle > span {
|
:global(.dark) .toggle > span {
|
||||||
@ -23,18 +32,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggle > span::after {
|
.toggle > span::after {
|
||||||
|
width: var(--toggle-size);
|
||||||
|
height: var(--toggle-size);
|
||||||
|
border-radius: calc(var(--toggle-size) / 8);
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute w-4 h-4 rounded-sm bg-chalkboard-110;
|
@apply absolute bg-chalkboard-70;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
translate: calc(-100% - var(--padding)) -50%;
|
translate: calc(-100% - var(--padding) + var(--border)) -50%;
|
||||||
transition: translate 0.08s ease-out;
|
transition: translate 0.08s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .toggle > span::after {
|
:global(.dark) .toggle > span::after {
|
||||||
@apply bg-chalkboard-10;
|
@apply bg-chalkboard-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle input:checked + span::after {
|
.toggle input:checked + span::after {
|
||||||
translate: calc(50% - var(--padding)) -50%;
|
translate: calc(50% - var(--padding) + var(--border)) -50%;
|
||||||
|
@apply bg-chalkboard-110;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .toggle input:checked + span::after {
|
||||||
|
@apply bg-chalkboard-10;
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,11 @@ export const Toggle = ({
|
|||||||
}: ToggleProps) => {
|
}: ToggleProps) => {
|
||||||
return (
|
return (
|
||||||
<label className={`${styles.toggle} ${className}`}>
|
<label className={`${styles.toggle} ${className}`}>
|
||||||
{offLabel}
|
<p
|
||||||
|
className={checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
|
||||||
|
>
|
||||||
|
{offLabel}
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={name}
|
name={name}
|
||||||
@ -28,7 +32,11 @@ export const Toggle = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<span></span>
|
<span></span>
|
||||||
{onLabel}
|
<p
|
||||||
|
className={!checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
|
||||||
|
>
|
||||||
|
{onLabel}
|
||||||
|
</p>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { OnboardingButtons, useDismiss, useNextClick } from '.'
|
import { OnboardingButtons, useDismiss, useNextClick } from '.'
|
||||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||||
import { useStore } from '../../useStore'
|
import { useStore } from '../../useStore'
|
||||||
import { SettingsSection } from 'routes/Settings'
|
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
import {
|
import {
|
||||||
CameraSystem,
|
CameraSystem,
|
||||||
cameraMouseDragGuards,
|
cameraMouseDragGuards,
|
||||||
cameraSystems,
|
cameraSystems,
|
||||||
} from 'lib/cameraControls'
|
} from 'lib/cameraControls'
|
||||||
|
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||||
|
|
||||||
export default function Units() {
|
export default function Units() {
|
||||||
const { buttonDownInStream } = useStore((s) => ({
|
const { buttonDownInStream } = useStore((s) => ({
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
||||||
import { ActionButton } from 'components/ActionButton'
|
import { ActionButton } from 'components/ActionButton'
|
||||||
import { SettingsSection } from '../Settings'
|
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||||
import { useDismiss, useNextClick } from '.'
|
import { useDismiss, useNextClick } from '.'
|
||||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
import { ActionButton } from '../components/ActionButton'
|
import { ActionButton } from '../components/ActionButton'
|
||||||
import {
|
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||||
SetEventTypes,
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
SettingsLevel,
|
|
||||||
WildcardSetEvent,
|
|
||||||
} from 'lib/settings/settingsTypes'
|
|
||||||
import { Toggle } from 'components/Toggle/Toggle'
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { paths } from 'lib/paths'
|
import { paths } from 'lib/paths'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
@ -14,27 +9,32 @@ import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
|||||||
import { sep } from '@tauri-apps/api/path'
|
import { sep } from '@tauri-apps/api/path'
|
||||||
import { isTauri } from 'lib/isTauri'
|
import { isTauri } from 'lib/isTauri'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import React, { Fragment, useMemo, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef } from 'react'
|
||||||
import { Setting } from 'lib/settings/initialSettings'
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
import decamelize from 'decamelize'
|
import decamelize from 'decamelize'
|
||||||
import { Event } from 'xstate'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { Dialog, RadioGroup, Transition } from '@headlessui/react'
|
import { CustomIcon } from 'components/CustomIcon'
|
||||||
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
|
|
||||||
import Tooltip from 'components/Tooltip'
|
|
||||||
import {
|
import {
|
||||||
getSettingInputType,
|
|
||||||
shouldHideSetting,
|
shouldHideSetting,
|
||||||
shouldShowSettingInput,
|
shouldShowSettingInput,
|
||||||
} from 'lib/settings/settingsUtils'
|
} from 'lib/settings/settingsUtils'
|
||||||
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||||
|
import { SettingsSearchBar } from 'components/Settings/SettingsSearchBar'
|
||||||
|
import { SettingsTabs } from 'components/Settings/SettingsTabs'
|
||||||
|
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||||
|
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
|
||||||
|
|
||||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
|
const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isFileSettings = location.pathname.includes(paths.FILE)
|
const isFileSettings = location.pathname.includes(paths.FILE)
|
||||||
|
const searchParamTab =
|
||||||
|
(searchParams.get('tab') as SettingsLevel) ??
|
||||||
|
(isFileSettings ? 'project' : 'user')
|
||||||
const projectPath =
|
const projectPath =
|
||||||
isFileSettings && isTauri()
|
isFileSettings && isTauri()
|
||||||
? decodeURI(
|
? decodeURI(
|
||||||
@ -44,9 +44,7 @@ export const Settings = () => {
|
|||||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
const [settingsLevel, setSettingsLevel] = useState<SettingsLevel>(
|
|
||||||
isFileSettings ? 'project' : 'user'
|
|
||||||
)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const dotDotSlash = useDotDotSlash()
|
const dotDotSlash = useDotDotSlash()
|
||||||
useHotkeys('esc', () => navigate(dotDotSlash()))
|
useHotkeys('esc', () => navigate(dotDotSlash()))
|
||||||
@ -70,6 +68,20 @@ export const Settings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll to the hash on load if it exists
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('hash', location.hash)
|
||||||
|
if (location.hash) {
|
||||||
|
const element = document.getElementById(location.hash.slice(1))
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
;(
|
||||||
|
element.querySelector('input, select, textarea') as HTMLInputElement
|
||||||
|
)?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.hash])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={true} as={Fragment}>
|
<Transition appear show={true} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -102,42 +114,24 @@ export const Settings = () => {
|
|||||||
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8">
|
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8">
|
||||||
<div className="p-5 pb-0 flex justify-between items-center">
|
<div className="p-5 pb-0 flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<button
|
<div className="flex gap-4 items-start">
|
||||||
onClick={close}
|
<SettingsSearchBar />
|
||||||
className="p-0 m-0 focus:ring-0 focus:outline-none border-none hover:bg-destroy-10 focus:bg-destroy-10 dark:hover:bg-destroy-80/50 dark:focus:bg-destroy-80/50"
|
<button
|
||||||
data-testid="settings-close-button"
|
onClick={close}
|
||||||
>
|
className="p-0 m-0 focus:ring-0 focus:outline-none border-none hover:bg-destroy-10 focus:bg-destroy-10 dark:hover:bg-destroy-80/50 dark:focus:bg-destroy-80/50"
|
||||||
<CustomIcon name="close" className="w-5 h-5" />
|
data-testid="settings-close-button"
|
||||||
</button>
|
>
|
||||||
|
<CustomIcon name="close" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RadioGroup
|
<SettingsTabs
|
||||||
value={settingsLevel}
|
value={searchParamTab}
|
||||||
onChange={setSettingsLevel}
|
onChange={(v) => setSearchParams((p) => ({ ...p, tab: v }))}
|
||||||
className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90"
|
showProjectTab={isFileSettings}
|
||||||
>
|
/>
|
||||||
<RadioGroup.Option value="user">
|
|
||||||
{({ checked }) => (
|
|
||||||
<SettingsTabButton
|
|
||||||
checked={checked}
|
|
||||||
icon="person"
|
|
||||||
text="User"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
{isFileSettings && (
|
|
||||||
<RadioGroup.Option value="project">
|
|
||||||
{({ checked }) => (
|
|
||||||
<SettingsTabButton
|
|
||||||
checked={checked}
|
|
||||||
icon="folder"
|
|
||||||
text="This project"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
)}
|
|
||||||
</RadioGroup>
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-4 overflow-hidden"
|
className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-2 overflow-hidden"
|
||||||
style={{ gridTemplateColumns: 'auto 1fr' }}
|
style={{ gridTemplateColumns: 'auto 1fr' }}
|
||||||
>
|
>
|
||||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||||
@ -146,7 +140,7 @@ export const Settings = () => {
|
|||||||
// Filter out categories that don't have any non-hidden settings
|
// Filter out categories that don't have any non-hidden settings
|
||||||
Object.values(categorySettings).some(
|
Object.values(categorySettings).some(
|
||||||
(setting: Setting) =>
|
(setting: Setting) =>
|
||||||
!shouldHideSetting(setting, settingsLevel)
|
!shouldHideSetting(setting, searchParamTab)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.map(([category]) => (
|
.map(([category]) => (
|
||||||
@ -156,7 +150,7 @@ export const Settings = () => {
|
|||||||
scrollRef.current
|
scrollRef.current
|
||||||
?.querySelector(`#category-${category}`)
|
?.querySelector(`#category-${category}`)
|
||||||
?.scrollIntoView({
|
?.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'center',
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -170,7 +164,7 @@ export const Settings = () => {
|
|||||||
scrollRef.current
|
scrollRef.current
|
||||||
?.querySelector(`#settings-resets`)
|
?.querySelector(`#settings-resets`)
|
||||||
?.scrollIntoView({
|
?.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'center',
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -183,7 +177,7 @@ export const Settings = () => {
|
|||||||
scrollRef.current
|
scrollRef.current
|
||||||
?.querySelector(`#settings-about`)
|
?.querySelector(`#settings-about`)
|
||||||
?.scrollIntoView({
|
?.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'center',
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -193,19 +187,19 @@ export const Settings = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative overflow-y-auto">
|
<div className="relative overflow-y-auto">
|
||||||
<div ref={scrollRef} className="flex flex-col gap-6 px-2">
|
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
|
||||||
{Object.entries(context)
|
{Object.entries(context)
|
||||||
.filter(([_, categorySettings]) =>
|
.filter(([_, categorySettings]) =>
|
||||||
// Filter out categories that don't have any non-hidden settings
|
// Filter out categories that don't have any non-hidden settings
|
||||||
Object.values(categorySettings).some(
|
Object.values(categorySettings).some(
|
||||||
(setting) => !shouldHideSetting(setting, settingsLevel)
|
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.map(([category, categorySettings]) => (
|
.map(([category, categorySettings]) => (
|
||||||
<Fragment key={category}>
|
<Fragment key={category}>
|
||||||
<h2
|
<h2
|
||||||
id={`category-${category}`}
|
id={`category-${category}`}
|
||||||
className="text-2xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||||
>
|
>
|
||||||
{decamelize(category, { separator: ' ' })}
|
{decamelize(category, { separator: ' ' })}
|
||||||
</h2>
|
</h2>
|
||||||
@ -214,44 +208,50 @@ export const Settings = () => {
|
|||||||
// Filter out settings that don't have a Component or inputType
|
// Filter out settings that don't have a Component or inputType
|
||||||
// or are hidden on the current level or the current platform
|
// or are hidden on the current level or the current platform
|
||||||
(item: [string, Setting<unknown>]) =>
|
(item: [string, Setting<unknown>]) =>
|
||||||
shouldShowSettingInput(item[1], settingsLevel)
|
shouldShowSettingInput(item[1], searchParamTab)
|
||||||
)
|
)
|
||||||
.map(([settingName, s]) => {
|
.map(([settingName, s]) => {
|
||||||
const setting = s as Setting
|
const setting = s as Setting
|
||||||
const parentValue =
|
const parentValue =
|
||||||
setting[setting.getParentLevel(settingsLevel)]
|
setting[setting.getParentLevel(searchParamTab)]
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={decamelize(settingName, {
|
title={decamelize(settingName, {
|
||||||
separator: ' ',
|
separator: ' ',
|
||||||
})}
|
})}
|
||||||
key={`${category}-${settingName}-${settingsLevel}`}
|
id={settingName}
|
||||||
|
className={
|
||||||
|
location.hash === `#${settingName}`
|
||||||
|
? 'bg-primary/10 dark:bg-chalkboard-90'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
key={`${category}-${settingName}-${searchParamTab}`}
|
||||||
description={setting.description}
|
description={setting.description}
|
||||||
settingHasChanged={
|
settingHasChanged={
|
||||||
setting[settingsLevel] !== undefined &&
|
setting[searchParamTab] !== undefined &&
|
||||||
setting[settingsLevel] !==
|
setting[searchParamTab] !==
|
||||||
setting.getFallback(settingsLevel)
|
setting.getFallback(searchParamTab)
|
||||||
}
|
}
|
||||||
parentLevel={setting.getParentLevel(
|
parentLevel={setting.getParentLevel(
|
||||||
settingsLevel
|
searchParamTab
|
||||||
)}
|
)}
|
||||||
onFallback={() =>
|
onFallback={() =>
|
||||||
send({
|
send({
|
||||||
type: `set.${category}.${settingName}`,
|
type: `set.${category}.${settingName}`,
|
||||||
data: {
|
data: {
|
||||||
level: settingsLevel,
|
level: searchParamTab,
|
||||||
value:
|
value:
|
||||||
parentValue !== undefined
|
parentValue !== undefined
|
||||||
? parentValue
|
? parentValue
|
||||||
: setting.getFallback(settingsLevel),
|
: setting.getFallback(searchParamTab),
|
||||||
},
|
},
|
||||||
} as SetEventTypes)
|
} as SetEventTypes)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<GeneratedSetting
|
<SettingsFieldInput
|
||||||
category={category}
|
category={category}
|
||||||
settingName={settingName}
|
settingName={settingName}
|
||||||
settingsLevel={settingsLevel}
|
settingsLevel={searchParamTab}
|
||||||
setting={setting}
|
setting={setting}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@ -298,7 +298,7 @@ export const Settings = () => {
|
|||||||
? decodeURIComponent(projectPath)
|
? decodeURIComponent(projectPath)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
showInFolder(paths[settingsLevel])
|
showInFolder(paths[searchParamTab])
|
||||||
}}
|
}}
|
||||||
icon={{
|
icon={{
|
||||||
icon: 'folder',
|
icon: 'folder',
|
||||||
@ -368,226 +368,3 @@ export const Settings = () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsSectionProps extends React.PropsWithChildren {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
parentLevel?: SettingsLevel | 'default'
|
|
||||||
onFallback?: () => void
|
|
||||||
settingHasChanged?: boolean
|
|
||||||
headingClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
parentLevel,
|
|
||||||
settingHasChanged,
|
|
||||||
onFallback,
|
|
||||||
headingClassName = 'text-base font-normal capitalize tracking-wide',
|
|
||||||
}: SettingsSectionProps) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={
|
|
||||||
'group grid grid-cols-2 gap-6 items-start ' +
|
|
||||||
className +
|
|
||||||
(settingHasChanged ? ' border-0 border-l-2 -ml-0.5 border-primary' : '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="ml-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className={headingClassName}>{title}</h2>
|
|
||||||
{onFallback && parentLevel && settingHasChanged && (
|
|
||||||
<button
|
|
||||||
onClick={onFallback}
|
|
||||||
className="hidden group-hover:block group-focus-within:block border-none p-0 hover:bg-warn-10 dark:hover:bg-warn-80 focus:bg-warn-10 dark:focus:bg-warn-80 focus:outline-none"
|
|
||||||
>
|
|
||||||
<CustomIcon name="refresh" className="w-4 h-4" />
|
|
||||||
<span className="sr-only">Roll back {title}</span>
|
|
||||||
<Tooltip position="right">
|
|
||||||
Roll back to match {parentLevel}
|
|
||||||
</Tooltip>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-2 text-xs text-chalkboard-80 dark:text-chalkboard-30">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneratedSettingProps {
|
|
||||||
// We don't need the fancy types here,
|
|
||||||
// it doesn't help us with autocomplete or anything
|
|
||||||
category: string
|
|
||||||
settingName: string
|
|
||||||
settingsLevel: SettingsLevel
|
|
||||||
setting: Setting<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeneratedSetting({
|
|
||||||
category,
|
|
||||||
settingName,
|
|
||||||
settingsLevel,
|
|
||||||
setting,
|
|
||||||
}: GeneratedSettingProps) {
|
|
||||||
const {
|
|
||||||
settings: { context, send },
|
|
||||||
} = useSettingsAuthContext()
|
|
||||||
const options = useMemo(() => {
|
|
||||||
return setting.commandConfig &&
|
|
||||||
'options' in setting.commandConfig &&
|
|
||||||
setting.commandConfig.options
|
|
||||||
? setting.commandConfig.options instanceof Array
|
|
||||||
? setting.commandConfig.options
|
|
||||||
: setting.commandConfig.options(
|
|
||||||
{
|
|
||||||
argumentsToSubmit: {
|
|
||||||
level: settingsLevel,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
context
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
}, [setting, settingsLevel, context])
|
|
||||||
const inputType = getSettingInputType(setting)
|
|
||||||
|
|
||||||
switch (inputType) {
|
|
||||||
case 'component':
|
|
||||||
return (
|
|
||||||
setting.Component && (
|
|
||||||
<setting.Component
|
|
||||||
value={setting[settingsLevel] || setting.getFallback(settingsLevel)}
|
|
||||||
updateValue={(newValue) => {
|
|
||||||
send({
|
|
||||||
type: `set.${category}.${settingName}`,
|
|
||||||
data: {
|
|
||||||
level: settingsLevel,
|
|
||||||
value: newValue,
|
|
||||||
},
|
|
||||||
} as unknown as Event<WildcardSetEvent>)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<Toggle
|
|
||||||
offLabel="Off"
|
|
||||||
onLabel="On"
|
|
||||||
onChange={(e) =>
|
|
||||||
send({
|
|
||||||
type: `set.${category}.${settingName}`,
|
|
||||||
data: {
|
|
||||||
level: settingsLevel,
|
|
||||||
value: Boolean(e.target.checked),
|
|
||||||
},
|
|
||||||
} as SetEventTypes)
|
|
||||||
}
|
|
||||||
checked={Boolean(
|
|
||||||
setting[settingsLevel] !== undefined
|
|
||||||
? setting[settingsLevel]
|
|
||||||
: setting.getFallback(settingsLevel)
|
|
||||||
)}
|
|
||||||
name={`${category}-${settingName}`}
|
|
||||||
data-testid={`${category}-${settingName}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 'options':
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
name={`${category}-${settingName}`}
|
|
||||||
data-testid={`${category}-${settingName}`}
|
|
||||||
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
|
|
||||||
value={String(
|
|
||||||
setting[settingsLevel] || setting.getFallback(settingsLevel)
|
|
||||||
)}
|
|
||||||
onChange={(e) =>
|
|
||||||
send({
|
|
||||||
type: `set.${category}.${settingName}`,
|
|
||||||
data: {
|
|
||||||
level: settingsLevel,
|
|
||||||
value: e.target.value,
|
|
||||||
},
|
|
||||||
} as unknown as Event<WildcardSetEvent>)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{options &&
|
|
||||||
options.length > 0 &&
|
|
||||||
options.map((option) => (
|
|
||||||
<option key={option.name} value={String(option.value)}>
|
|
||||||
{option.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
case 'string':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
name={`${category}-${settingName}`}
|
|
||||||
data-testid={`${category}-${settingName}`}
|
|
||||||
type="text"
|
|
||||||
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
|
|
||||||
defaultValue={String(
|
|
||||||
setting[settingsLevel] || setting.getFallback(settingsLevel)
|
|
||||||
)}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (
|
|
||||||
setting[settingsLevel] === undefined
|
|
||||||
? setting.getFallback(settingsLevel) !== e.target.value
|
|
||||||
: setting[settingsLevel] !== e.target.value
|
|
||||||
) {
|
|
||||||
send({
|
|
||||||
type: `set.${category}.${settingName}`,
|
|
||||||
data: {
|
|
||||||
level: settingsLevel,
|
|
||||||
value: e.target.value,
|
|
||||||
},
|
|
||||||
} as unknown as Event<WildcardSetEvent>)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<p className="text-destroy-70 dark:text-destroy-20">
|
|
||||||
No component or input type found for setting {settingName} in category{' '}
|
|
||||||
{category}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsTabButtonProps {
|
|
||||||
checked: boolean
|
|
||||||
icon: CustomIconName
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsTabButton(props: SettingsTabButtonProps) {
|
|
||||||
const { checked, icon, text } = props
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`cursor-pointer select-none flex items-center gap-1 p-1 pr-2 -mb-[1px] border-0 border-b ${
|
|
||||||
checked
|
|
||||||
? 'border-primary'
|
|
||||||
: 'border-chalkboard-20 dark:border-chalkboard-30 hover:bg-primary/20 dark:hover:bg-primary/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CustomIcon
|
|
||||||
name={icon}
|
|
||||||
className={
|
|
||||||
'w-5 h-5 ' + (checked ? 'bg-primary !text-chalkboard-10' : '')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user