mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-26 17:39:58 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc9ae1f1a7 | |||
| ce7717f26d | |||
| 0106145a45 | |||
| e4cfa1d17d |
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
|
||||
---
|
||||
|
||||
# Review Ontime changes
|
||||
|
||||
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
|
||||
|
||||
## Load relevant context
|
||||
|
||||
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
|
||||
|
||||
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
|
||||
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
|
||||
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
|
||||
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
|
||||
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
|
||||
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
|
||||
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
|
||||
|
||||
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
|
||||
|
||||
## Review order
|
||||
|
||||
1. Establish intent and affected runtime surfaces.
|
||||
2. Read tests first. Identify claimed behaviour and coverage.
|
||||
3. Trace implementation, errors, and state transitions.
|
||||
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
|
||||
5. Verify claimed checks. Never claim unobserved results.
|
||||
|
||||
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
|
||||
|
||||
## Findings
|
||||
|
||||
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
|
||||
|
||||
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
|
||||
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
|
||||
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
|
||||
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
|
||||
|
||||
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
|
||||
|
||||
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Ontime agent guide
|
||||
|
||||
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
|
||||
|
||||
## Load only relevant guides
|
||||
|
||||
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
|
||||
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
|
||||
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
|
||||
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
|
||||
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
|
||||
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
|
||||
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
|
||||
|
||||
Load multiple guides when needed. Skip unrelated guides for mechanical work.
|
||||
|
||||
## Before handoff
|
||||
|
||||
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
|
||||
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
|
||||
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ interface UseFollowComponentProps {
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
doFollow: boolean;
|
||||
topOffset?: number;
|
||||
/**
|
||||
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
|
||||
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
|
||||
*/
|
||||
getTopOffset?: () => number;
|
||||
setScrollFlag?: (newValue: boolean) => void;
|
||||
followTrigger?: MaybeString; // this would be an entry id or null
|
||||
}
|
||||
@@ -31,6 +36,7 @@ export default function useFollowComponent({
|
||||
scrollRef,
|
||||
doFollow,
|
||||
topOffset = 100,
|
||||
getTopOffset,
|
||||
setScrollFlag,
|
||||
followTrigger,
|
||||
}: UseFollowComponentProps) {
|
||||
@@ -44,21 +50,23 @@ export default function useFollowComponent({
|
||||
setScrollFlag?.(true);
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
||||
// resolve the offset after layout, so that measured values are up to date
|
||||
const offset = getTopOffset?.() ?? topOffset;
|
||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
|
||||
setScrollFlag?.(false);
|
||||
});
|
||||
}
|
||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
|
||||
|
||||
const scrollToRefComponent = useCallback(
|
||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
|
||||
if (componentRef && containerRef) {
|
||||
// @ts-expect-error -- we know this are not null
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
scrollToComponent(componentRef!, containerRef!, offset);
|
||||
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
|
||||
}
|
||||
},
|
||||
[followRef, scrollRef, topOffset],
|
||||
[followRef, scrollRef, topOffset, getTopOffset],
|
||||
);
|
||||
|
||||
return scrollToRefComponent;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
padding-bottom: 95vh;
|
||||
}
|
||||
|
||||
/* a group and its events, bounds the sticky group header to its own section.
|
||||
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
|
||||
.groupSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.editPrompt {
|
||||
position: fixed;
|
||||
z-index: $zindex-dialog;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
@@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// the group of the running event is pinned to the top of the list, the running event needs to clear it
|
||||
const getStickyOffset = useCallback(() => {
|
||||
const header = stickyHeaderRef.current;
|
||||
// account for the list gap, so that the rows do not touch
|
||||
return header ? header.offsetHeight + 2 : 0;
|
||||
}, []);
|
||||
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
|
||||
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: selectedOffset,
|
||||
getTopOffset,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
@@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
if (selectedRect && scrollerRect) {
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||
}
|
||||
@@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<div className={style.groupSection} key={entry.id}>
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
isLive={isCurrentParent}
|
||||
/>
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
@@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,17 +3,32 @@
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||
background-color: $gray-1350;
|
||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
|
||||
and is what keeps the rows from showing through while the header is pinned */
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
/* stays visible as a header for the events of the group, bound by the group section */
|
||||
position: sticky;
|
||||
/* covers the list padding, otherwise the rows scroll through it */
|
||||
top: -0.25rem;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
|
||||
the card is already filled with the group colour, so we use the lighter active indicator over a dark
|
||||
frame, which stays legible against any of them, green groups included */
|
||||
.live {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px $ui-black,
|
||||
inset 0 0 0 4px $active-indicator;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { CSSProperties, Ref, memo } from 'react';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorGroup.module.scss';
|
||||
@@ -10,15 +10,22 @@ interface OperatorGroup {
|
||||
colour: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
/** whether the group contains the currently running event */
|
||||
isLive?: boolean;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export default memo(OperatorGroup);
|
||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
|
||||
const groupColour = colour || '#929292';
|
||||
const groupColours = getAccessibleColour(groupColour);
|
||||
|
||||
return (
|
||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||
<div
|
||||
className={cx([style.group, isLive && style.live])}
|
||||
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={style.title}>{title}</span>
|
||||
<span className={style.meta}>
|
||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,6 +103,21 @@ $item-height: 3.5rem;
|
||||
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
||||
}
|
||||
|
||||
/* a subscribed group and its subscribed events, bounds the sticky header to its own section.
|
||||
flex rather than block so that the row margins do not collapse, keeping the spacing between
|
||||
rows of a section equal to the spacing between sections */
|
||||
.sub-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* scoped to the section, the select view shows the same cards in a flat list */
|
||||
.sub-section .sub--group {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ====================== LIST-ITEM ======================*/
|
||||
.sub {
|
||||
margin: 2px;
|
||||
@@ -196,9 +211,15 @@ $item-height: 3.5rem;
|
||||
|
||||
.sub--group {
|
||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||
background:
|
||||
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
|
||||
split into longhands since the shorthand only takes a colour in its last layer */
|
||||
background-color: var(--background-color-override, $viewer-background-color);
|
||||
background-image:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||
var(--card-background-color-override, $viewer-card-bg-color);
|
||||
linear-gradient(
|
||||
var(--card-background-color-override, $viewer-card-bg-color),
|
||||
var(--card-background-color-override, $viewer-card-bg-color)
|
||||
);
|
||||
|
||||
.sub__binder {
|
||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
@@ -230,6 +251,16 @@ $item-height: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
|
||||
.sub--group.sub--live {
|
||||
box-shadow: inset 0 0 0 2px $active-green;
|
||||
}
|
||||
|
||||
/* loaded but not yet running, subtle enough to not compete with the live ring */
|
||||
.sub--group.sub--armed {
|
||||
box-shadow: inset 0 0 0 2px $gray-1000;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
grid-area: title;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CountdownTarget,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
groupSubscriptionTargets,
|
||||
isOutsideRange,
|
||||
preferredFormat12,
|
||||
preferredFormat24,
|
||||
@@ -48,11 +49,25 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
|
||||
|
||||
// The group of the running event is pinned to the top of the list, the running event needs to clear it.
|
||||
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
|
||||
// against vw), it grows while live since the running event's title is added, and long titles wrap.
|
||||
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
|
||||
const getStickyOffset = useCallback(() => {
|
||||
const header = stickyHeaderRef.current;
|
||||
// add a row gap, the 2px margins of the header and of the running event
|
||||
return header ? header.offsetHeight + 4 : 0;
|
||||
}, []);
|
||||
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: 0,
|
||||
getTopOffset: getStickyOffset,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
@@ -79,7 +94,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
if (selectedRect && scrollerRect) {
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||
}
|
||||
@@ -98,41 +114,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
|
||||
return (
|
||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{subscribedEvents.map((event) => {
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
||||
// a subscribed group is live when any of its children is the selected/running event
|
||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
||||
{sections.map((section) => {
|
||||
const rows = section.group ? [section.group, ...section.events] : section.events;
|
||||
// the running event anchors the scroll, the group header stays pinned above it
|
||||
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
className={cx([
|
||||
'sub',
|
||||
isLive && 'sub--live',
|
||||
isArmed && 'sub--armed',
|
||||
event.isGroup && 'sub--group',
|
||||
isGroupedEvent && 'sub--in-group',
|
||||
])}
|
||||
data-testid={event.cue}
|
||||
>
|
||||
<div
|
||||
className='sub__binder'
|
||||
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||
/>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||
{displayTitle}
|
||||
</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
|
||||
{rows.map((event) => {
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
||||
// a subscribed group is live when any of its children is the selected/running event
|
||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
||||
// only ever hand the ref to a single row, sharing it would null it out on the next commit
|
||||
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
|
||||
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
|
||||
const countdownEvent = extendEventData(
|
||||
event,
|
||||
currentDay,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
offset,
|
||||
mode,
|
||||
reportData,
|
||||
);
|
||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
ref={rowRef}
|
||||
className={cx([
|
||||
'sub',
|
||||
isLive && 'sub--live',
|
||||
isArmed && 'sub--armed',
|
||||
event.isGroup && 'sub--group',
|
||||
isGroupedEvent && 'sub--in-group',
|
||||
])}
|
||||
data-testid={event.cue}
|
||||
>
|
||||
<div
|
||||
className='sub__binder'
|
||||
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||
/>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||
{displayTitle}
|
||||
</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { resolveSubscriptionTarget } from './countdown.utils';
|
||||
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
|
||||
|
||||
/**
|
||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
|
||||
expect(result?.liveEntry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupSubscriptionTargets()', () => {
|
||||
/**
|
||||
* Resolves a group the same way the view does, so that the tests exercise the real target shape
|
||||
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
|
||||
*/
|
||||
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
|
||||
const resolved = resolveSubscriptionTarget(group, flat);
|
||||
if (resolved === null) {
|
||||
throw new Error('test setup: group has no playable children');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
it('returns no sections for an empty subscription list', () => {
|
||||
expect(groupSubscriptionTargets([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each ungrouped event its own section', () => {
|
||||
const e1 = makeEvent({ id: 'e1' });
|
||||
const e2 = makeEvent({ id: 'e2' });
|
||||
|
||||
expect(groupSubscriptionTargets([e1, e2])).toEqual([
|
||||
{ group: null, events: [e1] },
|
||||
{ group: null, events: [e2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('absorbs the children of a subscribed group into its section', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
|
||||
const resolved = resolveGroup(group, [group, c1, c2]);
|
||||
|
||||
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
|
||||
});
|
||||
|
||||
it('keeps a subscribed group with no subscribed children as an empty section', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||
const resolved = resolveGroup(group, [group, c1]);
|
||||
|
||||
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
|
||||
});
|
||||
|
||||
it('does not absorb an event which belongs to a different group', () => {
|
||||
const group1 = makeGroup({ id: 'g1' });
|
||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||
const group2 = makeGroup({ id: 'g2' });
|
||||
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
||||
const flat = [group1, c1, group2, c2];
|
||||
const resolved1 = resolveGroup(group1, flat);
|
||||
const resolved2 = resolveGroup(group2, flat);
|
||||
|
||||
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
|
||||
{ group: resolved1, events: [c1] },
|
||||
{ group: resolved2, events: [c2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not absorb an event whose parent group is not subscribed', () => {
|
||||
const group1 = makeGroup({ id: 'g1' });
|
||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||
const group2 = makeGroup({ id: 'g2' });
|
||||
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
||||
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
|
||||
|
||||
// only the first group is subscribed, so the second group's child stands alone
|
||||
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
|
||||
{ group: resolved1, events: [c1] },
|
||||
{ group: null, events: [c2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes a section when an ungrouped event follows a group', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||
const e1 = makeEvent({ id: 'e1' });
|
||||
const resolved = resolveGroup(group, [group, c1]);
|
||||
|
||||
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
|
||||
{ group: resolved, events: [c1] },
|
||||
{ group: null, events: [e1] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +252,44 @@ export function resolveSubscriptionTarget(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A subscribed group along with the subscribed events which belong to it.
|
||||
* Events without a subscribed parent group form their own section with no group.
|
||||
*/
|
||||
export type CountdownSection = {
|
||||
group: CountdownTarget | null;
|
||||
events: CountdownTarget[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Folds the flat, rundown ordered subscription targets into sections.
|
||||
* A group opens a section which absorbs the following targets that declare it as parent,
|
||||
* which allows the group to be rendered as a sticky header for its own events.
|
||||
*/
|
||||
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
|
||||
const sections: CountdownSection[] = [];
|
||||
let openSection: CountdownSection | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
|
||||
if (target.isGroup) {
|
||||
openSection = { group: target, events: [] };
|
||||
sections.push(openSection);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (openSection?.group && target.parent === openSection.group.id) {
|
||||
openSection.events.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
openSection = null;
|
||||
sections.push({ group: null, events: [target] });
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function extendEventData(
|
||||
event: CountdownTarget,
|
||||
currentDay: number,
|
||||
|
||||
@@ -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[]>({
|
||||
|
||||
@@ -195,25 +195,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================== SOUND PROMPT ===================*/
|
||||
.sound-prompt {
|
||||
position: absolute;
|
||||
bottom: $view-block-padding;
|
||||
left: $view-inline-padding;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: $element-border-radius;
|
||||
background-color: $viewer-card-bg-color;
|
||||
color: $viewer-secondary-color;
|
||||
font-size: $timer-label-size;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
transition: opacity $viewer-transition-time;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== LOGO ===================*/
|
||||
.logo {
|
||||
position: absolute;
|
||||
|
||||
@@ -8,7 +8,6 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
import { useTimerSound } from './useTimerSound';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
@@ -68,7 +66,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
endSound,
|
||||
font,
|
||||
keyColour,
|
||||
timerColour,
|
||||
@@ -78,8 +75,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const { showPrompt } = useTimerSound(time.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -161,8 +156,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
||||
|
||||
{showPrompt && <EnableSoundPrompt />}
|
||||
|
||||
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||
|
||||
{!hideMessage && (
|
||||
@@ -234,19 +227,3 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges the user to interact with the screen so that the browser allows audio playback
|
||||
* Any interaction arms the sound, so this is a hint rather than a control
|
||||
* It is tied to mouse movement since that does not itself grant playback permission,
|
||||
* which keeps the hint off screen unless somebody is at the machine to act on it
|
||||
*/
|
||||
function EnableSoundPrompt() {
|
||||
const isUserActive = useFadeOutOnInactivity(true);
|
||||
|
||||
return (
|
||||
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
|
||||
Tap the screen to enable sound
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
|
||||
import { shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
'sounds when a running timer goes into overtime from %s',
|
||||
(previousPhase) => {
|
||||
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
|
||||
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent for a roll timer waiting to start', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('sounds once, not on every update while in overtime', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent on phases which are not the end of the timer', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -76,14 +76,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'endSound',
|
||||
title: 'Play sound on timer end',
|
||||
description:
|
||||
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -177,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',
|
||||
},
|
||||
@@ -201,7 +193,6 @@ type TimerOptions = {
|
||||
freezeOvertime: boolean;
|
||||
freezeMessage: string;
|
||||
hidePhase: boolean;
|
||||
endSound: boolean;
|
||||
font?: string;
|
||||
keyColour?: string;
|
||||
timerColour?: string;
|
||||
@@ -236,7 +227,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
||||
freezeMessage: getValue('freezeMessage') ?? '',
|
||||
hidePhase: isStringBoolean(getValue('hidePhase')),
|
||||
endSound: isStringBoolean(getValue('endSound')),
|
||||
|
||||
font: getValue('font') ?? undefined,
|
||||
keyColour: makeColourString(getValue('keyColour')),
|
||||
|
||||
@@ -189,18 +189,3 @@ export function getCardData(
|
||||
nextSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the end of timer sound should play for a given phase transition
|
||||
* We only sound the transition into overtime from a phase that was already counting,
|
||||
* which keeps a client that connects or reloads mid-overtime silent
|
||||
*/
|
||||
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
|
||||
if (phase !== TimerPhase.Overtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import buzzer from '../../assets/sounds/buzzer.mp3';
|
||||
import { shouldPlayEndSound } from './timer.utils';
|
||||
|
||||
/**
|
||||
* Plays a sound when the timer reaches its end
|
||||
*
|
||||
* Browsers reject playback until the document has been interacted with, and that permission
|
||||
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
|
||||
* audio element on the first interaction and let the view prompt for one if it never comes.
|
||||
* Safari grants the permission per element, so priming has to call play() on this element from
|
||||
* inside the event handler, it is not enough to know that an interaction happened.
|
||||
*/
|
||||
export function useTimerSound(phase: TimerPhase, enabled: boolean): { showPrompt: boolean } {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previousPhaseRef = useRef<TimerPhase | null>(null);
|
||||
const [isArmed, setIsArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(buzzer);
|
||||
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
setIsArmed(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isArmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const prime = () => {
|
||||
audioRef.current
|
||||
?.play()
|
||||
.then(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setIsArmed(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// playback is still blocked, a later interaction will try again
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
|
||||
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, isArmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousPhase = previousPhaseRef.current;
|
||||
previousPhaseRef.current = phase;
|
||||
|
||||
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current?.play().catch(() => {
|
||||
// the screen has not been interacted with, the view shows a prompt for it
|
||||
});
|
||||
}, [enabled, phase]);
|
||||
|
||||
return { showPrompt: enabled && !isArmed };
|
||||
}
|
||||
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -818,7 +818,7 @@ export async function renameRundown(id: string, title: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
|
||||
await dataProvider.setRundown(id, { ...rundown, title });
|
||||
|
||||
/**
|
||||
* If we are modifying the loaded rundown we re-init it
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Ontime architecture
|
||||
|
||||
Use for module placement or cross-boundary changes.
|
||||
|
||||
## Direction
|
||||
|
||||
Dependencies point toward pure domain logic:
|
||||
|
||||
```text
|
||||
HTTP request
|
||||
-> validation / router or controller
|
||||
-> service orchestration
|
||||
-> pure domain utilities
|
||||
|
||||
service orchestration
|
||||
-> DAO / stores / adapters / external clients
|
||||
```
|
||||
|
||||
Keep a layer only for clearer ownership, isolated side effects, or direct business-rule tests.
|
||||
|
||||
## Reuse and ownership
|
||||
|
||||
Before adding a module, helper, service, or interface, find the concept owner and inspect callers.
|
||||
|
||||
1. Reuse when the contract already matches.
|
||||
2. Extend for the same concept when the contract stays coherent.
|
||||
3. Keep local when reuse needs flags, broad optional inputs, unrelated modes, or leaky terms.
|
||||
4. Generalise only after stable common behaviour appears across real callers.
|
||||
|
||||
Do not duplicate canonical rules or distort an abstraction to force reuse. Small local duplication can beat coupling unrelated concepts.
|
||||
|
||||
## Server
|
||||
|
||||
### Routers and controllers
|
||||
|
||||
Routers declare paths and middleware. Controllers map validated HTTP input to typed service arguments, then results/errors to responses.
|
||||
|
||||
Keep reusable calculations, domain decisions, transformations, persistence workflows, and integration coordination out of handlers. Simple reads may stay direct when a service would only pass through.
|
||||
|
||||
### Services
|
||||
|
||||
Services orchestrate use cases and side-effect boundaries. Make ordering and effects visible. Move substantial branching, calculation, comparison, parsing, and transformation to pure utilities.
|
||||
|
||||
### Pure utilities
|
||||
|
||||
Pass required state, config, and time explicitly. No I/O, stores/globals, logging, websocket publication, browser inspection, or caller-owned mutation unless explicitly contracted.
|
||||
|
||||
Use focused Vitest coverage. Colocate feature logic. Move to `ontime-utils` only for genuine cross-package use.
|
||||
|
||||
### State and boundaries
|
||||
|
||||
- DAOs/data providers: persistence.
|
||||
- Stores: mutable runtime state.
|
||||
- Adapters/clients: external protocols and integrations.
|
||||
- Validators/parsers: protect boundaries before domain logic.
|
||||
- Commit state before dependent notifications or invalidations.
|
||||
|
||||
Old layering exceptions are context, not precedent. Improve touched boundaries only through focused, behaviour-preserving moves.
|
||||
|
||||
## Client
|
||||
|
||||
- `common/api`: HTTP transport.
|
||||
- `common/hooks-query`: TanStack Query reads, mutations, keys, cache, invalidation.
|
||||
- `features`: reusable product capabilities and domain behaviour.
|
||||
- `views`: route-level composition.
|
||||
- `common`: genuinely cross-feature code.
|
||||
|
||||
Keep substantial rules out of JSX, effects, and handlers. Use tested, colocated pure utilities. TanStack Query owns server state; established Zustand/context owns local state. No parallel caches.
|
||||
|
||||
Limit subscriptions with selectors. Keep effect dependencies stable. Clean up listeners, intervals, external resources.
|
||||
|
||||
## Shared packages
|
||||
|
||||
- `ontime-types`: shared contracts; type-focused.
|
||||
- `ontime-utils`: environment-independent, side-effect-free shared logic.
|
||||
- Never import application layers into shared packages.
|
||||
- Keep feature-specific helpers with their owner, even when used by another file.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Business rule understandable/testable without app startup?
|
||||
- Transport, orchestration, state, transformation separated?
|
||||
- Existing owner reused without forcing unrelated behaviour?
|
||||
- Abstraction removes concepts rather than relocating them?
|
||||
- Smallest focused remedy clear?
|
||||
@@ -1,30 +0,0 @@
|
||||
# Change assessment
|
||||
|
||||
Use before non-trivial planning/implementation and during non-trivial review. Let it shape scope and verification; avoid process theatre.
|
||||
|
||||
## Rate three dimensions
|
||||
|
||||
- **Value** — concrete user, product, operational, or maintenance benefit; include urgency.
|
||||
- **Risk** — regression likelihood/impact: data loss, security, cloud incompatibility, disruption, hard rollback.
|
||||
- **Complexity** — concepts, dependencies, layers, states, verification surfaces; not line count.
|
||||
|
||||
Use `Low`, `Medium`, or `High`. Give one evidence-based sentence each. No pseudo-precise scores.
|
||||
|
||||
```markdown
|
||||
## Assessment
|
||||
|
||||
- Value: High — <concrete benefit>
|
||||
- Risk: Medium — <failure modes and reversibility>
|
||||
- Complexity: Low — <conceptual and verification burden>
|
||||
- Recommendation: Proceed | Reshape | Defer — <why>
|
||||
```
|
||||
|
||||
One assessment per overall change.
|
||||
|
||||
- High value never excuses unmanaged risk/complexity.
|
||||
- High risk needs earlier proof, narrower increments, rollback, or stronger checks.
|
||||
- High complexity needs clearer boundaries and smaller steps, not automatic abstraction.
|
||||
- Low value plus high risk/complexity suggests reshape or defer.
|
||||
- Revise after material scope discovery.
|
||||
|
||||
Plans: assessment before steps. Reviews: findings first, then assessment. Assessment informs; never replaces user intent or evidence.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Simplicity and maintainability
|
||||
|
||||
Use for abstractions, comments, helpers, naming, or structural complexity.
|
||||
|
||||
## Simplicity
|
||||
|
||||
Choose the smallest explicit, testable design. No extension points, generic engines, wrappers, or config for hypothetical needs.
|
||||
|
||||
- Prefer direct flow over clever expressions or scattered conditions.
|
||||
- Extract only to name a rule, enable pure tests, or remove meaningful duplication.
|
||||
- Keep abstractions only when they reduce reader-held concepts.
|
||||
- Reuse the concept owner when semantics match.
|
||||
- Do not add flags, optional branches, generic names, or extension points to merge unlike cases.
|
||||
- Prefer focused local helpers over generic APIs exposing unrelated modes.
|
||||
- Keep scope narrow; note unrelated cleanup.
|
||||
- Delete clearly obsolete branches, harnesses, and shims. Ask when ownership or compatibility is unclear.
|
||||
|
||||
## Comments
|
||||
|
||||
Keep comments for:
|
||||
|
||||
- non-obvious intent or invariants;
|
||||
- necessary ordering, timing, mutation, or side effects;
|
||||
- browser, Electron, cloud, or protocol constraints;
|
||||
- workaround reasons and removal conditions;
|
||||
- public contracts names/types cannot express.
|
||||
|
||||
Remove comments that:
|
||||
|
||||
- narrate code or names;
|
||||
- number obvious steps;
|
||||
- explain mechanics better expressed by code;
|
||||
- preserve removed history;
|
||||
- claim untested behaviour;
|
||||
- use banners to hide oversized modules.
|
||||
|
||||
Update adjacent comments with code. Stale comments are defects.
|
||||
|
||||
## Naming and types
|
||||
|
||||
- Prefer Ontime terms over vague `data`, `result`, `item`.
|
||||
- Prefer explicit/discriminated types over `any`, broad casts, optional fields, non-null assertions, silent fallbacks.
|
||||
- Handle unions/enums exhaustively when future cases could be unsafe.
|
||||
- Keep public functions focused enough to avoid long contract explanations.
|
||||
|
||||
## Review standard
|
||||
|
||||
Do not demand personal style. Report complexity only when it risks maintenance, hides rules, blocks focused tests, duplicates ownership, or makes changes unsafe. Suggest reuse only after verifying the existing contract fits without added genericity.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Ontime domain invariants
|
||||
|
||||
Load only for touched domains. Add only stable, recurring invariants; not one-off bugs.
|
||||
|
||||
## Rundowns and entries
|
||||
|
||||
- Keep `entries`, `order`, `flatOrder` normalised.
|
||||
- Keep group membership, group entry lists, child `parent` references consistent.
|
||||
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
|
||||
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
|
||||
- No caller-owned rundown mutation unless explicitly contracted.
|
||||
|
||||
## Persistence, realtime, cache
|
||||
|
||||
- No partial commit on failure.
|
||||
- Preserve revision/transaction semantics for loaded and background rundowns.
|
||||
- Persist before websocket refetches, runtime updates, integration notifications, or cache assumptions.
|
||||
- Notify only invalidated consumers; never leave client cache stale.
|
||||
- Avoid duplicate listeners, notifications, invalidations, lifecycle effects.
|
||||
- Reconnect/refetch must converge on authoritative state.
|
||||
- Align query keys and websocket refetch keys with the changed resource.
|
||||
|
||||
## Timers
|
||||
|
||||
Use temporal values by meaning: `Instant` for epoch time, `TimeOfDay` for local time since midnight, `Duration` for elapsed time, `Day` for calendar offsets. Convert through `timeCore`; never interchange as raw numbers.
|
||||
|
||||
Active work: [runtimeState time-core migration](../migrations/runtime-state-time-core.md).
|
||||
|
||||
When relevant, cover interactions among:
|
||||
|
||||
- midnight/day offsets;
|
||||
- linked events/gaps;
|
||||
- delays/skipped entries;
|
||||
- count-to-end;
|
||||
- absolute/relative offsets;
|
||||
- warning, danger, finish, roll, end-action transitions;
|
||||
- loaded/next-event state.
|
||||
|
||||
Pass time/state explicitly to keep rules deterministic and unit-testable.
|
||||
|
||||
## Imports and migrations
|
||||
|
||||
- Treat project files, spreadsheets, custom fields, migrated data as untrusted.
|
||||
- Preserve fields the import/migration does not own.
|
||||
- Validate/parse into the current model before runtime logic.
|
||||
- Avoid source mutation; test round trips and non-mutation when preservation matters.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Routing and Ontime Cloud
|
||||
|
||||
Use for navigation, URLs, endpoints, websockets, auth, cookies, assets, redirects, presets, or local-storage scope.
|
||||
|
||||
## Deployment invariant
|
||||
|
||||
Support root and runtime-prefixed deployments:
|
||||
|
||||
```text
|
||||
local: http://localhost:4001/timer
|
||||
cloud: https://cloud.example/client-hash/timer
|
||||
```
|
||||
|
||||
Prefix is deployment data. Never assume `/`.
|
||||
|
||||
## Client
|
||||
|
||||
- `apps/client/src/externals.ts`: derives `baseURI`, `serverURL`, `websocketUrl` from document base/current origin.
|
||||
- `BrowserRouter`: uses `baseURI` basename.
|
||||
- APIs/assets: use `common/api/constants.ts` or base-aware helpers.
|
||||
- App navigation: use React Router. Never strip/guess/re-add prefix from `window.location.pathname`.
|
||||
- Persisted browser state: use existing base-aware scoping where prefixes need isolation.
|
||||
|
||||
## Server
|
||||
|
||||
- `updateRouterPrefix()` in `apps/server/src/externals.ts`: normalises `ROUTER_PREFIX`.
|
||||
- `apps/server/src/app.ts`: mounts routes below that prefix.
|
||||
- Domain routers: paths relative to mount; never derive prefix.
|
||||
- Websockets, auth redirects, cookie paths, share URLs: preserve prefix.
|
||||
|
||||
## URL rules
|
||||
|
||||
Use `URL`, React Router, or existing helpers instead of string manipulation. Preserve:
|
||||
|
||||
- leading/trailing slashes and runtime prefix;
|
||||
- query params, auth tokens, navigation locks;
|
||||
- preset aliases and canonical view paths;
|
||||
- `https`/`wss` behind proxies;
|
||||
- static/user asset paths.
|
||||
|
||||
Never infer Ontime Cloud from hostname alone. Generated base markup marks cloud; runtime prefixes also serve non-cloud reverse proxies.
|
||||
|
||||
## Cloud capabilities
|
||||
|
||||
Gate unavailable local-network integrations in cloud, including OSC output. Keep server behaviour and UI availability aligned.
|
||||
|
||||
## Verification
|
||||
|
||||
Test both root and a prefix such as `/client-hash`. Include relevant queries, redirects, cookies, websocket paths, presets, assets. Root-only routing coverage is incomplete.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Security boundaries
|
||||
|
||||
Use for auth, external input, files, integrations, assets, URLs, or secrets.
|
||||
|
||||
## Untrusted inputs
|
||||
|
||||
Validate/parse before trust:
|
||||
|
||||
- HTTP bodies, params, headers, cookies, websocket messages;
|
||||
- project files, migrations, spreadsheets, imports;
|
||||
- custom HTML/CSS/views, translations, served assets;
|
||||
- automation payloads, third-party responses;
|
||||
- externally supplied paths/filenames.
|
||||
|
||||
Validate shape and domain constraints at the owning boundary. Keep existing parser/validation layers; avoid downstream defensive casts.
|
||||
|
||||
## Authentication and authorisation
|
||||
|
||||
- Keep protected routers behind auth middleware in `apps/server/src/app.ts`.
|
||||
- Preserve auth across prefixed routes, redirects, websockets, generated links.
|
||||
- Scope session cookies to runtime prefix; isolate hosted clients.
|
||||
- Authentication never grants arbitrary file, path, project, or rundown access.
|
||||
|
||||
## Files, URLs, integrations
|
||||
|
||||
- Use established path/file helpers. Reject traversal and unexpected types.
|
||||
- Build URLs with `URL` or existing helpers. Check open redirects, SSRF, protocol changes, token leaks.
|
||||
- Encode/constrain untrusted HTML, CSS, filenames, headers, log values.
|
||||
- Preserve cloud limits on local-network capabilities.
|
||||
|
||||
## Secrets and diagnostics
|
||||
|
||||
- Never commit/log passwords, hashes, tokens, credentials, cookies, private project content.
|
||||
- Errors: useful, but no internal paths, stacks, credentials, sensitive payloads.
|
||||
- Keep tokens in existing session/authenticated-share flows. Avoid new URL-token patterns.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Where does input become trusted?
|
||||
- Validation once at owner, then useful type?
|
||||
- Can one prefixed client cross another client's session/assets?
|
||||
- Can logs, responses, redirects, URLs leak secrets?
|
||||
@@ -1,52 +0,0 @@
|
||||
# Testing strategy
|
||||
|
||||
Use for changed behaviour or tests.
|
||||
|
||||
## Layers
|
||||
|
||||
### Pure unit
|
||||
|
||||
Put detailed business-rule coverage on pure functions. Test public inputs/outputs with Vitest: relevant boundaries, invalid input, ordering, rollover, errors.
|
||||
|
||||
Every bug fix needs a regression test that fails before the fix. Prefer behaviour over mocks/implementation assertions.
|
||||
|
||||
### Service and state
|
||||
|
||||
Use service, DAO, store, or hook tests for orchestration: transitions, transactions, persistence, cache, notifications, external effects.
|
||||
|
||||
Do not repeat all pure cases here. Prove delegation and sequencing.
|
||||
|
||||
### End-to-end
|
||||
|
||||
Reserve Playwright for key journeys and high-risk cross-layer integrations: edit/run rundown, playback, imports, rundown switching, cloud-prefixed navigation.
|
||||
|
||||
No E2E for edges already proven in lower layers. Add E2E only when lower layers cannot prove the user-facing integration.
|
||||
|
||||
## Compact before handoff
|
||||
|
||||
Development harnesses may be broad, repetitive, diagnostic, temporary. Before handoff:
|
||||
|
||||
1. Identify distinct required behaviours/regressions.
|
||||
2. Keep the smallest readable set that catches them.
|
||||
3. Parameterise repetition only when the table reads better.
|
||||
4. Remove diagnostic assertions, redundant permutations, temporary fixtures, private-detail coupling.
|
||||
5. Keep rare cases that encode real domain rules.
|
||||
|
||||
Optimise for future readers, not minimum line count.
|
||||
|
||||
## Quality
|
||||
|
||||
- Name tests by observable behaviour.
|
||||
- Keep setup local/explicit unless a fixture improves comprehension.
|
||||
- Avoid arbitrary waits, wall-clock dependence, cross-test state, weak assertions.
|
||||
- Prefer realistic typed fixtures over large snapshots or masking casts.
|
||||
- Test non-mutation when promised.
|
||||
- No tests for trivial type/format changes or framework behaviour Ontime does not own.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Business logic directly unit-testable?
|
||||
- Test catches the reported bug/regression?
|
||||
- Cases distinct, not repeated path?
|
||||
- Temporary harness leaked?
|
||||
- E2E justified by cross-layer risk?
|
||||
@@ -1,69 +0,0 @@
|
||||
# Workflow and repository conventions
|
||||
|
||||
Use for commands, imports, formatting, CI, dependencies, PRs.
|
||||
|
||||
## Workspaces
|
||||
|
||||
Confirm names from `package.json`.
|
||||
|
||||
| Package | Workspace name |
|
||||
| ---------------- | --------------------- |
|
||||
| Client | `ontime-ui` |
|
||||
| Server | `ontime-server` |
|
||||
| Electron | `ontime-electron` |
|
||||
| Resolver | `@getontime/resolver` |
|
||||
| CLI | `@getontime/cli` |
|
||||
| Shared types | `ontime-types` |
|
||||
| Shared utilities | `ontime-utils` |
|
||||
|
||||
Run from repo root: `pnpm --filter <workspace-name> <script>`. Add/install workspaces only when required.
|
||||
|
||||
## Verification
|
||||
|
||||
Start narrow; expand with risk:
|
||||
|
||||
```text
|
||||
focused test
|
||||
-> affected package tests
|
||||
-> package lint/typecheck
|
||||
-> required repository checks
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm --filter ontime-ui test:pipeline <path-to-test>
|
||||
pnpm --filter ontime-server test:pipeline <path-to-test>
|
||||
pnpm --filter ontime-utils test:pipeline <path-to-test>
|
||||
pnpm --filter <workspace-name> lint
|
||||
pnpm --filter <workspace-name> typecheck
|
||||
|
||||
pnpm format:check
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
PR CI: `.github/workflows/test.yml`; runs format, lint, types, unit, Playwright. Run E2E locally only for key flows or E2E infrastructure.
|
||||
|
||||
Before code commit: `pnpm lint`, `pnpm test`; add `pnpm typecheck` for TypeScript and `pnpm format:check` for formatted files. Claim only observed results.
|
||||
|
||||
## TypeScript and imports
|
||||
|
||||
- Strict TypeScript. Prefer `ontime-types` domain types over local copies.
|
||||
- Keep client/server payloads aligned through `ontime-types`.
|
||||
- Import `ontime-types`/`ontime-utils` from public entry points; Oxlint forbids `src` subpaths.
|
||||
- Server relative ESM imports need `.js`.
|
||||
- Reuse public exports/canonical helpers before adding utilities.
|
||||
|
||||
## Formatting and dependencies
|
||||
|
||||
- Use Oxlint/Oxfmt, not ESLint/Prettier.
|
||||
- Oxfmt: two spaces, semicolons, single quotes, trailing commas, 120 columns.
|
||||
- Add dependencies only when platform/current stack cannot solve the need. Review `package.json` and `pnpm-lock.yaml` together.
|
||||
- Never hand-edit lockfile. No build output unless already tracked.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Title: `[<workspace-name>] <Title>`.
|
||||
- Separate behaviour from unrelated refactors/format churn.
|
||||
- State what changed, why, verification.
|
||||
@@ -1,67 +0,0 @@
|
||||
# runtimeState time-core migration
|
||||
|
||||
**Status:** In progress
|
||||
|
||||
## Goal
|
||||
|
||||
Make temporal meaning explicit across `runtimeState` and timer calculations. Prevent mixing:
|
||||
|
||||
- `Instant`: epoch timestamp;
|
||||
- `TimeOfDay`: local milliseconds since midnight, range `[0, dayInMs)`;
|
||||
- `Duration`: elapsed or remaining milliseconds;
|
||||
- `Day`: calendar-day offset.
|
||||
|
||||
Use `apps/server/src/lib/time-core/timeCore.ts` for conversions and temporal arithmetic. Branded types remain numbers at serialization boundaries.
|
||||
|
||||
## Current state
|
||||
|
||||
Completed foundation:
|
||||
|
||||
- temporal brands in `ontime-types`;
|
||||
- `timeCore` helpers for now, conversion, duration arithmetic, midnight crossing, calendar-day distance;
|
||||
- partial `runtimeState` adoption of `Instant`, `TimeOfDay`, `Day`, and `timeCore`.
|
||||
|
||||
Remaining ambiguity:
|
||||
|
||||
- `TimerState`, `RundownState`, and `Offset` expose temporal fields as `number`/`MaybeNumber`;
|
||||
- `timerUtils` accepts/returns raw numbers with different meanings;
|
||||
- `runtimeState` retains raw duration fields, manual arithmetic, and `as TimeOfDay`/`as Duration` casts.
|
||||
|
||||
## Migration rules
|
||||
|
||||
- Classify each temporal field before changing it. No generic `Time` type.
|
||||
- Convert only through `timeCore` or an explicit transport adapter.
|
||||
- Keep public/websocket JSON numeric where required; brand at the boundary.
|
||||
- Keep type migration separate from behaviour changes.
|
||||
- Preserve current midnight, rollover, pause, add-time, roll, and offset behaviour per slice.
|
||||
- Add helpers only when they encode a named temporal rule and remove caller casts/arithmetic.
|
||||
|
||||
## Slices
|
||||
|
||||
- [x] Add temporal brands and initial `timeCore` helpers/tests.
|
||||
- [ ] Inventory temporal fields in `TimerState`, `RundownState`, `Offset`, and private runtime state; assign intended types.
|
||||
- [ ] Migrate pure `timerUtils` functions by temporal concept; add focused midnight/rollover tests.
|
||||
- [ ] Migrate private `RuntimeState` fields and calculations; remove local casts/manual conversions.
|
||||
- [ ] Migrate shared runtime contracts and add numeric transport adapters where compatibility requires them.
|
||||
- [ ] Update remaining callers, fixtures, and mocks.
|
||||
- [ ] Remove superseded helpers, casts, and ambiguous temporal numbers.
|
||||
|
||||
Each slice must leave old/new boundaries explicit, compile cleanly, and preserve behaviour.
|
||||
|
||||
## Required coverage
|
||||
|
||||
- before, at, and after midnight;
|
||||
- overnight and multi-day rundowns;
|
||||
- local-day calculation across timezone/DST offset changes;
|
||||
- pause/resume, added time, elapsed/remaining duration;
|
||||
- roll secondary targets and expected finish;
|
||||
- absolute/relative offsets and day offsets.
|
||||
|
||||
## Complete when
|
||||
|
||||
- Runtime/timer boundaries use semantic temporal types or documented numeric transport fields.
|
||||
- Temporal conversions/arithmetic use `timeCore` or named pure helpers.
|
||||
- No unexplained temporal casts or ambiguous numeric fields remain in migrated scope.
|
||||
- Focused tests cover the required boundaries.
|
||||
|
||||
After completion, remove this migration tracker. Keep durable rules in `docs/agent-guides/domain-invariants.md`.
|
||||
@@ -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:"
|
||||
|
||||
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