From 2db5b308455ad8d0755042fa327da1329cd3592e Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 1 Aug 2026 17:09:16 +0200 Subject: [PATCH 1/4] refactor(ui): extend link and modal variants --- .../components/dialog/Dialog.module.scss | 12 ++++-- .../common/components/modal/Modal.module.scss | 42 +++++++++++++------ .../src/common/components/modal/Modal.tsx | 4 +- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/client/src/common/components/dialog/Dialog.module.scss b/apps/client/src/common/components/dialog/Dialog.module.scss index 21e5704d53..828ff130db 100644 --- a/apps/client/src/common/components/dialog/Dialog.module.scss +++ b/apps/client/src/common/components/dialog/Dialog.module.scss @@ -1,12 +1,16 @@ .dialog { position: fixed; - top: 10%; + top: 1rem; left: 50%; transform: translateX(-50%); + box-sizing: border-box; + width: min(600px, 90vw); + max-height: calc(90vh - 1rem); + display: flex; + flex-direction: column; padding-inline: 1rem; - min-width: min(600px, 90vw); background-color: $gray-1250; color: $ui-white; @@ -43,7 +47,8 @@ flex-direction: column; gap: 0.5rem; - max-height: min(80vh, 600px); + flex: 1; + min-height: 0; overflow-y: auto; } @@ -53,4 +58,5 @@ align-items: center; justify-content: end; gap: 1rem; + flex-wrap: wrap; } diff --git a/apps/client/src/common/components/modal/Modal.module.scss b/apps/client/src/common/components/modal/Modal.module.scss index 8c050857f7..8bcea3d37c 100644 --- a/apps/client/src/common/components/modal/Modal.module.scss +++ b/apps/client/src/common/components/modal/Modal.module.scss @@ -4,10 +4,13 @@ left: 50%; transform: translateX(-50%); - padding-inline: 1rem; - min-width: min(880px, 90vw); + box-sizing: border-box; + width: min(880px, 90vw); min-height: min(200px, 10vh); - max-width: min(1200px, 90vw); + max-height: calc(90vh - 1rem); + + display: flex; + flex-direction: column; background-color: $gray-1250; color: $ui-white; @@ -17,14 +20,21 @@ outline: none; } +.compact { + min-width: 0; + width: min(680px, 90vw); +} + +.small { + min-width: 0; + width: min(480px, 90vw); +} + .wide { top: 4vh; - min-width: min(1280px, 96vw); - max-width: min(1800px, 98vw); - height: 88vh; - max-height: 88vh; - display: flex; - flex-direction: column; + width: min(1800px, 98vw); + height: min(88vh, calc(96vh - 1rem)); + min-height: 0; } .backdrop { @@ -41,15 +51,19 @@ .title { padding-block: 1rem; + padding-inline: 1rem; display: flex; align-items: center; justify-content: space-between; + gap: 1rem; } .titleText { + min-width: 0; font-size: 1rem; font-weight: 600; + overflow-wrap: anywhere; } .body { @@ -57,21 +71,23 @@ flex-direction: column; gap: 0.5rem; - max-height: 60vh; + flex: 1; + min-height: 0; + padding: 0.5rem 1rem; overflow-y: auto; + scroll-padding-block: 0.5rem; } .wide .body { - flex: 1; - min-height: 0; - max-height: none; overflow: hidden; } .footer { padding-block: 1rem; + padding-inline: 1rem; display: flex; align-items: center; justify-content: end; gap: 1rem; + flex-wrap: wrap; } diff --git a/apps/client/src/common/components/modal/Modal.tsx b/apps/client/src/common/components/modal/Modal.tsx index ae0a741555..1e51936f72 100644 --- a/apps/client/src/common/components/modal/Modal.tsx +++ b/apps/client/src/common/components/modal/Modal.tsx @@ -12,7 +12,7 @@ interface ModalProps { title?: string; showCloseButton?: boolean; showBackdrop?: boolean; - size?: 'default' | 'wide'; + size?: 'small' | 'compact' | 'default' | 'wide'; bodyElements: ReactNode; footerElements?: ReactNode; onClose: () => void; @@ -38,7 +38,7 @@ export default function Modal({ > {showBackdrop && } - +
{title ? {title} :
} {showCloseButton && ( From 2667308a484c05fc9c28dda051194b9e7e5b37b0 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 1 Aug 2026 17:09:34 +0200 Subject: [PATCH 2/4] feat(settings): add searchable navigation --- .../__tests__/useAppSettingsMenu.test.ts | 21 +++ .../panel-content/PanelContent.module.scss | 31 ++-- .../panel-content/PanelContent.tsx | 2 +- .../panel-list/PanelList.module.scss | 38 +++-- .../app-settings/panel-list/PanelList.tsx | 83 ++++++++--- .../panel-list/SettingsSearch.module.scss | 24 ++++ .../panel-list/SettingsSearch.tsx | 60 ++++++++ .../panel/project-panel/ManageProjects.tsx | 26 +--- .../panel/project-panel/ProjectCreateForm.tsx | 84 ----------- .../project-panel/ProjectPanel.module.scss | 2 +- .../app-settings/useAppSettingsMenu.tsx | 132 +++++++++++++++--- 11 files changed, 341 insertions(+), 162 deletions(-) create mode 100644 apps/client/src/features/app-settings/__tests__/useAppSettingsMenu.test.ts create mode 100644 apps/client/src/features/app-settings/panel-list/SettingsSearch.module.scss create mode 100644 apps/client/src/features/app-settings/panel-list/SettingsSearch.tsx delete mode 100644 apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx 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 0000000000..cd556ea341 --- /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 265409b70b..20dd0c8385 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 42d028d9cf..fe79301247 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 6326a15764..d01fbb519e 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; - &.active { + &.groupActive { color: $blue-400; - background-color: $gray-1100; + font-weight: 600; + } + + &.active { + 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 01dc977c06..c305bba6b8 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 ( -
    - {options.map((panel) => { - const isSelected = selectedPanel === panel.id; - if (panel.highlight) { - return ( - }> - - - ); - } - 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 0000000000..571ea59b9b --- /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 0000000000..f3dc08c0c8 --- /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 f47ef304bb..7f2d8273ac 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 90519f3030..0000000000 --- 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 f41a56f174..eaee0c59d3 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 370f0cf920..8c7b57f0e0 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(); From 0f7d3c652b7e615f6e57feea7ed0286ba5f41dcc Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 1 Aug 2026 17:17:29 +0200 Subject: [PATCH 3/4] refactor(link): add row presentation variant --- .../link/external-link/ExternalLink.module.scss | 11 +++++++++++ .../components/link/external-link/ExternalLink.tsx | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/client/src/common/components/link/external-link/ExternalLink.module.scss b/apps/client/src/common/components/link/external-link/ExternalLink.module.scss index 83c26d3e81..59162642a1 100644 --- a/apps/client/src/common/components/link/external-link/ExternalLink.module.scss +++ b/apps/client/src/common/components/link/external-link/ExternalLink.module.scss @@ -21,4 +21,15 @@ outline: none; box-shadow: 0 1px 0 0 currentColor; } + + &:hover .icon, + &:focus-visible .icon { + transform: translateX(0.25rem); + } +} + +.icon { + flex: 0 0 1.5em; + font-size: 1em; + transition: transform $transition-time-action; } diff --git a/apps/client/src/common/components/link/external-link/ExternalLink.tsx b/apps/client/src/common/components/link/external-link/ExternalLink.tsx index 7b7c4c76a6..105f5d8e3f 100644 --- a/apps/client/src/common/components/link/external-link/ExternalLink.tsx +++ b/apps/client/src/common/components/link/external-link/ExternalLink.tsx @@ -1,5 +1,5 @@ import { MouseEvent, ReactNode } from 'react'; -import { IoOpenOutline } from 'react-icons/io5'; +import { HiArrowLongRight } from 'react-icons/hi2'; import { openLink } from '../../../utils/linkUtils'; import { cx } from '../../../utils/styleUtils'; @@ -10,9 +10,10 @@ interface ExternalLinkProps { href: string; children: ReactNode; inline?: boolean; + className?: string; } -export default function ExternalLink({ href, inline, children }: ExternalLinkProps) { +export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) { const handleClick = (event: MouseEvent) => { event.preventDefault(); openLink(href); @@ -23,10 +24,11 @@ export default function ExternalLink({ href, inline, children }: ExternalLinkPro href='#!' target='_blank' rel='noreferrer' - className={cx([style.link, inline && style.inline])} + className={cx([style.link, inline && style.inline, className])} onClick={handleClick} > - {children} + {children} + ); } From 91e0e6ecc2279864e378090d17d47ac883c51669 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 1 Aug 2026 17:09:48 +0200 Subject: [PATCH 4/4] refactor(settings): polish settings UI --- .../RedirectClientModal.module.scss | 74 ++- .../client-modal/RedirectClientModal.tsx | 66 +- .../RenameClientModal.module.scss | 13 + .../client-modal/RenameClientModal.tsx | 17 +- .../components/dialog/Dialog.module.scss | 2 +- .../src/common/components/tag/Tag.module.scss | 5 + apps/client/src/common/components/tag/Tag.tsx | 2 +- .../panel-utils/PanelUtils.module.scss | 106 ++-- .../app-settings/panel-utils/PanelUtils.tsx | 35 +- .../panel/about-panel/AboutPanel.tsx | 67 +- .../panel/about-panel/AppVersion.tsx | 37 +- .../panel/about-panel/CloudPanel.module.scss | 74 +++ .../panel/about-panel/CloudPanel.tsx | 52 ++ .../about-panel/ExternalLinkRow.module.scss | 15 + .../panel/about-panel/ExternalLinkRow.tsx | 21 + .../AutomationForm.module.scss | 9 +- .../automations-panel/AutomationForm.tsx | 580 +++++++++--------- .../automations-panel/AutomationPanel.tsx | 39 +- .../AutomationSettingsForm.tsx | 239 ++++---- .../automations-panel/AutomationsList.tsx | 165 ++--- .../panel/automations-panel/TriggerForm.tsx | 141 +++-- .../panel/automations-panel/TriggersList.tsx | 164 ++--- .../automations-panel/TriggersListItem.tsx | 43 +- .../panel/feature-panel/ReportSettings.tsx | 5 +- .../panel/feature-panel/URLPresets.tsx | 14 +- .../feature-panel/composite/URLPresetForm.tsx | 166 ++--- .../panel/manage-panel/CustomFields.tsx | 65 +- .../panel/manage-panel/CustomViewForm.tsx | 121 ++-- .../panel/manage-panel/CustomViews.tsx | 5 +- .../panel/manage-panel/CustomViewsList.tsx | 13 +- .../manage-panel/ManagePanel.module.scss | 9 +- .../panel/manage-panel/ManageRundownForm.tsx | 47 +- .../panel/manage-panel/ManageRundowns.tsx | 2 +- .../composite/CustomFieldEntry.tsx | 29 +- .../composite/CustomFieldForm.tsx | 134 ++-- .../preview/PreviewTable.module.scss | 27 - .../sheet-import/preview/PreviewTable.tsx | 25 +- .../ClientControlPanel.module.scss | 20 +- .../client-control/ClientList.tsx | 78 ++- .../panel/project-panel/ProjectList.tsx | 8 +- .../panel/project-panel/ProjectListItem.tsx | 11 +- .../panel/project-panel/ProjectMergeForm.tsx | 184 ++++-- .../project-panel/ProjectPanel.module.scss | 86 ++- .../composite/CustomTranslationModal.tsx | 7 +- .../composite/StyleEditorModal.tsx | 9 +- .../panel/shutdown-panel/ShutdownPanel.tsx | 79 +-- apps/client/src/features/log/Log.module.scss | 47 +- apps/client/src/features/log/Log.tsx | 24 +- e2e/tests/features/210-client-remote.spec.ts | 23 +- 49 files changed, 1941 insertions(+), 1263 deletions(-) create mode 100644 apps/client/src/common/components/client-modal/RenameClientModal.module.scss create mode 100644 apps/client/src/features/app-settings/panel/about-panel/CloudPanel.module.scss create mode 100644 apps/client/src/features/app-settings/panel/about-panel/CloudPanel.tsx create mode 100644 apps/client/src/features/app-settings/panel/about-panel/ExternalLinkRow.module.scss create mode 100644 apps/client/src/features/app-settings/panel/about-panel/ExternalLinkRow.tsx diff --git a/apps/client/src/common/components/client-modal/RedirectClientModal.module.scss b/apps/client/src/common/components/client-modal/RedirectClientModal.module.scss index a8896858ce..2ed0571829 100644 --- a/apps/client/src/common/components/client-modal/RedirectClientModal.module.scss +++ b/apps/client/src/common/components/client-modal/RedirectClientModal.module.scss @@ -1,24 +1,82 @@ +.section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + background-color: $gray-1300; + border: 1px solid $gray-1100; + border-radius: $component-border-radius-md; +} + +.sectionHeader { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.separator { + display: flex; + align-items: center; + gap: 0.75rem; + color: $gray-500; + font-size: $inner-section-text-size; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + + &::before, + &::after { + content: ''; + flex: 1; + border-top: 1px solid $gray-1100; + } +} + .textEntry { - grid-area: input; display: flex; align-items: center; gap: 0.5rem; + flex: 1; + min-width: 0; color: $label-gray; } .inlineEntry { display: grid; - grid-template-areas: - 'label label' - 'input btn'; - grid-template-columns: 1fr auto; - column-gap: 0.5rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; } .redirect { - grid-area: btn; + margin-left: auto; } .label { - color: $label-gray; + color: $gray-200; + font-size: $text-body-size; + font-weight: 600; +} + +.description { + color: $gray-500; + font-size: $inner-section-text-size; +} + +.origin { + flex: 0 0 auto; + color: $gray-500; + font-size: $inner-section-text-size; + white-space: nowrap; +} + +@media (max-width: $min-tablet) { + .inlineEntry { + grid-template-columns: 1fr; + align-items: stretch; + } + + .redirect { + align-self: flex-end; + } } diff --git a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx index f577316ee3..26c0d58724 100644 --- a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx +++ b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { IoArrowForward } from 'react-icons/io5'; +import { baseURI } from '../../../externals'; import { navigatorConstants } from '../../../viewerConfig'; import useUrlPresets from '../../hooks-query/useUrlPresets'; import { setClientRemote } from '../../hooks/useSocket'; @@ -8,7 +9,7 @@ import Button from '../buttons/Button'; import Dialog from '../dialog/Dialog'; import Info from '../info/Info'; import Input from '../input/input/Input'; -import AppLink from '../link/app-link/AppLink'; +import ExternalLink from '../link/external-link/ExternalLink'; import Select from '../select/Select'; import style from './RedirectClientModal.module.scss'; @@ -44,6 +45,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC }; const enabledPresets = data.filter((preset) => preset.enabled); + const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`; const viewOptions = [ ...navigatorConstants.map((view) => ({ @@ -66,34 +68,46 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC bodyElements={ <> - Remotely redirect the client to a different URL.
- Either by entering a custom path or selecting a URL Preset. -
-
- Manage URL Presets + + Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset. + + + Manage URL Presets +
-
- Enter custom path - - +
+
+ Enter custom path + Type the path the client should open. +
+
+ + +
+
+
+ Or
-
- Select View or URL Preset +
+
+ Select view or URL Preset + Choose from the available destinations. +