mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-24 16:39:13 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c29756abe |
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -13,12 +13,12 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^9.5.1",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.18.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.7.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -60,8 +60,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"@types/node": "catalog:",
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -73,8 +72,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.eyebrow {
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Eyebrow.module.scss';
|
||||
|
||||
interface EyebrowProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
|
||||
return <span className={cx([style.eyebrow, className])}>{children}</span>;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -19,15 +23,14 @@
|
||||
|
||||
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%);
|
||||
@@ -47,51 +50,22 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
height: 3.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
|
||||
.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;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 0.5rem 1.25rem 0.25rem;
|
||||
color: $gray-700;
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
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';
|
||||
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreenDocument();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
@@ -54,93 +52,75 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<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}>
|
||||
<Dialog.Title>Ontime</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
<MenuGroup label='This screen'>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
|
||||
)}
|
||||
<NavigationMenuToggle
|
||||
checked={mirror}
|
||||
icon={<IoSwapVertical />}
|
||||
label='Flip Screen'
|
||||
onToggle={() => toggleMirror()}
|
||||
/>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuToggle
|
||||
checked={keepAwake}
|
||||
icon={<LuCoffee />}
|
||||
label='Keep Awake'
|
||||
onToggle={toggleKeepAwake}
|
||||
/>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>
|
||||
<IoPencilOutline />
|
||||
Rename Client
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
</MenuGroup>
|
||||
)}
|
||||
<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 label='Ontime app'>
|
||||
<EditorNavigation />
|
||||
<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) => (
|
||||
<ClientLink
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
{route.label}
|
||||
</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 && <OtherAddresses currentLocation={location.pathname} />}
|
||||
{isLocalhost && (
|
||||
<div>
|
||||
<OtherAddresses currentLocation={location.pathname} />
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.group}>
|
||||
<div className={style.groupLabel}>
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MenuGroup label='Presets'>
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||
@@ -15,37 +15,21 @@ interface ViewNavigationMenuProps {
|
||||
suppressSettings?: boolean;
|
||||
}
|
||||
|
||||
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
target.closest(
|
||||
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const hasSavedChanges = hasCustomParams(searchParams);
|
||||
|
||||
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
|
||||
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'Space',
|
||||
(event) => {
|
||||
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
() => {
|
||||
if (isNavigationLocked) return;
|
||||
menuHandler.toggle();
|
||||
},
|
||||
{ preventDefault: false },
|
||||
{ preventDefault: true },
|
||||
],
|
||||
[
|
||||
'mod + ,',
|
||||
@@ -64,11 +48,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation
|
||||
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
hasSavedChanges={hasSavedChanges}
|
||||
/>
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-45
@@ -2,68 +2,35 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.625rem 1.25rem;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
|
||||
border-left: 3px solid transparent;
|
||||
color: $gray-200;
|
||||
font-size: $text-body-size;
|
||||
border-left: 4px solid transparent;
|
||||
color: $action-text-color;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: color, background-color;
|
||||
transition-property: 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 {
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
|
||||
> svg {
|
||||
color: $gray-300;
|
||||
}
|
||||
color: $ontime-color;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $white-7;
|
||||
background-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:focus {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
// toggle rows are labels, the focusable control sits inside them
|
||||
&:focus-within {
|
||||
background-color: $white-3;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: $bg-active;
|
||||
border-left-color: $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
|
||||
> svg {
|
||||
color: $blue-300;
|
||||
}
|
||||
background-color: $gray-1300;
|
||||
border-left: 4px solid $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
|
||||
import { isKeyEnter } from '../../../utils/keyEvent';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
|
||||
export default function NavigationMenuItem({
|
||||
active,
|
||||
className,
|
||||
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
|
||||
role='button'
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event) || isKeySpace(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isKeyEnter(event)) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import Switch from '../../switch/Switch';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
|
||||
interface NavigationMenuToggleProps {
|
||||
checked: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** A menu row which reflects, and toggles, an on/off state */
|
||||
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
|
||||
return (
|
||||
<label className={style.link}>
|
||||
{icon}
|
||||
<span className={style.label}>{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+4
-34
@@ -1,52 +1,22 @@
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0 1.25rem 0.25rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.25rem 1.25rem 0;
|
||||
padding: 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.interface {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
> div {
|
||||
max-width: 100%;
|
||||
|
||||
> button:first-child {
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.interfaceCopy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
flex: 0 0 auto;
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
+15
-18
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
|
||||
import useInfo from '../../../hooks-query/useInfo';
|
||||
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
||||
import CopyTag from '../../copy-tag/CopyTag';
|
||||
import Eyebrow from '../../eyebrow/Eyebrow';
|
||||
import { getExternalInterfaces } from './otherAddresses.utils';
|
||||
|
||||
import style from './OtherAddresses.module.scss';
|
||||
|
||||
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
|
||||
|
||||
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
||||
const { data } = useInfo();
|
||||
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
|
||||
|
||||
if (externalInterfaces.length === 0) {
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.footer}>
|
||||
<div className={style.header}>
|
||||
<Eyebrow>Also available at</Eyebrow>
|
||||
</div>
|
||||
<>
|
||||
<div className={style.header}>Accessible on external networks</div>
|
||||
<div className={style.interfaces}>
|
||||
{externalInterfaces.map((nif) => {
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<div key={nif.name} className={style.interface}>
|
||||
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
<span className={style.address}>{nif.address}</span>
|
||||
<IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
</div>
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getExternalInterfaces } from '../otherAddresses.utils';
|
||||
|
||||
describe('getExternalInterfaces', () => {
|
||||
test('returns no alternatives when localhost is the only interface', () => {
|
||||
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat an interface name as deployment information', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'cloud', address: '192.168.1.42' },
|
||||
]),
|
||||
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
|
||||
});
|
||||
|
||||
test('returns non-local interfaces when local and external interfaces are available', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { type NetworkInterface } from 'ontime-types';
|
||||
|
||||
/** Returns network addresses other than the localhost address injected by the info endpoint. */
|
||||
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
|
||||
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
|
||||
}
|
||||
@@ -2,12 +2,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
> span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
|
||||
@@ -1,77 +1,37 @@
|
||||
.multiOptionList {
|
||||
overflow-y: auto;
|
||||
max-height: 12rem;
|
||||
background: $gray-1200;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// styles from subtle button
|
||||
.toggleSelect {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: $gray-300;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.5rem;
|
||||
height: 2.5rem;
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
line-height: 1em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color $transition-time-action,
|
||||
border-color $transition-time-action,
|
||||
color $transition-time-action;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
> [role='checkbox'] {
|
||||
flex: 0 0 auto;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border: 1px solid $gray-400;
|
||||
|
||||
&[data-unchecked] {
|
||||
background: $gray-1100;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
border-color: $blue-400;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
margin-left: 0.25rem;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
background: var(--user-bg, $gray-900);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||
<div className={style.multiOptionList}>
|
||||
<div className={style.inline}>
|
||||
{values.map((option) => {
|
||||
const selected = paramState.includes(option.value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cx([style.toggleSelect, selected && style.selected])}
|
||||
className={style.toggleSelect}
|
||||
style={{
|
||||
'--user-bg': option.colour,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
checked={paramState.includes(option.value)}
|
||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||
/>
|
||||
{option.label}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -24,11 +34,11 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem 1.5rem;
|
||||
|
||||
background-color: $ui-black;
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
border-left: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-l1;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -48,54 +58,36 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
height: 3.5rem;
|
||||
|
||||
.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: 1rem;
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-inline: 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
background-color: $ui-black;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 2rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { viewLabels } from '../../../viewerConfig';
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||
@@ -72,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Backdrop className={style.backdrop} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<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}
|
||||
>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
.presetSection {
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.presetList {
|
||||
border-top: 1px solid $white-7;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -25,39 +14,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.presetName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.presetActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
color: $blue-400;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router';
|
||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
|
||||
import style from './ViewParamsPresets.module.scss';
|
||||
|
||||
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
<div className={style.header}>
|
||||
<Eyebrow>Saved presets</Eyebrow>
|
||||
</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])}>
|
||||
<span className={style.presetName}>{preset.alias}</span>
|
||||
{active ? (
|
||||
<span className={style.current}>Current</span>
|
||||
) : (
|
||||
<Button
|
||||
variant='ghosted'
|
||||
size='small'
|
||||
onClick={() => handleRecall(preset)}
|
||||
className={style.presetActions}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-92
@@ -1,118 +1,48 @@
|
||||
.section {
|
||||
overflow: hidden;
|
||||
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
||||
color: $gray-300;
|
||||
height: 2rem;
|
||||
|
||||
&.collapsible {
|
||||
cursor: pointer;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
margin-top: $section-spacing;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&: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;
|
||||
}
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $ui-white;
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-400;
|
||||
font-size: $aux-text-size;
|
||||
line-height: 1.4;
|
||||
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;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
import { OptionTitle } from './constants';
|
||||
import ParamInput from './ParamInput';
|
||||
import { type ParamField } from './viewParams.types';
|
||||
@@ -16,74 +14,50 @@ 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 isCollapsed = Boolean(collapsible && collapsed);
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
setCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
isCustomised={options.some((option) => searchParams.has(option.id))}
|
||||
collapsed={isCollapsed}
|
||||
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
|
||||
/>
|
||||
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
||||
<div className={cx([style.options, isCollapsed && style.hidden])}>
|
||||
{options.map((option) => (
|
||||
<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} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{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} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
isCustomised: boolean;
|
||||
interface SectionContentsProps {
|
||||
options: ParamField[];
|
||||
collapsed: boolean;
|
||||
/** a header without a toggle belongs to a section which cannot collapse */
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
|
||||
const label = (
|
||||
<Eyebrow className={style.sectionTitle}>
|
||||
{title}
|
||||
{isCustomised && <span className={style.customised} title='Contains custom values' />}
|
||||
</Eyebrow>
|
||||
);
|
||||
|
||||
if (!onToggle) {
|
||||
return <div className={style.sectionHeader}>{label}</div>;
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={cx([style.sectionHeader, style.collapsible])}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{label}
|
||||
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
|
||||
</button>
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Enter';
|
||||
}
|
||||
|
||||
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === ' ' || event.code === 'Space';
|
||||
}
|
||||
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
+35
@@ -1,3 +1,5 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -30,6 +32,39 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow passing CSS Properties
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: $bg-active;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: $bg-active;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ $card-padding: 2rem;
|
||||
// active rows use a panel-local class today; keep the treatment here so all
|
||||
// settings tables share the same state without each panel owning its colours
|
||||
tr[class*='current'] {
|
||||
background-color: $bg-active;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
|
||||
> td:first-child {
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
|
||||
+8
-9
@@ -12,21 +12,20 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import { viewLabels } from '../../../../../viewerConfig';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
|
||||
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
|
||||
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
|
||||
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
|
||||
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
|
||||
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
|
||||
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
|
||||
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
];
|
||||
|
||||
const formId = 'url-preset-form';
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
|
||||
|
||||
/**
|
||||
* Creates column definitions for the rundown table
|
||||
* Reuses cuesheetColsFactory with preset=undefined for full access
|
||||
*/
|
||||
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
|
||||
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
|
||||
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
||||
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
||||
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
||||
|
||||
@@ -48,7 +48,6 @@ $playback-under: $green-500;
|
||||
// interface panels
|
||||
$bg-container-l1: $gray-1350;
|
||||
$bg-container-l2: $gray-1300;
|
||||
$bg-active: rgba($action-blue, 0.16);
|
||||
|
||||
$backdrop-color: rgba(0, 0, 0, 0.7);
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
/** User facing name of each view, the single source for view naming in the UI */
|
||||
export 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',
|
||||
};
|
||||
|
||||
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
|
||||
const navigatorViews = [
|
||||
OntimeView.Timer,
|
||||
OntimeView.Backstage,
|
||||
OntimeView.Timeline,
|
||||
OntimeView.StudioClock,
|
||||
OntimeView.Countdown,
|
||||
OntimeView.ProjectInfo,
|
||||
export const navigatorConstants = [
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline' },
|
||||
{ url: 'studio', label: 'Studio Clock' },
|
||||
{ url: 'countdown', label: 'Countdown' },
|
||||
{ url: 'info', label: 'Project Info' },
|
||||
];
|
||||
|
||||
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
|
||||
|
||||
// default time format to use for users in 12 hour clocks
|
||||
export const FORMAT_12 = 'h:mm:ss a';
|
||||
// default time format to use for users in 24 hour clocks
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||
|
||||
interface CuesheetDndProps {
|
||||
columns: CuesheetColumnDef[];
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
tableRoot?: 'editor' | 'cuesheet';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { useTable } from '@tanstack/react-table';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
@@ -29,17 +29,12 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import {
|
||||
CuesheetColumnDef,
|
||||
CuesheetTable as CuesheetTableInstance,
|
||||
cuesheetTableFeatures,
|
||||
} from './cuesheetTable.features';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: CuesheetColumnDef[];
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
@@ -125,8 +120,7 @@ export default function CuesheetTable({
|
||||
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useTable({
|
||||
features: cuesheetTableFeatures,
|
||||
const table = useReactTable({
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
@@ -137,6 +131,7 @@ export default function CuesheetTable({
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta,
|
||||
});
|
||||
|
||||
@@ -200,7 +195,7 @@ export default function CuesheetTable({
|
||||
return colSizes;
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||
}, [columns, table.state.columnResizing, table.state.columnSizing]);
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -219,7 +214,9 @@ export default function CuesheetTable({
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
const fixedHeaderContent = useCallback(() => {
|
||||
return table.getHeaderGroups().map((headerGroup) => {
|
||||
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
|
||||
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||
? CuesheetHeader
|
||||
: SortableCuesheetHeader;
|
||||
|
||||
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||
return (
|
||||
@@ -282,8 +279,8 @@ interface CuesheetVirtuosoContext {
|
||||
columnSizeVars: { [key: string]: number };
|
||||
cursor: string | null;
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
|
||||
table: CuesheetTableInstance;
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { HeaderGroup, flexRender } from '@tanstack/react-table';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
|
||||
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroup: CuesheetHeaderGroup;
|
||||
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||
cuesheetMode: AppMode;
|
||||
hideIndexColumn: boolean;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
@@ -25,7 +25,7 @@ interface EventRowProps {
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
table: CuesheetTable;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ function EventRow({
|
||||
data-testid={`cuesheet-cell-${cell.column.id}`}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
<FlexRender cell={cell} />
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './GroupRow.module.scss';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface GroupRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: CuesheetTable;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ function GroupRow({
|
||||
}}
|
||||
role='cell'
|
||||
>
|
||||
<FlexRender cell={cell} />
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './MilestoneRow.module.scss';
|
||||
|
||||
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: CuesheetTable;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function MilestoneRow({
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{canRender && <FlexRender cell={cell} />}
|
||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
@@ -47,7 +48,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
|
||||
}
|
||||
|
||||
interface DraggableProps {
|
||||
header: CuesheetHeaderCell;
|
||||
header: Header<ExtendedEntry, unknown>;
|
||||
}
|
||||
|
||||
export function Draggable({ header }: DraggableProps) {
|
||||
|
||||
+12
-12
@@ -1,3 +1,4 @@
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
@@ -7,7 +8,6 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||
}
|
||||
|
||||
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function MakeFlagField({ row }: CuesheetCellContext) {
|
||||
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event) || !event.flag) {
|
||||
return null;
|
||||
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CuesheetCellContext) {
|
||||
return <FlagCell />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
|
||||
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): CuesheetColumnDef[] {
|
||||
const columnsDef: CuesheetColumnDef[] = [];
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
|
||||
+5
-4
@@ -2,6 +2,7 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { ReactNode } from 'react';
|
||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||
|
||||
@@ -9,9 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import type { CuesheetColumn } from '../cuesheetTable.features';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
@@ -36,7 +37,7 @@ type TableModeControls = {
|
||||
};
|
||||
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: CuesheetColumn[];
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
@@ -110,7 +111,7 @@ interface ViewSettingsProps {
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: CuesheetColumn[];
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -202,7 +203,7 @@ function ColumnSettings({
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
{columnHeader as ReactNode}
|
||||
</Editor.Label>
|
||||
);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-background-rundown='true'] {
|
||||
background-color: $bg-active;
|
||||
background-color: rgba($ontime-color, 0.5);
|
||||
|
||||
.apart::before {
|
||||
content: 'BACKGROUND EDIT';
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
columnOrderingFeature,
|
||||
columnResizingFeature,
|
||||
columnSizingFeature,
|
||||
columnVisibilityFeature,
|
||||
metaHelper,
|
||||
tableFeatures,
|
||||
} from '@tanstack/react-table';
|
||||
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
|
||||
import type { TimeField } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
/**
|
||||
* Custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*/
|
||||
export interface CuesheetTableMeta {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
export interface CuesheetColumnMeta {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Features registered in the cuesheet and rundown tables.
|
||||
* In v9 an API only exists once its feature is registered, so this list is the
|
||||
* source of truth for what the table can do:
|
||||
* - `columnOrderingFeature`: user reorders columns by dragging the headers
|
||||
* - `columnVisibilityFeature`: user toggles columns in the table settings
|
||||
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
|
||||
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
|
||||
*
|
||||
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
|
||||
* they scope our meta types to this table instead of every table in the app.
|
||||
*/
|
||||
export const cuesheetTableFeatures = tableFeatures({
|
||||
columnOrderingFeature,
|
||||
columnVisibilityFeature,
|
||||
columnSizingFeature,
|
||||
columnResizingFeature,
|
||||
tableMeta: metaHelper<CuesheetTableMeta>(),
|
||||
columnMeta: metaHelper<CuesheetColumnMeta>(),
|
||||
});
|
||||
|
||||
export type CuesheetFeatures = typeof cuesheetTableFeatures;
|
||||
|
||||
/** Convenience aliases so consumers do not need to repeat the feature generic */
|
||||
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { debounce } from '../../../common/utils/debounce';
|
||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||
import type { CuesheetColumnDef } from './cuesheetTable.features';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
|
||||
type TableRoot = 'editor' | 'cuesheet';
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
|
||||
};
|
||||
}
|
||||
|
||||
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
|
||||
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
|
||||
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
||||
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
|
||||
@@ -169,7 +169,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
{
|
||||
id: 'timerColour',
|
||||
title: 'Timer Colour',
|
||||
description: 'Timer colour. Default: #F6F6F6',
|
||||
description: 'Timer colour. Default: #f6f6f6',
|
||||
type: 'colour',
|
||||
defaultValue: 'f6f6f6',
|
||||
},
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "38.2.1",
|
||||
"electron-builder": "26.15.3",
|
||||
"wait-on": "^9.0.0"
|
||||
"electron-builder": "26.9.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^17.0.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"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.28.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
|
||||
import type { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
@@ -35,7 +34,6 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneRundown,
|
||||
cloneSimpleRundownEntry,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
@@ -81,16 +79,9 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
|
||||
/**
|
||||
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
|
||||
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
|
||||
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
|
||||
* - which would silently corrupt the live cache without going through commit() - is a
|
||||
* compile-time error instead of a runtime bug.
|
||||
*/
|
||||
type Transaction<R> = {
|
||||
type Transaction = {
|
||||
customFields: CustomFields;
|
||||
rundown: R;
|
||||
rundown: Rundown;
|
||||
|
||||
commit: (shouldProcess?: boolean) => Promise<{
|
||||
rundown: Readonly<Rundown>;
|
||||
@@ -111,17 +102,11 @@ type TransactionOptions = {
|
||||
rundownId?: string;
|
||||
};
|
||||
|
||||
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
|
||||
export function createTransaction(
|
||||
options: TransactionOptions & { mutableRundown?: false },
|
||||
): Transaction<DeepReadonly<Rundown>>;
|
||||
export function createTransaction(
|
||||
options: TransactionOptions,
|
||||
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
|
||||
export function createTransaction(options: TransactionOptions): Transaction {
|
||||
const targetId = options.rundownId ?? cachedRundown.id;
|
||||
const isLoaded = targetId === cachedRundown.id;
|
||||
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
|
||||
const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
|
||||
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
|
||||
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||
|
||||
/**
|
||||
@@ -722,7 +707,7 @@ export const customFieldMutation = {
|
||||
* Expose function to add an initial rundown to the system
|
||||
*/
|
||||
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
||||
const rundown = cloneRundown(initialRundown);
|
||||
const rundown = structuredClone(initialRundown);
|
||||
const customFields = structuredClone(initialCustomFields);
|
||||
const processedData = processRundown(rundown, customFields, { mutate: true });
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
|
||||
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* Parse a rundowns object along with the project custom fields
|
||||
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
|
||||
};
|
||||
|
||||
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
|
||||
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
|
||||
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
|
||||
}
|
||||
|
||||
function getMetadata(): ProcessedRundownMetadata {
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
import { parseRundown } from './rundown.parser.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
cloneRundown,
|
||||
generateEvent,
|
||||
getFirstInsertId,
|
||||
getIntegerAndFraction,
|
||||
@@ -627,7 +626,7 @@ export async function editCustomField(
|
||||
// ... reassign references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
}
|
||||
@@ -667,7 +666,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
||||
// remove references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
}
|
||||
@@ -847,7 +846,7 @@ export async function duplicateExistingRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
const duplicatedRundown: Rundown = cloneRundown(rundown);
|
||||
const duplicatedRundown: Rundown = structuredClone(rundown);
|
||||
duplicatedRundown.id = generateId();
|
||||
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
||||
duplicatedRundown.revision = 0;
|
||||
|
||||
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
|
||||
const structure = isOntimeGroup(incomingEntry)
|
||||
? { entries: incomingEntry.entries }
|
||||
: { parent: incomingEntry.parent };
|
||||
entries[id] = cloneEntryData({ ...merged, ...structure });
|
||||
entries[id] = structuredClone({ ...merged, ...structure });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,97 +499,6 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
|
||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||
}
|
||||
|
||||
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
||||
|
||||
/**
|
||||
* The keys of `T` holding a reference (object or array) rather than a primitive: exactly the
|
||||
* fields a shallow spread aliases instead of copying, and so exactly the fields
|
||||
* `cloneEntryData` has to give a fresh copy to.
|
||||
* Branded primitives (eg. `Day`) resolve as primitives, which is what we want - they are
|
||||
* plain numbers at runtime.
|
||||
*/
|
||||
type ReferenceKeys<T> = { [K in keyof T]-?: T[K] extends Primitive ? never : K }[keyof T];
|
||||
|
||||
/** The member of the `OntimeEntry` union carrying a given `type` tag */
|
||||
type EntryOfType<K extends SupportedEntry> = Extract<OntimeEntry, { type: K }>;
|
||||
|
||||
/**
|
||||
* The reference-typed fields that `cloneEntryData` below gives a fresh copy to, per entry type.
|
||||
* This is the one place to update when an entry gains or loses such a field - the shape is
|
||||
* checked against the real types by `UnclonedReferenceFields`.
|
||||
*/
|
||||
type ClonedReferenceFields = {
|
||||
[SupportedEntry.Event]: 'custom' | 'triggers';
|
||||
[SupportedEntry.Group]: 'custom' | 'entries';
|
||||
[SupportedEntry.Milestone]: 'custom';
|
||||
[SupportedEntry.Delay]: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every reference-typed field `cloneEntryData` would alias instead of copy. Stays `never`
|
||||
* while the clone is complete, so `AssertEntryClonesEveryReference` fails the build when:
|
||||
* - an entry type gains a reference-typed field -> the field name resolves here, and is
|
||||
* named in the error, until it is copied in the switch and listed above
|
||||
* - `SupportedEntry` gains a member -> indexing `ClonedReferenceFields` fails here
|
||||
* A new primitive field needs no action: the spread already copies it by value.
|
||||
*/
|
||||
type UnclonedReferenceFields = {
|
||||
[K in SupportedEntry]: Exclude<ReferenceKeys<EntryOfType<K>>, ClonedReferenceFields[K]>;
|
||||
}[SupportedEntry];
|
||||
|
||||
type AssertNever<T extends never> = T;
|
||||
|
||||
/**
|
||||
* Compile-time guard only, with no runtime meaning - see `UnclonedReferenceFields`.
|
||||
* Exported because `noUnusedLocals` rejects an unreferenced local type.
|
||||
*/
|
||||
export type AssertEntryClonesEveryReference = AssertNever<UnclonedReferenceFields>;
|
||||
|
||||
/**
|
||||
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
|
||||
* Drop-in replacement for `structuredClone(entry)`: the generic structured-clone
|
||||
* algorithm does far more work than the plain object spreads an entry this shallow needs.
|
||||
*/
|
||||
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
|
||||
switch (entry.type) {
|
||||
case SupportedEntry.Event:
|
||||
return { ...entry, custom: { ...entry.custom }, triggers: entry.triggers?.slice() ?? [] } as T;
|
||||
case SupportedEntry.Group:
|
||||
return { ...entry, custom: { ...entry.custom }, entries: entry.entries?.slice() ?? [] } as T;
|
||||
case SupportedEntry.Milestone:
|
||||
return { ...entry, custom: { ...entry.custom } } as T;
|
||||
case SupportedEntry.Delay:
|
||||
return { ...entry } as T;
|
||||
default: {
|
||||
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
|
||||
const unhandled: never = entry;
|
||||
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, shape-aware clone of a whole rundown.
|
||||
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
|
||||
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
|
||||
* the result freely without touching the source - same contract as structuredClone,
|
||||
* at a fraction of the cost since we skip the generic serialization algorithm.
|
||||
*/
|
||||
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
|
||||
const entries: RundownEntries = {};
|
||||
for (const id in rundown.entries) {
|
||||
entries[id] = cloneEntryData(rundown.entries[id]);
|
||||
}
|
||||
return {
|
||||
id: rundown.id,
|
||||
title: rundown.title,
|
||||
revision: rundown.revision,
|
||||
order: rundown.order.slice(),
|
||||
flatOrder: rundown.flatOrder.slice(),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for calculating if the current events should have a day offset
|
||||
* @param current the current event under test
|
||||
|
||||
@@ -464,8 +464,9 @@ export async function upload(sheetId: string, options: ImportMap) {
|
||||
sheetOrder.forEach((entryId, index) => {
|
||||
const isGroupEnd = entryId.startsWith('group-end-');
|
||||
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
|
||||
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
|
||||
const entry = isGroupEnd
|
||||
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||
: structuredClone(rundown.entries[id]);
|
||||
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -20,9 +20,12 @@ 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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { cloneRundown } from '../../api-data/rundown/rundown.utils.js';
|
||||
import { isTest } from '../../setup/environment.js';
|
||||
import { shouldCrashDev } from '../../utils/development.js';
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
@@ -103,7 +102,7 @@ function getCustomFields(): Readonly<CustomFields> {
|
||||
}
|
||||
|
||||
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
|
||||
db.data.rundowns[rundownKey] = cloneRundown(newData);
|
||||
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||
await persist();
|
||||
return db.data.rundowns;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,12 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* Merges a partial ontime project into a given ontime project
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
||||
// rundowns are merged separately below by reference (only the top-level map is copied,
|
||||
// same as the other properties here) - deep-cloning them here would be wasted work,
|
||||
// since a project's rundowns are by far the largest part of this object
|
||||
const { rundowns: existingRundowns, ...existingRest } = existing;
|
||||
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
|
||||
|
||||
const deepExisting = structuredClone(existingRest);
|
||||
const deepNewData = structuredClone(newDataRest);
|
||||
const deepExisting = structuredClone(existing);
|
||||
const deepNewData = structuredClone(newData);
|
||||
|
||||
// destructure each property to simplify merging not provided ie: ...{} has no effect
|
||||
const {
|
||||
rundowns = {},
|
||||
project = {},
|
||||
settings = {},
|
||||
viewSettings = {},
|
||||
@@ -24,7 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
|
||||
} = deepNewData;
|
||||
|
||||
return {
|
||||
rundowns: { ...existingRundowns, ...newRundowns },
|
||||
rundowns: { ...existing.rundowns, ...rundowns },
|
||||
project: { ...deepExisting.project, ...project },
|
||||
settings: { ...deepExisting.settings, ...settings },
|
||||
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/session/session.service.js', () => ({
|
||||
hasPassword: true,
|
||||
hashedPassword: 'valid-token',
|
||||
}));
|
||||
|
||||
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
|
||||
|
||||
function makeResponse() {
|
||||
return {
|
||||
redirect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
||||
return {
|
||||
cookie,
|
||||
get authorization(): never {
|
||||
throw new Error('Authorization header should not be read');
|
||||
},
|
||||
};
|
||||
}
|
||||
import { isPublicAssetRequest } from '../authenticate.js';
|
||||
|
||||
describe('isPublicAssetRequest()', () => {
|
||||
it('allows root public assets without a prefix', () => {
|
||||
@@ -43,144 +18,3 @@ describe('isPublicAssetRequest()', () => {
|
||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for API requests', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for redirecting routes', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
originalUrl: '/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
||||
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
||||
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
||||
|
||||
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('authenticates API requests with a bearer token', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'bearer valid-token ' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
authenticate(req, makeResponse(), next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('authenticates redirecting routes with a bearer token', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
originalUrl: '/stage/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticateAndRedirect(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates WebSocket handshakes with a bearer token', () => {
|
||||
const req = {
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
} as IncomingMessage;
|
||||
|
||||
authenticateSocket({} as never, req, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects an invalid bearer token', () => {
|
||||
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
|
||||
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
|
||||
const redirectRes = makeResponse();
|
||||
authenticateAndRedirect(redirectReq, redirectRes, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
|
||||
|
||||
const socketNext = vi.fn();
|
||||
authenticateSocket(
|
||||
{} as never,
|
||||
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
|
||||
socketNext,
|
||||
);
|
||||
|
||||
expect(socketNext).toHaveBeenCalledOnce();
|
||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||
});
|
||||
|
||||
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
|
||||
'rejects lookalike WebSocket query tokens in %s',
|
||||
(url) => {
|
||||
const socketNext = vi.fn();
|
||||
|
||||
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
|
||||
|
||||
expect(socketNext).toHaveBeenCalledOnce();
|
||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,16 +77,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||
|
||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
if (req.query.token) {
|
||||
if (req.query.token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
return next();
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
res.status(401).send('Unauthorized');
|
||||
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
// we expect the token to be in the cookies
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
if (req.query.token === hashedPassword) {
|
||||
if (hashedPassword !== undefined) {
|
||||
setSessionCookie(res, hashedPassword, prefix);
|
||||
}
|
||||
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
||||
// check if the token is in the cookie
|
||||
const cookieString = req.headers.cookie;
|
||||
if (typeof cookieString === 'string') {
|
||||
const cookies = parseCookie(cookieString);
|
||||
if (cookies.token) {
|
||||
const token = getTokenFromCookie(cookies.token);
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if token is in the params - simple string check first
|
||||
const urlString = req.url || '';
|
||||
if (urlString.includes(`token=${hashedPassword}`)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
||||
return next();
|
||||
// fallback to full URL parsing for other formats
|
||||
try {
|
||||
const url = new URL(urlString, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore URL parsing errors
|
||||
}
|
||||
|
||||
return next(new Error('Unauthorized'));
|
||||
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
||||
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
||||
if (typeof cookieContents !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fast path: avoid JSON parsing when the expected token can be found directly
|
||||
const cookieTokenString = '"token":"' + hashedPassword + '"';
|
||||
/**
|
||||
* When calling this function we already know a cookie called 'token' exists
|
||||
* And want to extract its value
|
||||
*/
|
||||
function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||
// Fast path: check if the hashed password is directly in the cookie string
|
||||
// This avoids JSON parsing for the common case
|
||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// Fallback to JSON parsing for other cases or validation
|
||||
try {
|
||||
const cookie = JSON.parse(cookieContents);
|
||||
if (cookie && typeof cookie.token === 'string') {
|
||||
@@ -184,19 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
|
||||
// no error handling to do here
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
||||
return authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
|
||||
}
|
||||
|
||||
function getTokenFromParams(params: string | Record<string, unknown> | undefined, host?: string): string | undefined {
|
||||
if (typeof params !== 'string') {
|
||||
return typeof params?.token === 'string' ? params.token : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
findNextPlayableId,
|
||||
findNextPlayableWithCue,
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from '../runtime.utils.js';
|
||||
|
||||
describe('isNewSecond()', () => {
|
||||
it('is false while the value moves within the same second', () => {
|
||||
// count down rounds up, so both resolve to second 2
|
||||
expect(isNewSecond(1500, 1200)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once the value crosses a second boundary', () => {
|
||||
expect(isNewSecond(1001, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('rounds according to the given direction', () => {
|
||||
// 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1
|
||||
expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false);
|
||||
expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false);
|
||||
// 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2
|
||||
expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true);
|
||||
expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats null and undefined as second zero', () => {
|
||||
expect(isNewSecond(undefined, null)).toBe(false);
|
||||
expect(isNewSecond(null, 0)).toBe(false);
|
||||
expect(isNewSecond(undefined, 500)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldClockUpdate()', () => {
|
||||
it('is false within the same second and true across the boundary', () => {
|
||||
expect(getShouldClockUpdate(1000, 1999)).toBe(false);
|
||||
expect(getShouldClockUpdate(1000, 2000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldTimerUpdate()', () => {
|
||||
const baseTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
current: 10000,
|
||||
duration: 10000,
|
||||
elapsed: 0,
|
||||
expectedFinish: 10000,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 0,
|
||||
};
|
||||
|
||||
it('always updates when there is no previous state', () => {
|
||||
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update while the timer ticks within the same second', () => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false);
|
||||
});
|
||||
|
||||
it('updates when the timer crosses a second', () => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when the secondary timer crosses a second', () => {
|
||||
const previous = { ...baseTimer, secondaryTimer: 2000 };
|
||||
// counting down rounds up, so 1999 is still second 2
|
||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false);
|
||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['addedTime', { addedTime: 1 }],
|
||||
['duration', { duration: 1 }],
|
||||
['phase', { phase: TimerPhase.Warning }],
|
||||
['playback', { playback: Playback.Pause }],
|
||||
['startedAt', { startedAt: 1 }],
|
||||
])('updates immediately when %s changes', (_label, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['elapsed', { elapsed: 1 }],
|
||||
['expectedFinish', { expectedFinish: 1 }],
|
||||
])('does not update on %s alone, since it is derived', (_label, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldOffsetUpdate()', () => {
|
||||
const baseOffset: Offset = {
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
expectedGroupEnd: null,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
};
|
||||
|
||||
it('always updates when there is no previous state', () => {
|
||||
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates on a mode change even when no dependency ticked', () => {
|
||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('holds back value changes until a dependency ticks', () => {
|
||||
const next = { ...baseOffset, absolute: 1000 };
|
||||
expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false);
|
||||
expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update when a dependency ticked but nothing changed', () => {
|
||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousPlayableId()', () => {
|
||||
const order = ['1', '2', '3'];
|
||||
|
||||
it('returns undefined when there is nothing to play', () => {
|
||||
expect(findPreviousPlayableId([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the first event when nothing is loaded', () => {
|
||||
expect(findPreviousPlayableId(order)).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the preceding event', () => {
|
||||
expect(findPreviousPlayableId(order, '3')).toBe('2');
|
||||
});
|
||||
|
||||
it('stays on the first event when already at the top', () => {
|
||||
expect(findPreviousPlayableId(order, '1')).toBe('1');
|
||||
});
|
||||
|
||||
it('falls back to the first event when the loaded id is unknown', () => {
|
||||
expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextPlayableId()', () => {
|
||||
const order = ['1', '2', '3'];
|
||||
|
||||
it('returns undefined when there is nothing to play', () => {
|
||||
expect(findNextPlayableId([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the first event when nothing is loaded', () => {
|
||||
expect(findNextPlayableId(order)).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the following event', () => {
|
||||
expect(findNextPlayableId(order, '1')).toBe('2');
|
||||
});
|
||||
|
||||
it('wraps to the first event from the last', () => {
|
||||
expect(findNextPlayableId(order, '3')).toBe('1');
|
||||
});
|
||||
|
||||
it('falls back to the first event when the loaded id is unknown', () => {
|
||||
expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextPlayableWithCue()', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3', '4'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', cue: 'a' }),
|
||||
'2': makeOntimeEvent({ id: '2', cue: 'b' }),
|
||||
'3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }),
|
||||
'4': makeOntimeEvent({ id: '4', cue: 'b' }),
|
||||
},
|
||||
});
|
||||
const order = ['1', '2', '3', '4'];
|
||||
|
||||
it('finds the next event with the given cue', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('skips events which are not playable', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4');
|
||||
});
|
||||
|
||||
it('wraps around to the start of the rundown', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1');
|
||||
});
|
||||
|
||||
it('excludes the current event unless allowCurrent is set', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4');
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('returns undefined when no event carries the cue', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventAtIndex()', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2' }),
|
||||
},
|
||||
});
|
||||
|
||||
it('returns the event at the given index', () => {
|
||||
expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('returns undefined when the index is out of range', () => {
|
||||
expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined();
|
||||
expect(getEventAtIndex(rundown, [], 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
|
||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { timerConfig } from '../../setup/config.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
@@ -755,7 +754,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||
RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null;
|
||||
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,47 +48,6 @@ test.describe('test view navigation feature', () => {
|
||||
await expect(page).toHaveURL('/timer');
|
||||
});
|
||||
|
||||
/**
|
||||
* The shortcut has to read the current open state, not the one captured when it was
|
||||
* registered, otherwise it only ever opens the menu.
|
||||
*/
|
||||
test('Space closes the menu as well as opening it', async ({ page }) => {
|
||||
const menu = page.getByRole('dialog');
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await openNavigationMenu(page);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('Space closes a menu opened with the button', async ({ page }) => {
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-menu').click();
|
||||
|
||||
const menu = page.getByRole('dialog');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('Space toggles a focused menu switch without closing the menu', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
|
||||
const menu = page.getByRole('dialog');
|
||||
const flipScreen = page.getByRole('switch', { name: 'Flip Screen' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const initiallyChecked = await flipScreen.getAttribute('aria-checked');
|
||||
await flipScreen.focus();
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await expect(flipScreen).toHaveAttribute('aria-checked', initiallyChecked === 'true' ? 'false' : 'true');
|
||||
await expect(menu).toBeVisible();
|
||||
});
|
||||
|
||||
test('not-found', async ({ page }) => {
|
||||
await page.goto('/not-found');
|
||||
|
||||
|
||||
@@ -6,25 +6,6 @@ test('cuesheet displays events', async ({ page }) => {
|
||||
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('cuesheet persists column visibility', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
const noteHeader = page.getByRole('columnheader', { name: 'Note', exact: true });
|
||||
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
|
||||
await expect(noteHeader).toBeVisible();
|
||||
await expect(noteCell).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Columns' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Note', exact: true }).click();
|
||||
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
});
|
||||
|
||||
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ test.describe('URL Preset', () => {
|
||||
.filter({ hasText: /^testingApply$/ })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await expect(page.getByText('Current')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible();
|
||||
|
||||
// 2. the URL contains the preset
|
||||
expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
|
||||
|
||||
@@ -13,28 +13,3 @@ test('View params configures timer view', async ({ page }) => {
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The form gathers its values from the DOM, so a collapsed section has to stay mounted.
|
||||
* Unmounting it would quietly drop everything the user set in it on the next apply.
|
||||
*/
|
||||
test('View params keeps the values of collapsed sections', async ({ page }) => {
|
||||
// hideClock lives in the section we are about to collapse
|
||||
await page.goto('/timer?hideClock=true');
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-settings').click();
|
||||
|
||||
const section = page.getByRole('button', { name: /Element visibility/ });
|
||||
await expect(section).toHaveAttribute('aria-expanded', 'true');
|
||||
await section.focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(section).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('dialog', { name: 'Ontime' })).toBeHidden();
|
||||
|
||||
await page.getByTestId('apply-view-params').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
});
|
||||
|
||||
+5
-5
@@ -41,14 +41,14 @@
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"rimraf": "catalog:",
|
||||
"turbo": "2.10.10",
|
||||
"turbo": "2.8.20",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"nanoid": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"ontime-types": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { colourToHex, cssOrHexToColour, hexToColour, isLightColour, mixColours } from './colour.utils';
|
||||
|
||||
describe('hexToColour()', () => {
|
||||
it('parses a full length hex', () => {
|
||||
expect(hexToColour('#ff8800')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('parses a compressed hex by duplicating each digit', () => {
|
||||
expect(hexToColour('#f80')).toStrictEqual(hexToColour('#ff8800'));
|
||||
});
|
||||
|
||||
it('parses the alpha channel of a full length hex', () => {
|
||||
expect(hexToColour('#ff880000')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 0 });
|
||||
expect(hexToColour('#ff8800ff')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('parses the alpha channel of a compressed hex', () => {
|
||||
expect(hexToColour('#f800')).toStrictEqual(hexToColour('#ff880000'));
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(hexToColour('#FF8800')).toStrictEqual(hexToColour('#ff8800'));
|
||||
});
|
||||
|
||||
it('returns null for values which are not a hex colour', () => {
|
||||
// these are the values which reach us from user input
|
||||
for (const invalid of ['', 'red', '#', '#ff', '#fffff', '#ffg', 'ff8800']) {
|
||||
expect(hexToColour(invalid)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('colourToHex()', () => {
|
||||
it('pads single digit channels', () => {
|
||||
expect(colourToHex({ red: 0, green: 1, blue: 2, alpha: 1 })).toBe('#000102ff');
|
||||
});
|
||||
|
||||
it('round trips with hexToColour', () => {
|
||||
for (const hex of ['#000000ff', '#ff8800ff', '#ffffffff', '#12345600']) {
|
||||
expect(colourToHex(hexToColour(hex)!)).toBe(hex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cssOrHexToColour()', () => {
|
||||
it('resolves named css colours', () => {
|
||||
expect(cssOrHexToColour('red')).toStrictEqual({ red: 255, green: 0, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('resolves named css colours regardless of casing', () => {
|
||||
expect(cssOrHexToColour('CornflowerBlue')).toStrictEqual(cssOrHexToColour('cornflowerblue'));
|
||||
});
|
||||
|
||||
it('delegates hex values to the hex parser', () => {
|
||||
expect(cssOrHexToColour('#f80')).toStrictEqual(hexToColour('#f80'));
|
||||
});
|
||||
|
||||
it('returns null for an unknown colour name', () => {
|
||||
expect(cssOrHexToColour('not-a-colour')).toBeNull();
|
||||
expect(cssOrHexToColour('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixColours()', () => {
|
||||
const black = { red: 0, green: 0, blue: 0, alpha: 1 };
|
||||
const white = { red: 255, green: 255, blue: 255, alpha: 1 };
|
||||
|
||||
it('defaults to an even mix', () => {
|
||||
expect(mixColours(black, white)).toStrictEqual({ red: 128, green: 128, blue: 128, alpha: 1 });
|
||||
});
|
||||
|
||||
it('weights the first colour by the given proportion', () => {
|
||||
expect(mixColours(black, white, 1)).toStrictEqual({ ...black, alpha: 1 });
|
||||
expect(mixColours(black, white, 0)).toStrictEqual({ ...white, alpha: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLightColour()', () => {
|
||||
it('detects light and dark colours', () => {
|
||||
expect(isLightColour({ red: 255, green: 255, blue: 255, alpha: 1 })).toBe(true);
|
||||
expect(isLightColour({ red: 0, green: 0, blue: 0, alpha: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('weights green most heavily, as per the YIQ calculation', () => {
|
||||
// pure green is considered light, pure blue is not
|
||||
expect(isLightColour({ red: 0, green: 255, blue: 0, alpha: 1 })).toBe(true);
|
||||
expect(isLightColour({ red: 0, green: 0, blue: 255, alpha: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+2115
-1151
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@types/node': 22.19.11
|
||||
rimraf: 6.1.3
|
||||
ts-essentials: 10.2.1
|
||||
rimraf: 6.0.1
|
||||
ts-essentials: 10.1.1
|
||||
typescript: 7.0.2
|
||||
vitest: 4.1.10
|
||||
vitest: 4.0.17
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
|
||||
Reference in New Issue
Block a user