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 {
|
||||
@apply flex items-center gap-2 w-fit;
|
||||
--toggle-size: 1.25rem;
|
||||
@apply text-chalkboard-110;
|
||||
--toggle-size: 0.75rem;
|
||||
--padding: 0.25rem;
|
||||
--border: 1px;
|
||||
}
|
||||
|
||||
:global(.dark) .toggle {
|
||||
@apply text-chalkboard-10;
|
||||
}
|
||||
|
||||
.toggle:focus-within > span {
|
||||
@ -13,9 +19,12 @@
|
||||
}
|
||||
|
||||
.toggle > span {
|
||||
@apply relative rounded border border-chalkboard-110 hover:border-chalkboard-100 cursor-pointer;
|
||||
width: calc(2 * (var(--toggle-size) + var(--padding)));
|
||||
height: calc(var(--toggle-size) + var(--padding));
|
||||
@apply relative rounded border border-chalkboard-70 hover:border-chalkboard-80 cursor-pointer;
|
||||
border-width: var(--border);
|
||||
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 {
|
||||
@ -23,18 +32,26 @@
|
||||
}
|
||||
|
||||
.toggle > span::after {
|
||||
width: var(--toggle-size);
|
||||
height: var(--toggle-size);
|
||||
border-radius: calc(var(--toggle-size) / 8);
|
||||
content: '';
|
||||
@apply absolute w-4 h-4 rounded-sm bg-chalkboard-110;
|
||||
@apply absolute bg-chalkboard-70;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: calc(-100% - var(--padding)) -50%;
|
||||
translate: calc(-100% - var(--padding) + var(--border)) -50%;
|
||||
transition: translate 0.08s ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .toggle > span::after {
|
||||
@apply bg-chalkboard-10;
|
||||
@apply bg-chalkboard-50;
|
||||
}
|
||||
|
||||
.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) => {
|
||||
return (
|
||||
<label className={`${styles.toggle} ${className}`}>
|
||||
<p
|
||||
className={checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
|
||||
>
|
||||
{offLabel}
|
||||
</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
@ -28,7 +32,11 @@ export const Toggle = ({
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span></span>
|
||||
<p
|
||||
className={!checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
|
||||
>
|
||||
{onLabel}
|
||||
</p>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { OnboardingButtons, useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||
import { useStore } from '../../useStore'
|
||||
import { SettingsSection } from 'routes/Settings'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import {
|
||||
CameraSystem,
|
||||
cameraMouseDragGuards,
|
||||
cameraSystems,
|
||||
} from 'lib/cameraControls'
|
||||
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||
|
||||
export default function Units() {
|
||||
const { buttonDownInStream } = useStore((s) => ({
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { SettingsSection } from '../Settings'
|
||||
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||
import { useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import {
|
||||
SetEventTypes,
|
||||
SettingsLevel,
|
||||
WildcardSetEvent,
|
||||
} from 'lib/settings/settingsTypes'
|
||||
import { Toggle } from 'components/Toggle/Toggle'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
@ -14,27 +9,32 @@ import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
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 decamelize from 'decamelize'
|
||||
import { Event } from 'xstate'
|
||||
import { Dialog, RadioGroup, Transition } from '@headlessui/react'
|
||||
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { CustomIcon } from 'components/CustomIcon'
|
||||
import {
|
||||
getSettingInputType,
|
||||
shouldHideSetting,
|
||||
shouldShowSettingInput,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
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 Settings = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
|
||||
const location = useLocation()
|
||||
const isFileSettings = location.pathname.includes(paths.FILE)
|
||||
const searchParamTab =
|
||||
(searchParams.get('tab') as SettingsLevel) ??
|
||||
(isFileSettings ? 'project' : 'user')
|
||||
const projectPath =
|
||||
isFileSettings && isTauri()
|
||||
? decodeURI(
|
||||
@ -44,9 +44,7 @@ export const Settings = () => {
|
||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||
)
|
||||
: undefined
|
||||
const [settingsLevel, setSettingsLevel] = useState<SettingsLevel>(
|
||||
isFileSettings ? 'project' : 'user'
|
||||
)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const dotDotSlash = useDotDotSlash()
|
||||
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 (
|
||||
<Transition appear show={true} as={Fragment}>
|
||||
<Dialog
|
||||
@ -102,6 +114,8 @@ 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">
|
||||
<div className="p-5 pb-0 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<div className="flex gap-4 items-start">
|
||||
<SettingsSearchBar />
|
||||
<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"
|
||||
@ -110,34 +124,14 @@ export const Settings = () => {
|
||||
<CustomIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={settingsLevel}
|
||||
onChange={setSettingsLevel}
|
||||
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"
|
||||
</div>
|
||||
<SettingsTabs
|
||||
value={searchParamTab}
|
||||
onChange={(v) => setSearchParams((p) => ({ ...p, tab: v }))}
|
||||
showProjectTab={isFileSettings}
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
{isFileSettings && (
|
||||
<RadioGroup.Option value="project">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="folder"
|
||||
text="This project"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
</RadioGroup>
|
||||
<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' }}
|
||||
>
|
||||
<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
|
||||
Object.values(categorySettings).some(
|
||||
(setting: Setting) =>
|
||||
!shouldHideSetting(setting, settingsLevel)
|
||||
!shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category]) => (
|
||||
@ -156,7 +150,7 @@ export const Settings = () => {
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
@ -170,7 +164,7 @@ export const Settings = () => {
|
||||
scrollRef.current
|
||||
?.querySelector(`#settings-resets`)
|
||||
?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
@ -183,7 +177,7 @@ export const Settings = () => {
|
||||
scrollRef.current
|
||||
?.querySelector(`#settings-about`)
|
||||
?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
@ -193,19 +187,19 @@ export const Settings = () => {
|
||||
</button>
|
||||
</div>
|
||||
<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)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting) => !shouldHideSetting(setting, settingsLevel)
|
||||
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category, categorySettings]) => (
|
||||
<Fragment key={category}>
|
||||
<h2
|
||||
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: ' ' })}
|
||||
</h2>
|
||||
@ -214,44 +208,50 @@ export const Settings = () => {
|
||||
// Filter out settings that don't have a Component or inputType
|
||||
// or are hidden on the current level or the current platform
|
||||
(item: [string, Setting<unknown>]) =>
|
||||
shouldShowSettingInput(item[1], settingsLevel)
|
||||
shouldShowSettingInput(item[1], searchParamTab)
|
||||
)
|
||||
.map(([settingName, s]) => {
|
||||
const setting = s as Setting
|
||||
const parentValue =
|
||||
setting[setting.getParentLevel(settingsLevel)]
|
||||
setting[setting.getParentLevel(searchParamTab)]
|
||||
return (
|
||||
<SettingsSection
|
||||
title={decamelize(settingName, {
|
||||
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}
|
||||
settingHasChanged={
|
||||
setting[settingsLevel] !== undefined &&
|
||||
setting[settingsLevel] !==
|
||||
setting.getFallback(settingsLevel)
|
||||
setting[searchParamTab] !== undefined &&
|
||||
setting[searchParamTab] !==
|
||||
setting.getFallback(searchParamTab)
|
||||
}
|
||||
parentLevel={setting.getParentLevel(
|
||||
settingsLevel
|
||||
searchParamTab
|
||||
)}
|
||||
onFallback={() =>
|
||||
send({
|
||||
type: `set.${category}.${settingName}`,
|
||||
data: {
|
||||
level: settingsLevel,
|
||||
level: searchParamTab,
|
||||
value:
|
||||
parentValue !== undefined
|
||||
? parentValue
|
||||
: setting.getFallback(settingsLevel),
|
||||
: setting.getFallback(searchParamTab),
|
||||
},
|
||||
} as SetEventTypes)
|
||||
}
|
||||
>
|
||||
<GeneratedSetting
|
||||
<SettingsFieldInput
|
||||
category={category}
|
||||
settingName={settingName}
|
||||
settingsLevel={settingsLevel}
|
||||
settingsLevel={searchParamTab}
|
||||
setting={setting}
|
||||
/>
|
||||
</SettingsSection>
|
||||
@ -298,7 +298,7 @@ export const Settings = () => {
|
||||
? decodeURIComponent(projectPath)
|
||||
: undefined
|
||||
)
|
||||
showInFolder(paths[settingsLevel])
|
||||
showInFolder(paths[searchParamTab])
|
||||
}}
|
||||
icon={{
|
||||
icon: 'folder',
|
||||
@ -368,226 +368,3 @@ export const Settings = () => {
|
||||
</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