Compare commits

...

3 Commits

Author SHA1 Message Date
Claude be2226a38f Polish navigation menu and view params editor
Bring both drawers onto the list and field patterns already used by the
settings panel, rather than each carrying its own treatment.

Navigation menu
- Rows adopt the PanelList styling: neutral idle colour with blue reserved
  for the current view, which previously competed with a dozen other blue rows
- Group the list under named headings instead of bare separators
- Display options become toggle rows with a switch matching the Switch
  component, replacing the inconsistent "Active" text notes
- Leading icons on a fixed box so labels line up, and an icon for Rename Client
- Swap :focus for :focus-visible so clicking no longer leaves an outline
- Show the client name in the header, next to the action that renames it

View params editor
- Compact fields (booleans, colours) sit beside their label instead of below
  it, which removes a third of the scroll height and pairs control with label
- Restore the type hierarchy: option titles were dimmer than their section
  header. Titles now read as primary with descriptions muted underneath
- Sections become cards with hairline separated rows
- Section headers are keyboard reachable and mark sections holding custom values
- Sticky header and footer with borders, so content no longer scrolls
  visibly underneath the Apply button, dropping the 10vh padding workaround
- Give the presets block a heading and a tinted applied state

Collapsed and hidden fields stay mounted and hidden with CSS: the form reads
its values from the DOM, so unmounting them would drop them on Apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014L7R2LgjMSH2WQbiSP4uRn
2026-08-18 18:36:31 +00:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
20 changed files with 1616 additions and 2290 deletions
+7 -6
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.6.0",
"@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-icons": "5.7.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,7 +60,8 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.1.1",
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
@@ -1,7 +1,3 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop {
position: fixed;
inset: 0;
@@ -23,14 +19,15 @@
width: 22rem;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] {
transform: translateX(0%);
@@ -50,22 +47,63 @@
}
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 1.5rem;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
}
.body {
flex: 1;
overflow-y: auto;
padding-block: 0.5rem 1rem;
scrollbar-width: thin;
scrollbar-color: $gray-1000 transparent;
}
.note {
margin-left: auto;
color: $white-20;
.group {
display: flex;
flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $gray-700;
}
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
@@ -17,6 +17,7 @@ import { RenameClientModal } from '../client-modal/RenameClientModal';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss';
@@ -33,7 +34,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -52,66 +53,77 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose />
</IconButton>
</div>
<div className={style.body}>
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
<MenuGroup label='This screen'>
{supportsFullscreen && (
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onClick={toggle} />
)}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onClick={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onClick={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
</MenuGroup>
<hr className={style.separator} />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<MenuGroup label='Ontime app'>
<EditorNavigation />
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
<IoLockClosedOutline />
Cuesheet
</ClientLink>
))}
<ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div>
{isLocalhost && (
<div>
<div className={style.footer}>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
@@ -121,6 +133,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
);
}
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>{label}</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
@@ -129,8 +150,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
<MenuGroup label='Presets'>
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
@@ -141,6 +161,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias}
</ClientLink>
))}
</>
</MenuGroup>
);
}
@@ -2,35 +2,98 @@
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
gap: 0.625rem;
width: 100%;
border-left: 4px solid transparent;
color: $action-text-color;
border-left: 3px solid transparent;
color: $gray-200;
font-size: $text-body-size;
white-space: nowrap;
text-align: left;
cursor: pointer;
transition-property: color;
transition-property: color, background-color;
transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover {
color: $ontime-color;
background-color: $gray-1350;
background-color: $white-3;
color: $ui-white;
> svg {
color: $gray-300;
}
}
&:active {
background-color: $border-color-ondark;
background-color: $white-7;
}
&:focus {
&:focus-visible {
outline: 2px solid $blue-500;
background-color: $gray-1350;
outline-offset: -2px;
background-color: $white-3;
}
&.current {
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
background-color: rgba($blue-500, 0.16);
border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
}
}
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
/**
* Presentational switch for toggle rows.
* The row itself owns the interaction (role=switch), so this is aria-hidden:
* nesting a real control inside a clickable row would double up the hit target.
* Metrics and colours are kept in sync with the medium Switch component.
*/
.toggleTrack {
flex-shrink: 0;
box-sizing: content-box;
width: 2.5rem;
height: calc(1.5rem - 4px);
padding: 2px;
border-radius: $component-border-radius-full;
background-color: $gray-1100;
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
&::after {
content: '';
display: block;
width: calc(1.5rem - 4px);
height: 100%;
border-radius: $component-border-radius-full;
background-color: $ui-white;
transition: translate 150ms ease;
}
}
[aria-checked='true'] > .toggleTrack {
background-color: $blue-700;
&::after {
translate: calc(2.5rem - (1.5rem - 4px)) 0;
}
}
@@ -0,0 +1,38 @@
import { ReactNode } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
}
/**
* A menu row which reflects an on/off state.
* We keep the div + Enter handling of NavigationMenuItem instead of a button:
* Space is a global hotkey for toggling the menu, and a button would react to it as well.
*/
export default function NavigationMenuToggle({ checked, icon, label, onClick }: NavigationMenuToggleProps) {
return (
<div
className={style.link}
tabIndex={0}
role='switch'
aria-checked={checked}
onClick={onClick}
onKeyDown={(event) => {
if (isKeyEnter(event)) {
onClick();
}
}}
>
{icon}
<span className={style.label}>{label}</span>
<span className={style.toggleTrack} aria-hidden />
</div>
);
}
@@ -1,11 +1,14 @@
.header {
font-size: calc(1rem - 2px);
margin-left: 1rem;
padding: 0 1.25rem 0.25rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $gray-700;
}
.interfaces {
padding: 0.5rem 0.5rem;
padding: 0.25rem 1.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
@@ -1,13 +1,3 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop {
position: fixed;
inset: 0;
@@ -34,11 +24,11 @@
display: flex;
flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] {
transform: translateX(0%);
@@ -58,36 +48,58 @@
}
.header {
padding-inline: 1rem;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
}
.body {
flex: 1;
padding-inline: 1rem;
padding-bottom: 10vh;
padding: 1rem;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: $gray-1000 transparent;
}
.footer {
flex: 0 0 auto;
display: flex;
padding-inline: 1rem;
padding: 0.75rem 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
background-color: $gray-1250;
border-top: 1px solid $white-10;
}
.sectionList {
display: flex;
flex-direction: column;
gap: 2rem;
padding-right: 0.5rem;
gap: 1rem;
}
.info {
@@ -18,6 +18,19 @@ import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
/** User facing names for the views we can customise */
const viewLabels: Record<OntimeView, string> = {
[OntimeView.Editor]: 'Editor',
[OntimeView.Cuesheet]: 'Cuesheet',
[OntimeView.Operator]: 'Operator',
[OntimeView.Timer]: 'Timer',
[OntimeView.Backstage]: 'Backstage',
[OntimeView.Timeline]: 'Timeline',
[OntimeView.StudioClock]: 'Studio Clock',
[OntimeView.Countdown]: 'Countdown',
[OntimeView.ProjectInfo]: 'Project Info',
};
interface EditFormDrawerProps {
target: OntimeView;
viewOptions: ViewOption[];
@@ -71,8 +84,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose />
</IconButton>
</div>
@@ -1,22 +1,45 @@
.presetSection {
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
background-color: $white-2;
border: 1px solid $gray-1100;
border-radius: $panel-border-radius;
margin-bottom: 1rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.75rem;
display: flex;
align-items: center;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $gray-300;
}
.presetList {
padding-inline: 0.5rem;
border-top: 1px solid $white-7;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: $gray-1000 transparent;
}
.preset {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: $component-border-radius-md;
font-size: $inner-section-text-size;
&.active {
color: $blue-500;
background-color: rgba($blue-500, 0.16);
color: $ui-white;
font-weight: 600;
}
}
@@ -26,22 +26,25 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return (
<div className={style.presetSection}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
<div className={style.header}>Presets</div>
<div className={style.presetList}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
</div>
);
}
@@ -1,48 +1,116 @@
.section {
color: $ui-white;
font-size: 1rem;
background-color: $white-2;
border: 1px solid $gray-1100;
border-radius: $panel-border-radius;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 2rem;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.5rem 1rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $gray-300;
&.collapsible {
cursor: pointer;
border-radius: $panel-border-radius;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
}
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label {
margin-top: $section-spacing;
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
color: $ui-white;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
color: $gray-400;
font-size: $aux-text-size;
line-height: 1.4;
max-width: 60ch;
}
.hidden {
@@ -1,6 +1,8 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { isKeyEnter } from '../../utils/keyEvent';
import { cx } from '../../utils/styleUtils';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput';
@@ -14,8 +16,19 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
// hidden options are inputs without a UI, there is nothing to frame them with
if (title === OptionTitle.Hidden) {
return <HiddenContents options={options} />;
}
const handleCollapse = () => {
if (collapsible) {
@@ -23,34 +36,43 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP
}
};
const isCollapsed = Boolean(collapsible && collapsed);
const isCustomised = options.some((option) => searchParams.has(option.id));
return (
<section className={style.section}>
{title === OptionTitle.Hidden ? (
<HiddenContents options={options} />
) : (
<>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
<SectionContents options={options} collapsed={collapsed} />
</>
)}
<div
className={cx([style.sectionHeader, collapsible && style.collapsible])}
onClick={handleCollapse}
onKeyDown={(event) => {
if (isKeyEnter(event)) {
handleCollapse();
}
}}
role={collapsible ? 'button' : undefined}
tabIndex={collapsible ? 0 : undefined}
aria-expanded={collapsible ? !isCollapsed : undefined}
>
<span className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</span>
{collapsible && <IoChevronDown className={cx([style.chevron, isCollapsed && style.closed])} />}
</div>
{/* collapsed options stay mounted: the form reads its values from the DOM */}
<div className={cx([style.options, isCollapsed && style.hidden])}>
<SectionContents options={options} />
</div>
</section>
);
}
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
function SectionContents({ options }: { options: ParamField[] }) {
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
+2 -2
View File
@@ -14,8 +14,8 @@
"main": "src/main.js",
"devDependencies": {
"electron": "38.2.1",
"electron-builder": "26.9.1",
"wait-on": "^7.2.0"
"electron-builder": "26.15.3",
"wait-on": "^9.0.0"
},
"scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+3 -3
View File
@@ -10,7 +10,7 @@
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"dotenv": "^16.0.1",
"dotenv": "^17.0.0",
"express": "5.2.1",
"express-static-gzip": "3.0.1",
"express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0",
"@types/node": "catalog:",
"@types/ws": "^8.5.10",
"esbuild": "^0.24.0",
"esbuild": "^0.28.0",
"ontime-types": "workspace:*",
"server-timing": "^3.3.3",
"ts-essentials": "catalog:",
"tsx": "^4.19.2",
"tsx": "^4.23.12",
"typescript": "catalog:",
"vitest": "catalog:"
},
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID
'cue',
'title',
'note',
'endAction',
'timerType',
'countToEnd',
'colour',
'timeWarning',
'timeDanger',
'custom',
'triggers',
id, // adding it for completeness, users cannot change ID
type, // adding it for completeness, users cannot change ID
cue,
title,
note,
endAction,
timerType,
countToEnd,
colour,
timeWarning,
timeDanger,
custom,
triggers,
}
/**
+3 -6
View File
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check"
},
"devDependencies": {
"@playwright/test": "1.60.0",
"@playwright/test": "1.62.1",
"@types/node": "catalog:",
"cross-env": "^7.0.3",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.17.4",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxlint-tsgolint": "^7.0.2001",
"rimraf": "catalog:",
"turbo": "2.8.20",
"turbo": "2.10.10",
"typescript": "catalog:"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
+1
View File
@@ -14,6 +14,7 @@
"nanoid": "^6.0.0"
},
"devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
+1108 -2093
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog:
'@types/node': 22.19.11
rimraf: 6.0.1
ts-essentials: 10.1.1
rimraf: 6.1.3
ts-essentials: 10.2.1
typescript: 7.0.2
vitest: 4.0.17
vitest: 4.1.10
allowBuilds:
'@parcel/watcher': true