diff --git a/apps/client/src/features/app-settings/__tests__/useAppSettingsMenu.test.ts b/apps/client/src/features/app-settings/__tests__/useAppSettingsMenu.test.ts new file mode 100644 index 000000000..cd556ea34 --- /dev/null +++ b/apps/client/src/features/app-settings/__tests__/useAppSettingsMenu.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { matchesSettingsOptionQuery } from '../useAppSettingsMenu'; + +describe('matchesSettingsOptionQuery', () => { + const option = { + id: 'settings__general', + label: 'General settings', + keywords: ['pin', 'time zone'], + }; + + it('does not match an empty or whitespace-only query', () => { + expect(matchesSettingsOptionQuery(option, '')).toBe(false); + expect(matchesSettingsOptionQuery(option, ' ')).toBe(false); + }); + + it('matches keywords case-insensitively', () => { + expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true); + expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true); + }); +}); diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 265409b70..20dd0c838 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -1,9 +1,4 @@ -.corner { - position: fixed; - top: 6rem; - right: 4rem; - z-index: $zindex-floating; -} +$content-max-width: 1280px; .contentWrapper { display: flex; @@ -14,9 +9,29 @@ position: relative; } +.corner { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + // width is needed so the button can align to the right of the capped + // column, auto margins alone would shrink this to fit and centre it + width: 100%; + max-width: $content-max-width; + margin-inline: auto; + padding: 0 1rem 0.5rem; + box-sizing: border-box; +} + .content { - margin: 1rem; + box-sizing: border-box; + // as with .corner, the width is needed to fill the capped column, otherwise + // auto margins shrink this to fit and each panel ends up its own width + width: 100%; + max-width: $content-max-width; + margin: 0 auto 1rem; + padding-inline: 1rem; overflow-y: auto; flex-grow: 1; - padding-bottom: 300px; + // room for the last section to scroll to the top of the viewport + padding-bottom: 40vh; } diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx index 42d028d9c..fe7930124 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx @@ -12,12 +12,12 @@ interface PanelContentProps { export default function PanelContent({ onClose, children }: PropsWithChildren) { return (
-
{children}
+
{children}
); } diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss index 6326a1576..d01fbb519 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss +++ b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss @@ -1,3 +1,13 @@ +.container { + width: min(30vw, 300px); + flex: 0 0 min(30vw, 300px); + min-width: min(30vw, 300px); + display: flex; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; +} + .tabs, ul { list-style: none; @@ -6,22 +16,20 @@ ul { } .tabs { - width: min(30vw, 300px); - flex: 0 0 min(30vw, 300px); - min-width: min(30vw, 300px); display: flex; flex-direction: column; - overflow-y: auto; } .primary, .secondary { padding: 0.25rem 1rem; margin-right: 1rem; + border-radius: 2px; - &:focus { + &:focus-visible { background-color: $gray-1000; - outline: 0; + outline: 2px solid $blue-500; + outline-offset: -2px; } &:hover { @@ -32,14 +40,21 @@ ul { .primary { font-size: 1rem; - border-radius: 2px; display: flex; align-items: center; gap: 0.5rem; + border-left: 3px solid transparent; + + &.groupActive { + color: $blue-400; + font-weight: 600; + } &.active { - color: $blue-400; - background-color: $gray-1100; + color: $ui-white; + border-left-color: $blue-400; + background-color: rgba($blue-500, 0.16); + font-weight: 600; } &.highlight { @@ -62,6 +77,9 @@ ul { font-size: $inner-section-text-size; &.active { - color: $blue-400; + color: $ui-white; + border-left-color: $blue-400; + background-color: rgba($blue-500, 0.16); + font-weight: 600; } } diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.tsx b/apps/client/src/features/app-settings/panel-list/PanelList.tsx index 01dc977c0..c305bba6b 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.tsx +++ b/apps/client/src/features/app-settings/panel-list/PanelList.tsx @@ -1,10 +1,18 @@ -import { Fragment } from 'react'; +import { Fragment, useState } from 'react'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import { isKeyEnter } from '../../../common/utils/keyEvent'; import { cx } from '../../../common/utils/styleUtils'; -import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu'; +import * as Panel from '../panel-utils/PanelUtils'; +import { + filterSettingsOptions, + matchesSettingsOptionQuery, + SettingsOption, + SettingsOptionId, + useAppSettingsMenu, +} from '../useAppSettingsMenu'; import useAppSettingsNavigation from '../useAppSettingsNavigation'; +import SettingsSearch from './SettingsSearch'; import style from './PanelList.module.scss'; @@ -16,23 +24,57 @@ interface PanelListProps extends PanelBaseProps { selectedPanel: string; } +/** Returns the first matching setting, preferring a matching child over its non-matching group. */ +function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null { + const firstResult = results[0]; + if (!firstResult) { + return null; + } + + if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) { + return firstResult.id as SettingsOptionId; + } + + return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null; +} + export default function PanelList({ selectedPanel, location }: PanelListProps) { const { options } = useAppSettingsMenu(); + const { setLocation } = useAppSettingsNavigation(); + const [query, setQuery] = useState(''); + + const results = filterSettingsOptions(options, query); + + const handleSearchSubmit = () => { + const target = getFirstResultId(results, query); + if (target) { + setLocation(target); + setQuery(''); + } + }; return ( - +
+ + + {results.length === 0 ? ( + + ) : ( +
    + {results.map((panel) => { + const isSelected = selectedPanel === panel.id; + if (panel.highlight) { + return ( + }> + + + ); + } + return ; + })} +
+ )} +
); } @@ -44,7 +86,15 @@ interface PanelListItemProps { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { const { setLocation } = useAppSettingsNavigation(); - const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]); + const hasSelectedChild = Boolean( + isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location), + ); + const classes = cx([ + style.primary, + isSelected && !hasSelectedChild && style.active, + hasSelectedChild && style.groupActive, + panel.highlight && style.highlight, + ]); return ( @@ -75,6 +125,7 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { } }} className={secondaryClasses} + tabIndex={0} role='button' > {secondary.label} diff --git a/apps/client/src/features/app-settings/panel-list/SettingsSearch.module.scss b/apps/client/src/features/app-settings/panel-list/SettingsSearch.module.scss new file mode 100644 index 000000000..571ea59b9 --- /dev/null +++ b/apps/client/src/features/app-settings/panel-list/SettingsSearch.module.scss @@ -0,0 +1,24 @@ +.search { + position: relative; + display: flex; + align-items: center; + flex: 0 0 auto; + margin: 0.25rem 1rem 0.25rem 0.25rem; +} + +.icon { + position: absolute; + left: 0.5rem; + color: $gray-400; + pointer-events: none; +} + +.input { + padding-left: 2rem; + padding-right: 2rem; +} + +.clear { + position: absolute; + right: 0.25rem; +} diff --git a/apps/client/src/features/app-settings/panel-list/SettingsSearch.tsx b/apps/client/src/features/app-settings/panel-list/SettingsSearch.tsx new file mode 100644 index 000000000..f3dc08c0c --- /dev/null +++ b/apps/client/src/features/app-settings/panel-list/SettingsSearch.tsx @@ -0,0 +1,60 @@ +import { KeyboardEvent, useRef } from 'react'; +import { IoClose, IoSearch } from 'react-icons/io5'; + +import IconButton from '../../../common/components/buttons/IconButton'; +import Input from '../../../common/components/input/input/Input'; + +import style from './SettingsSearch.module.scss'; + +interface SettingsSearchProps { + query: string; + onQueryChange: (query: string) => void; + onSubmit: () => void; +} + +export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) { + const searchRef = useRef(null); + const isSearching = query.trim().length > 0; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isSearching) { + // Do not let the settings panel close while the user is clearing a search. + event.stopPropagation(); + onQueryChange(''); + return; + } + + if (event.key === 'Enter') { + onSubmit(); + } + }; + + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + placeholder='Search settings' + className={style.input} + fluid + /> + {isSearching && ( + { + onQueryChange(''); + searchRef.current?.focus(); + }} + > + + + )} +
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx index f47ef304b..7f2d8273a 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx @@ -1,29 +1,21 @@ import { ChangeEvent, useRef, useState } from 'react'; import { IoAdd } from 'react-icons/io5'; -import { useSearchParams } from 'react-router'; import { uploadProjectFile } from '../../../../common/api/db'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import * as Panel from '../../panel-utils/PanelUtils'; -import ProjectCreateForm from './ProjectCreateForm'; +import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import ProjectList from './ProjectList'; export default function ManageProjects() { - const [searchParams, setSearchParams] = useSearchParams(); + const { setLocation } = useAppSettingsNavigation(); const [error, setError] = useState(''); const [loading, setLoading] = useState<'import' | null>(null); const fileInputRef = useRef(null); - const isCreatingProject = searchParams.get('new') === 'true'; - - const handleToggleCreate = () => { - searchParams.set('new', isCreatingProject ? 'false' : 'true'); - setSearchParams(searchParams); - }; - const handleSelectFile = () => { fileInputRef.current?.click(); }; @@ -49,11 +41,6 @@ export default function ManageProjects() { setLoading(null); }; - const handleCloseForm = () => { - searchParams.delete('new'); - setSearchParams(searchParams); - }; - return ( Manage projects - - {error && {error}} - {isCreatingProject && } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx deleted file mode 100644 index 90519f303..000000000 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; - -import { createProject } from '../../../../common/api/db'; -import { maybeAxiosError } from '../../../../common/api/utils'; -import Button from '../../../../common/components/buttons/Button'; -import Input from '../../../../common/components/input/input/Input'; -import { preventEscape } from '../../../../common/utils/keyEvent'; -import * as Panel from '../../panel-utils/PanelUtils'; - -import style from './ProjectPanel.module.scss'; - -interface ProjectCreateFromProps { - onClose: () => void; -} - -type ProjectCreateFormValues = { - title?: string; - description?: string; - info?: string; - url?: string; - custom?: { title: string; value: string }[]; -}; - -export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) { - const [error, setError] = useState(null); - - const { - handleSubmit, - register, - formState: { isSubmitting, isValid }, - setFocus, - } = useForm({ - defaultValues: { title: '' }, - values: { title: '' }, - resetOptions: { - keepDirtyValues: true, - }, - }); - - // set focus to first field - useEffect(() => { - setFocus('title'); - }, [setFocus]); - - const handleSubmitCreate = async (values: ProjectCreateFormValues) => { - try { - setError(null); - - const filename = values.title ?? 'untitled'; - - await createProject({ filename }); - - onClose(); - } catch (error) { - setError(maybeAxiosError(error)); - } - }; - - return ( - preventEscape(event, onClose)} - > - - Create new project - - - - - - {error && {error}} - - Project title - - - - ); -} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index f41a56f17..eaee0c59d 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -55,7 +55,7 @@ margin-bottom: 1rem; } -.empty { +.loaderBox { height: 300px; position: relative; } diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 370f0cf92..8c7b57f0e 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -3,64 +3,107 @@ import { useMemo } from 'react'; import useAppVersion from '../../common/hooks-query/useAppVersion'; import { isDocker } from '../../externals'; -export type SettingsOption = { +type SettingsOptionBase = { id: string; label: string; - secondary?: Readonly; highlight?: string; }; +export type SettingsOption = + | (SettingsOptionBase & { secondary: Readonly; keywords?: never }) + | (SettingsOptionBase & { secondary?: never; keywords: Readonly }); + const staticOptions = [ { id: 'settings', label: 'Settings', secondary: [ - { id: 'settings__data', label: 'Project data' }, - { id: 'settings__general', label: 'General settings' }, - { id: 'settings__view', label: 'View settings' }, - { id: 'settings__custom-views', label: 'Custom views' }, - { id: 'settings__mcp', label: 'MCP Server' }, - ...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]), + { id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] }, + { + id: 'settings__general', + label: 'General settings', + keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'], + }, + { + id: 'settings__view', + label: 'View settings', + keywords: ['css', 'style', 'theme', 'translation', 'freeze', 'overtime'], + }, + { id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] }, + { id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model'] }, + ...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address'] }]), ], }, { id: 'project', label: 'Project', secondary: [ - { id: 'project__create', label: 'Create...' }, - { id: 'project__list', label: 'Manage projects' }, + { id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] }, + { + id: 'project__list', + label: 'Manage projects', + keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'], + }, ], }, { id: 'manage', label: 'Project settings', secondary: [ - { id: 'manage__defaults', label: 'Rundown defaults' }, - { id: 'manage__custom', label: 'Custom fields' }, - { id: 'manage__rundowns', label: 'Manage rundowns' }, - { id: 'manage__sheets', label: 'Import spreadsheet' }, - { id: 'manage__sheets', label: 'Sync with Google Sheet' }, + { + id: 'manage__defaults', + label: 'Rundown defaults', + keywords: ['duration', 'warning', 'danger', 'defaults'], + }, + { + id: 'manage__custom', + label: 'Custom fields', + keywords: ['metadata', 'columns', 'extra data', 'image field'], + }, + { + id: 'manage__rundowns', + label: 'Manage rundowns', + keywords: ['rundown', 'xlsx', 'excel', 'load', 'export'], + }, + { + id: 'manage__sheets', + label: 'Import spreadsheet', + keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'], + }, ], }, { id: 'automation', label: 'Automation', secondary: [ - { id: 'automation__settings', label: 'Automation settings' }, - { id: 'automation__automations', label: 'Manage automations' }, - { id: 'automation__triggers', label: 'Manage triggers' }, + { + id: 'automation__settings', + label: 'Automation settings', + keywords: ['osc input', 'port', 'enable', 'remote control'], + }, + { + id: 'automation__automations', + label: 'Manage automations', + keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'], + }, + { + id: 'automation__triggers', + label: 'Manage triggers', + keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'], + }, ], }, { id: 'sharing', label: 'Sharing and reporting', secondary: [ - { id: 'sharing__presets', label: 'URL Presets' }, + { id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] }, { id: 'sharing__link', label: 'Share link', + keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'], }, - { id: 'sharing__report', label: 'Runtime report' }, + { id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] }, ], }, { @@ -70,20 +113,24 @@ const staticOptions = [ { id: 'network__log', label: 'Event log', + keywords: ['debug', 'errors', 'console', 'export log'], }, { id: 'network__clients', label: 'Manage clients', + keywords: ['redirect', 'identify', 'rename client', 'connected'], }, ], }, { id: 'about', label: 'About', + keywords: ['version', 'update', 'licence', 'license', 'credits'], }, { id: 'shutdown', label: 'Shutdown', + keywords: ['quit', 'exit', 'close ontime'], }, ] as const; @@ -92,6 +139,51 @@ export type SettingsOptionId = | (typeof staticOptions)[number]['id'] | Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id']; +function sanitiseSearchInput(query: string): string { + return query.trim().toLowerCase(); +} + +export function matchesSettingsOptionQuery(option: SettingsOption, query: string): boolean { + const sanitisedQuery = sanitiseSearchInput(query); + if (!sanitisedQuery) { + return false; + } + + const sanitisedLabel = sanitiseSearchInput(option.label); + if (sanitisedLabel.includes(sanitisedQuery)) { + return true; + } + + // check keywords + return Boolean(option.keywords?.some((keyword) => keyword.includes(sanitisedQuery))); +} + +/** + * Filters the settings menu against a user query. + * A group is kept if it matches itself (with all its children) or if any of its children match. + */ +export function filterSettingsOptions(options: Readonly, query: string): SettingsOption[] { + const sanitisedQuery = sanitiseSearchInput(query); + if (!sanitisedQuery) { + return [...options]; + } + + return options.reduce((accumulator, option) => { + if (matchesSettingsOptionQuery(option, sanitisedQuery)) { + accumulator.push(option); + return accumulator; + } + + if (option.secondary) { + const secondary = option.secondary.filter((child) => matchesSettingsOptionQuery(child, sanitisedQuery)); + if (secondary.length) { + accumulator.push({ ...option, secondary }); + } + } + return accumulator; + }, []); +} + export function useAppSettingsMenu() { const { data } = useAppVersion();