Compare commits

...

26 Commits

Author SHA1 Message Date
Claude ccdfb64921 perf(rundown): keep entry revisions in step with the server
An entry revision advances on every change to that entry, which makes it a
cheap marker for what has moved on in a rundown. The client did not maintain
it: an optimistic entry carried the old revision, so the entry the server
returned always differed from ours by that one field.

That difference is enough to defeat structural sharing. A new entries object
recomputes the rundown metadata, which fires the effect behind the list, which
re-renders every visible row. Editing one title on a fourteen entry rundown
cost eight structural and sixty-one attribute mutations, twice over.

- optimistic patches apply the same revision bump the server does, so an
  unchanged entry keeps its identity and a refetch costs nothing to render
- the rundown metadata memo keys on the fields the derivation reads, so a
  change of rundown revision alone no longer churns the list
- editing an entry now reads the server response, which is the authority on
  what was applied, and only writes when it differs from what we predicted

Measured on the same edit: eight to four structural, sixty-one to forty-one
attribute mutations. Both changes are needed, neither helps on its own.

Two bugs found on the way. A swap stored the pre increment revision, so
swapped entries were indistinguishable from untouched ones. The optimistic
batch edit spread the request envelope onto the entry rather than the patch,
so it never applied the edit it was predicting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-30 12:04:47 +00:00
Claude a5086aed0b fix(rundown): narrow rundown invalidation
Two independent issues in the revision strategy.

The client guard only skipped a refetch when the revision matched exactly,
while its comment described skipping anything not newer. A message which
arrives after a refetch has already brought in a later revision forced a
needless refetch. Compare with <= so the guard does what it claims.

Renaming a rundown re-initialised it, which stops playback. Renaming the
loaded rundown during a show therefore stopped the show. A title has no
bearing on the schedule, so it now goes through an ordinary transaction:
the title is persisted, the revision bumped and clients notified, with no
runtime involvement. Renaming to the current title is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-30 10:51:20 +00:00
Claude 1fa6175fb8 refactor(rundown): keep runtime views on the loaded rundown
Only the editor and the cuesheet can be pointed at a background rundown.
Everything else always works against the rundown being played, so it has
no business taking part in a rundown scope.

Split the readers by that assumption:

- useRundownById holds the query and the shared derivation
- useRundown reads from the enclosing scope, for the editor and cuesheet
- useLoadedRundown resolves the loaded rundown directly, for the viewers,
  the operator, app settings and the overview

This drops the app wide scope provider, so views which read no rundown
(studio, project info) no longer subscribe to the rundown list, and a
scope becomes what it claims to be: a panel concern which throws when
used outside a panel.

It also fixes the overview, which reads runtime ids (the playing group,
the next flag). Those only exist in the loaded rundown, so resolving them
against a scope returned nothing while a background rundown was open in
the cuesheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 17:48:42 +00:00
Claude bc8407c0e6 refactor(rundown): address review on the rundown scope
- extract getRundownCacheKey, the fallback to the bootstrap alias for an
  unresolved rundown id was spelled out in two readers and missing in the
  selection store, which could not resolve shift/ctrl ranges during startup
- an unresolved target is no longer reported as the loaded rundown, it was
  briefly enabling run mode and playback following before a rundown existed
- only the reader which bootstrapped drops the /current alias, a reader on
  another rundown was evicting a cache others still relied on
- the selection store returns a new Set on toggle and unselect, components
  selecting the set by reference never saw the change
- useSelectAndRevealEntry takes its rundown from the scope, callers were
  threading an id for no other reason

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 17:21:24 +00:00
Claude 802521c706 refactor(rundown): resolve rundown data from a scope context
The editor and cuesheet had three overlapping notions of which rundown
they were showing: loaded-only data hooks, a prop-drilled RundownSource,
and a selection store which read the loaded rundown out of the query
cache. Rendering two rundowns at once was not possible.

Introduce a rundown scope: a context carrying the rundown identity
(id, isLoaded and a selection store) which the utility hooks read from.
useRundown, useEntry and friends keep their signatures and call sites,
they simply resolve their rundown from the enclosing scope. The app
mounts a scope at the root which follows the loaded rundown, so runtime
views are unaffected; the editor and cuesheet mount an editable scope
which also binds entry actions to the same rundown.

- useEventSelection becomes a per-scope store factory, so panels hold
  independent cursors and the store is handed its rundown instead of
  reaching for the loaded one
- the cuesheet selection is generalised to useRundownSelection with a
  namespace, so each surface persists its own choice
- the clipboard records the rundown an entry was copied from, leaving
  room for pasting across rundowns
- removes useScopedRundown and the prop-drilled RundownSource

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011a5cbVjNC5XXF88b2PkUCa
2026-08-29 13:21:59 +00:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
Carlos Valente 9806f97d27 test(rundown): improve rundown utils coverage 2026-08-25 22:41:37 +02:00
Carlos Valente b9683f00dc fix: prevent stale rundown cache on rename 2026-08-25 21:41:34 +02:00
Carlos Valente 63fdc50c50 docs(llms): document ongoing code migrations 2026-08-25 21:41:18 +02:00
Carlos Valente 5acfc4a122 docs(llms): setup agents documentation 2026-08-25 21:41:18 +02:00
Carlos Valente a84b8b5530 refactor(ui): polish navigation menu and view params editor 2026-08-23 13:42:21 +02:00
Carlos Valente c19aa4ca28 fix(cuesheet): prevent stale column width on refresh 2026-08-23 13:28:24 +02:00
Carlos Valente 805663e2ea chore(deps): migrate to @tanstack/react-table v9 2026-08-23 13:28:24 +02:00
Carlos Valente 0f0b32d444 accept bearer token 2026-08-21 20:52:11 +02:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
143 changed files with 4971 additions and 3419 deletions
+46
View File
@@ -0,0 +1,46 @@
---
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.
+22
View File
@@ -0,0 +1,22 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.12.0", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+9 -8
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.12.0", "version": "4.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.6.0", "react-icons": "5.7.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,7 +60,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.1.1", "@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.0.1", "vite": "8.2.1",
"vite-plugin-compression2": "2.5.1", "vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
+3
View File
@@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext'; import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets'; import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
@@ -112,7 +113,9 @@ export default function AppRouter() {
path='rundown' path='rundown'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel /> <RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -0,0 +1,6 @@
.eyebrow {
font-size: $aux-text-size;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -0,0 +1,13 @@
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,7 +1,3 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -23,14 +19,15 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -50,22 +47,51 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
padding-inline: 1.5rem; min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.note { .group {
margin-left: auto; display: flex;
color: $white-20; flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -52,34 +54,40 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' onClick={onClose}> <Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<MenuGroup label='This screen'>
{supportsFullscreen && ( {supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}> <NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)} )}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuToggle
Flip Screen checked={mirror}
<IoSwapVertical /> icon={<IoSwapVertical />}
{mirror && <span className={style.note}>Active</span>} label='Flip Screen'
</NavigationMenuItem> onToggle={() => toggleMirror()}
/>
{canUseWakeLock && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuToggle
Keep Awake checked={keepAwake}
<LuCoffee /> icon={<LuCoffee />}
{keepAwake && <span className={style.note}>Active</span>} label='Keep Awake'
</NavigationMenuItem> onToggle={toggleKeepAwake}
/>
)} )}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem> <NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
<hr className={style.separator} /> Rename Client
</NavigationMenuItem>
</MenuGroup>
<MenuGroup label='Ontime app'>
<EditorNavigation /> <EditorNavigation />
<ClientLink <ClientLink
to='cuesheet' to='cuesheet'
@@ -89,13 +97,17 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}> <ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
</MenuGroup>
<hr className={style.separator} /> <MenuGroup label='Views'>
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink <ClientLink
key={route.url} key={route.url}
@@ -106,21 +118,29 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} /> <PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </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 }) { function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation(); const location = useLocation();
const { data: urlPresets } = useUrlPresets(); const { data: urlPresets } = useUrlPresets();
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null; if (navPresets.length === 0) return null;
return ( return (
<> <MenuGroup label='Presets'>
<hr className={style.separator} />
{navPresets.map((preset) => ( {navPresets.map((preset) => (
<ClientLink <ClientLink
key={preset.alias} key={preset.alias}
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias} {preset.alias}
</ClientLink> </ClientLink>
))} ))}
</> </MenuGroup>
); );
} }
@@ -1,5 +1,5 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams'; import { hasCustomParams } from '../../stores/savedViewParams';
@@ -15,21 +15,37 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean; 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); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams); const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
() => { (event) => {
if (isNavigationLocked) return; if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
menuHandler.toggle(); event.preventDefault();
toggleMenu();
}, },
{ preventDefault: true }, { preventDefault: false },
], ],
[ [
'mod + ,', 'mod + ,',
@@ -48,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges} hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
</> </>
); );
} }
@@ -2,35 +2,68 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75rem 1.5rem; padding: 0.625rem 1.25rem;
gap: 0.5rem; gap: 0.625rem;
width: 100%; width: 100%;
border-left: 4px solid transparent; border-left: 3px solid transparent;
color: $action-text-color; color: $gray-200;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color; transition-property: color, background-color;
transition-duration: $transition-time-action; 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 { &:hover {
color: $ontime-color; background-color: $white-3;
background-color: $gray-1350; color: $ui-white;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $border-color-ondark; background-color: $white-7;
} }
&:focus { &:focus-visible {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
background-color: $gray-1350; outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $gray-1300; background-color: $bg-active;
border-left: 4px solid $action-text-color; border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent'; import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event)) { if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); onClick();
} }
}} }}
@@ -0,0 +1,23 @@
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>
);
}
@@ -1,22 +1,52 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header { .header {
font-size: calc(1rem - 2px); padding: 0 1.25rem 0.25rem;
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.5rem 0.5rem; padding: 0.25rem 1.25rem 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; 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;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
// there is no point showing this if we only have one interface if (externalInterfaces.length === 0) {
if (data.networkInterfaces.length < 2) {
return null; return null;
} }
return ( return (
<> <div className={style.footer}>
<div className={style.header}>Accessible on external networks</div> <div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {externalInterfaces.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'> <div key={nif.name} className={style.interface}>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}> <span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.address}>{nif.address}</span>
<IoArrowUp className={style.goIcon} />
</span> </span>
</CopyTag> </CopyTag>
</div>
); );
})} })}
</div> </div>
</> </div>
); );
} }
@@ -0,0 +1,31 @@
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' },
]);
});
});
@@ -0,0 +1,6 @@
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,6 +2,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,37 +1,77 @@
.inline { .multiOptionList {
display: inline-flex; overflow-y: auto;
align-items: center; max-height: 12rem;
flex-wrap: wrap; background: $gray-1200;
gap: 1rem; border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.75rem;
padding-inline: 0.5rem; min-height: 2.5rem;
height: 2.5rem; padding: 0.5rem 0.75rem;
background: $gray-1050; background: transparent;
color: $ui-white; border: 0;
color: $gray-300;
line-height: 1em; line-height: 1em;
border-radius: $component-border-radius-md; cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover:not(:disabled):not(:active) { &:hover {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $ui-white;
} }
&:active:not(:disabled) { &:active {
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; }
&: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;
}
} }
&::after { &::after {
content: ''; content: '';
margin-left: 0.25rem; flex: 0 0 auto;
width: 0.75em; margin-left: auto;
height: 0.75em; width: 0.625rem;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}> <div className={style.multiOptionList}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={style.toggleSelect} className={cx([style.toggleSelect, selected && style.selected])}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={paramState.includes(option.value)} checked={selected}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,13 +1,3 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -34,11 +24,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $gray-1250; background-color: $ui-black;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -58,36 +48,54 @@
} }
.header { .header {
padding-inline: 1rem; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding-inline: 1rem; padding: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding-inline: 1rem; padding: 0.75rem 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 0.75rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams'; import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Customise</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}> <Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,10 +1,21 @@
.presetSection { .presetSection {
background-color: $gray-1350; 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; display: flex;
flex-direction: column; align-items: center;
gap: 0.25rem;
padding: 1rem 0.5rem; color: $gray-300;
margin-bottom: 1rem; }
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -14,12 +25,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; 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 { &.active {
color: $blue-500; background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; 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,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets'; import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
<div className={style.header}>
<Eyebrow>Saved presets</Eyebrow>
</div>
<div className={style.presetList}>
{viewPresets.map((preset) => { {viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias; const active = searchParams.get('alias') === preset.alias;
return ( return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div> <span className={style.presetName}>{preset.alias}</span>
{active ? (
<span className={style.current}>Current</span>
) : (
<Button <Button
variant={active ? 'ghosted' : 'subtle-white'} variant='ghosted'
size='small'
onClick={() => handleRecall(preset)} onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions} className={style.presetActions}
> >
{active ? 'Applied' : 'Apply'} Apply
</Button> </Button>
)}
</div> </div>
); );
})} })}
</div> </div>
</div>
); );
} }
@@ -1,48 +1,118 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 2rem; 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;
&.collapsible { &.collapsible {
cursor: pointer; 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 { .label {
margin-top: $section-spacing; display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
display: flex; &:not(:last-child) {
flex-direction: column; border-bottom: 1px solid $white-7;
gap: 0.25rem; }
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
} }
.title { .title {
font-size: $inner-section-text-size; color: $ui-white;
display: block;
width: 100%;
} }
.description { .description {
font-size: $inner-section-text-size; color: $gray-400;
display: block; font-size: $aux-text-size;
color: $modal-note-color; line-height: 1.4;
}
.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 { .hidden {
@@ -1,7 +1,9 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
options: ParamField[]; 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) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
const handleCollapse = () => { // hidden options are inputs without a UI, there is nothing to frame them with
if (collapsible) { if (title === OptionTitle.Hidden) {
setCollapsed((prev) => !prev); return <HiddenContents options={options} />;
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <SectionHeader
<HiddenContents options={options} /> title={title}
) : ( isCustomised={options.some((option) => searchParams.has(option.id))}
<> collapsed={isCollapsed}
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
{title} />
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} {/* collapsed options stay mounted: the form reads its values from the DOM */}
</div> <div className={cx([style.options, isCollapsed && style.hidden])}>
<SectionContents options={options} collapsed={collapsed} /> {options.map((option) => (
</> <label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
)}
</section>
);
}
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<>
{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.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
</label> </label>
))}
</div>
</section>
); );
})} }
</>
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
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>;
}
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>
); );
} }
@@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -0,0 +1,77 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -0,0 +1,81 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
+31 -100
View File
@@ -1,62 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { EntryId, OntimeEntry } from 'ontime-types';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { useRundownScope } from '../context/RundownScopeContext';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { flattenRundown, useRundownById } from './useRundownById';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data for the currently loaded rundown. * Normalised rundown data for the rundown of the enclosing scope
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export default function useRundown() {
const queryClient = useQueryClient(); const { rundownId } = useRundownScope();
const { return useRundownById(rundownId);
data: { loaded: loadedRundownId }, }
} = useProjectRundowns();
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ /**
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, * Runtime state only describes the loaded rundown,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), * a scope pointed elsewhere must not show a playing event
refetchInterval: queryRefetchIntervalSlow, */
}); export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
// Seed the id-keyed cache when fetching via the bootstrap alias const selectedEventId = useSelectedEventId();
useEffect(() => { return isLoaded ? selectedEventId : null;
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useScopedSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); // key on the fields the derivation reads, a revision only change must not churn the list
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -66,41 +42,23 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useScopedSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
} }
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/** /**
* Hook to get a specific entry by ID from the rundown * Hook to get a specific entry by ID from the rundown
*/ */
@@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,66 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -1,54 +0,0 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -1,13 +1,13 @@
import { import {
FOLLOW_LOADED_RUNDOWN_ID, FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey, getRundownSelectionStorageKey,
resolveSelectedRundownId, resolveSelectedRundownId,
} from '../useCuesheetRundownSelection'; } from '../useRundownSelection';
describe('useCuesheetRundownSelection helpers', () => { describe('useRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => { it('builds a namespace and project scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe( expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project', 'rundown-selection:cuesheet:http://localhost:4001:My Project',
); );
}); });
+47 -18
View File
@@ -8,7 +8,6 @@ import {
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
PatchWithId, PatchWithId,
ProjectRundownsList,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeField, TimeField,
@@ -32,9 +31,10 @@ import {
swapEventData, swapEventData,
} from 'ontime-utils'; } from 'ontime-utils';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants'; import { getRundownCacheKey } from '../api/constants';
import { import {
ReorderEntry, ReorderEntry,
deleteEntries, deleteEntries,
@@ -52,6 +52,7 @@ import {
requestFitGroupTarget, requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
@@ -64,22 +65,38 @@ export type EventOptions = Partial<{
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
/**
* Applies a patch the way the server does, revision included.
*
* An entry revision advances on every change to that entry, which makes it a
* cheap marker for whether an entry has moved on. The optimistic entry has to
* carry the revision the server will return, otherwise the two disagree and the
* refetch resolves to a different object for no reason.
* Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
*/
function patchEntry(entry: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
}
return { ...entry, ...patch } as OntimeEntry;
}
type ClientInsertOptions = { type ClientInsertOptions = {
after?: EntryId; after?: EntryId;
before?: EntryId; before?: EntryId;
}; };
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries in the rundown of the enclosing scope.
*/ */
export const useEntryActions = () => useEntryActionsForRundown(undefined); export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/** /**
* Gather utilities for actions on entries in an explicitly selected rundown. * Gather utilities for actions on entries in an explicitly selected rundown.
*/ */
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? ''); export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) { function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) { return getRundownCacheKey(scopedRundownId);
return getRundownQueryKey(scopedRundownId); }, [scopedRundownId]);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/** /**
* Returns the currently loaded rundown * Returns the currently loaded rundown
@@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.entries }; const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type const previousEntry = newRundown[eventId];
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, { queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
@@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData, newEvent, queryKey }; return { previousData, newEvent, queryKey };
}, },
// the server is the authority on the applied patch, it may normalise what we sent
onSuccess: (response, _variables, context) => {
const serverEntry = response.data;
if (!serverEntry || !context?.queryKey) return;
const cachedRundown = queryClient.getQueryData<Rundown>(context.queryKey);
if (!cachedRundown) return;
// our optimistic entry usually describes the change exactly, writing an
// identical entry would discard the cached reference for nothing
if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
queryClient.setQueryData<Rundown>(context.queryKey, {
...cachedRundown,
entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
});
},
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) { if (context?.previousData && context?.queryKey) {
@@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId]; const event = newRundown[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
newRundown[eventId] = { newRundown[eventId] = patchEntry(event, data.data);
...event,
...data,
};
} }
} }
}); });
@@ -2,14 +2,21 @@ import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react'; import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals'; import { serverURL } from '../../externals';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const; export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) { /** each surface keeps its own selection, so panels can point at different rundowns */
return `cuesheet-selected-rundown:${server}:${projectFilename}`; export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
} }
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) { export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID; return FOLLOW_LOADED_RUNDOWN_ID;
} }
export function useCuesheetRundownSelection() { /**
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
'use memo'; 'use memo';
const { data: projectRundowns } = useProjectRundowns(); const { data: projectRundowns } = useProjectRundowns();
const { const {
data: { lastLoadedProject }, data: { lastLoadedProject },
} = useOrderedProjectList(); } = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]); const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({ const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey, key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID, defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() {
return { return {
loadedRundownId, loadedRundownId,
selectedRundownId, selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns, projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => { setSelectedRundownId: (rundownId: string) => {
startTransition(() => { startTransition(() => {
@@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject }, data: { lastLoadedProject },
} = useOrderedProjectList(); } = useOrderedProjectList();
const navigate = useNavigate(); const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject); const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null }); const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback( return useCallback(
@@ -0,0 +1,64 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,14 +1,21 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: string | null; entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut'; entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void; setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
}; };
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy', entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') => setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }), set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
})); }));
@@ -0,0 +1,158 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
+4
View File
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; 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 { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return; return;
} }
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision === cachedRundown?.revision) { // we already have this change, or something newer
// we already have the latest change // messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return; return;
} }
-35
View File
@@ -1,5 +1,3 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -32,39 +30,6 @@ 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 * Allow passing CSS Properties
*/ */
@@ -53,7 +53,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
@@ -79,7 +79,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
} }
@@ -124,7 +124,7 @@ $card-padding: 2rem;
// active rows use a panel-local class today; keep the treatment here so all // 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 // settings tables share the same state without each panel owning its colours
tr[class*='current'] { tr[class*='current'] {
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
> td:first-child { > td:first-child {
box-shadow: inset 3px 0 $blue-400; box-shadow: inset 3px 0 $blue-400;
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
width: 100%; inset: 0;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return ( return (
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title={`Ontime ${appVersion}`} title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={ description={
isOntimeCloud isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.` ? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
} }
/> />
{!isOntimeCloud && ( {!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink> <ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.ListItem> </Panel.ListItem>
); );
@@ -4,8 +4,8 @@ import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: reportData } = useReport();
const { data } = useRundown(); const { data } = useLoadedRundown();
const clearReport = async () => await deleteAllReport(); const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => { const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -12,20 +12,21 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import { viewLabels } from '../../../../../viewerConfig';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions'; import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption<OntimeViewPresettable>[] = [ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
{ value: OntimeView.Backstage, label: 'Backstage' }, { value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
{ value: OntimeView.Timeline, label: 'Timeline' }, { value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
{ value: OntimeView.StudioClock, label: 'Studio Clock' }, { value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
{ value: OntimeView.Countdown, label: 'Countdown' }, { value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
]; ];
const formId = 'url-preset-form'; const formId = 'url-preset-form';
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown'; import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useLoadedRundown();
const { applyImport } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>} {rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && ( {data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info> <Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)} )}
+19 -30
View File
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -55,72 +56,60 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}> <div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}> <Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span> <span className={style.filterLabel}>Filter by</span>
<Button <ToggleButton
variant={showUser ? 'primary' : 'subtle'} pressed={showUser}
size='small' size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.User} {LogOrigin.User}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showClient ? 'primary' : 'subtle'} pressed={showClient}
size='small' size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Client} {LogOrigin.Client}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showServer ? 'primary' : 'subtle'} pressed={showServer}
size='small' size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Server} {LogOrigin.Server}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showPlayback ? 'primary' : 'subtle'} pressed={showPlayback}
size='small' size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Playback} {LogOrigin.Playback}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showRx ? 'primary' : 'subtle'} pressed={showRx}
size='small' size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Rx} {LogOrigin.Rx}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showTx ? 'primary' : 'subtle'} pressed={showTx}
size='small' size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Tx} {LogOrigin.Tx}
</Button> </ToggleButton>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}> <Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types'; import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb'; } from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntry } from '../../../common/hooks-query/useRundown'; import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock'; import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import { import {
useCurrentGroupId, useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null; const entry = useLoadedEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData'; import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss'; import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(); const { data: rundownData } = useLoadedRundownAuxData();
const projectTitle = projectData.title.trim(); const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim(); const rundownTitle = rundownData.title.trim();
+5 -1
View File
@@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext(); const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor // cursor
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
@@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -39,11 +36,9 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div <div
className={cx([style.rundownExport, style.extracted])} className={cx([style.rundownExport, style.extracted])}
@@ -60,7 +55,6 @@ function RundownExport() {
</div> </div>
</div> </div>
</ProtectRoute> </ProtectRoute>
</EntryActionsProvider>
); );
} }
@@ -70,7 +64,6 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement /> <FinderPlacement />
@@ -93,7 +86,6 @@ function RundownExport() {
</div> </div>
</div> </div>
</ProtectRoute> </ProtectRoute>
</EntryActionsProvider>
); );
} }
@@ -105,8 +97,6 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
@@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />} {viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,15 +1,22 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty // avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils'; import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction'; import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection'; import { SelectionMode } from '../useEventSelection';
@@ -26,6 +27,7 @@ export function useRundownCommands({
selectEntry: applySelection, selectEntry: applySelection,
handleCollapseGroup, handleCollapseGroup,
}: UseRundownCommandsOptions) { }: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
@@ -43,12 +45,17 @@ export function useRundownCommands({
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => { (atId: EntryId | null, above = false) => {
// lazily get the value from the store // lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) { if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId; let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId]; const elementToCopy = entries[entryCopyId];
@@ -65,7 +72,7 @@ export function useRundownCommands({
return; return;
} }
reorderEntry(entryCopyId, firstId, 'before') reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null)) .then(() => setEntryCopyId(null, null))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -74,7 +81,7 @@ export function useRundownCommands({
} }
const placement = above ? 'before' : 'after'; const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement) reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null)) .then(() => setEntryCopyId(null, null))
.catch(() => {}); .catch(() => {});
return; return;
} }
@@ -85,7 +92,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined, before: above ? (normalisedAtId ?? undefined) : undefined,
}); });
}, },
[entries, flatOrder, clone, reorderEntry], [entries, flatOrder, clone, reorderEntry, rundownId],
); );
/** /**
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
function FinderPlacement() { function FinderPlacement() {
const [isOpen, handler] = useDisclosure(); const [isOpen, handler] = useDisclosure();
useHotkeys([ /**
['mod + f', handler.toggle, { preventDefault: true }], * The empty tagsToIgnore is significant: by default the hook skips input elements,
['Escape', handler.close, { preventDefault: true }], * which would make the shortcut dead while editing an entry.
]); *
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) { if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />; return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-testid='rundown-event' data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})} {...(isPlaying ? { 'data-running': true } : {})}
> >
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} /> <RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -1,9 +1,6 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -15,8 +12,6 @@ function RundownTable() {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -32,10 +27,8 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return ( return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'> <CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' /> <CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd> </CuesheetDnd>
</EntryActionsProvider>
); );
} }
@@ -1,15 +1,14 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types'; import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; 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 * Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access * Reuses cuesheetColsFactory with preset=undefined for full access
*/ */
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] { export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true // When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run) // canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined); return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
@@ -1,155 +1,15 @@
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types'; import { useStore } from 'zustand';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; import { useRundownScope } from '../../common/context/RundownScopeContext';
import { ontimeQueryClient } from '../../common/queryClient'; import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
import { isMacOS } from '../../common/utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl'; export { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
/** /**
* Keeps track of the selected entries and selection mode * Selection and cursor for the rundown of the enclosing scope
* Provides methods to update the selection based on user interactions
*/ */
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({ export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
selectedEvents: new Set(), const { selectionStore } = useRundownScope();
anchoredIndex: null, return useStore(selectionStore, selector);
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
} }
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types'; import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups'; import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString; parent?: MaybeString;
}; };
export function useSelectAndRevealEntry(rundownId: string) { export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId); const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents); const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
+1
View File
@@ -48,6 +48,7 @@ $playback-under: $green-500;
// interface panels // interface panels
$bg-container-l1: $gray-1350; $bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300; $bg-container-l2: $gray-1300;
$bg-active: rgba($action-blue, 0.16);
$backdrop-color: rgba(0, 0, 0, 0.7); $backdrop-color: rgba(0, 0, 0, 0.7);
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
+25 -7
View File
@@ -1,12 +1,30 @@
export const navigatorConstants = [ import { OntimeView } from 'ontime-types';
{ url: 'timer', label: 'Timer' },
{ url: 'backstage', label: 'Backstage' }, /** User facing name of each view, the single source for view naming in the UI */
{ url: 'timeline', label: 'Timeline' }, export const viewLabels: Record<OntimeView, string> = {
{ url: 'studio', label: 'Studio Clock' }, [OntimeView.Editor]: 'Editor',
{ url: 'countdown', label: 'Countdown' }, [OntimeView.Cuesheet]: 'Cuesheet',
{ url: 'info', label: 'Project Info' }, [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 = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
// default time format to use for users in 12 hour clocks // default time format to use for users in 12 hour clocks
export const FORMAT_12 = 'h:mm:ss a'; export const FORMAT_12 = 'h:mm:ss a';
// default time format to use for users in 24 hour clocks // default time format to use for users in 24 hour clocks
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(); const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { usePartialRundown } from '../../../common/hooks-query/useRundown'; import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options'; import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter], [filter],
); );
const { data: events } = usePartialRundown(filterCallback); const { data: events } = useLoadedPartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,35 +3,31 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown'; import { useRundownSelection } from '../../common/hooks/useRundownSelection';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo'; 'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection(); const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } =
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId); useRundownSelection('cuesheet');
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<EntryActionsProvider actions={actions}> <EditableRundownScopeProvider rundownId={scopedRundownId}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} /> <EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview> <CuesheetOverview>
{!isLocked && ( {!isLocked && (
@@ -42,13 +38,12 @@ export default function CuesheetPage() {
</CuesheetOverview> </CuesheetOverview>
<CuesheetProgress /> <CuesheetProgress />
<CuesheetTableWrapper <CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId} selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId} loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId} setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns} projectRundowns={projectRundowns}
/> />
</div> </div>
</EntryActionsProvider> </EditableRundownScopeProvider>
); );
} }
@@ -3,19 +3,18 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select'; import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps { interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString; selectedRundownId: MaybeString;
loadedRundownId: string; loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void; setSelectedRundownId: (rundownId: string) => void;
@@ -24,15 +23,14 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({ function CuesheetTableWrapper({
source,
selectedRundownId, selectedRundownId,
setSelectedRundownId, setSelectedRundownId,
loadedRundownId, loadedRundownId,
projectRundowns, projectRundowns,
}: CuesheetTableWrapperProps) { }: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; const { isLoaded } = useRundownScope();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded });
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const columns = useMemo( const columns = useMemo(
@@ -44,11 +42,9 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
<CuesheetTable <CuesheetTable
columns={columns} columns={columns}
source={source}
cuesheetMode={cuesheetMode} cuesheetMode={cuesheetMode}
tableRoot='cuesheet' tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode} setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={ insertElement={
<> <>
<RundownSelect <RundownSelect
@@ -7,14 +7,13 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
import { useColumnOrder } from '../cuesheet-table/useColumnManager'; import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
tableRoot?: 'editor' | 'cuesheet'; tableRoot?: 'editor' | 'cuesheet';
} }
@@ -1,16 +1,13 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) { function EntryEditModal() {
const { data: rundown } = useRundown();
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -1,5 +1,5 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
@@ -14,7 +14,8 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill'; import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; import { useRundownScope } from '../../../common/context/RundownScopeContext';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -29,27 +30,29 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu'; import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar'; import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import {
CuesheetColumnDef,
CuesheetTable as CuesheetTableInstance,
cuesheetTableFeatures,
} from './cuesheetTable.features';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode; insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
@@ -57,13 +60,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ export default function CuesheetTable({
columns, columns,
cuesheetMode, cuesheetMode,
source,
tableRoot, tableRoot,
setCuesheetMode, setCuesheetMode,
isCurrentRundown,
insertElement, insertElement,
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source; const { isLoaded: isCurrentRundown } = useRundownScope();
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -120,7 +123,8 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot); const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot); const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({ const table = useTable({
features: cuesheetTableFeatures,
data: flatRundown, data: flatRundown,
columns, columns,
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
@@ -131,7 +135,6 @@ export default function CuesheetTable({
}, },
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta, meta,
}); });
@@ -195,7 +198,7 @@ export default function CuesheetTable({
return colSizes; return colSizes;
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need // 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [table.getState().columnSizingInfo, table.getState().columnSizing]); }, [columns, table.state.columnResizing, table.state.columnSizing]);
const allLeafColumns = table.getAllLeafColumns(); const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel(); const { rows } = table.getRowModel();
@@ -214,9 +217,7 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []); const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => { const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => { return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues // if the table is being resized, we render non-sortable headers to avoid performance issues
return ( return (
@@ -279,8 +280,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number }; columnSizeVars: { [key: string]: number };
cursor: string | null; cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners']; listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows']; rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: Table<ExtendedEntry>; table: CuesheetTableInstance;
handleAddNew?: (type: SupportedEntry) => void; handleAddNew?: (type: SupportedEntry) => void;
} }
@@ -1,16 +1,16 @@
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { HeaderGroup, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
import { Draggable, SortableCell, TableCell } from './SortableCell'; import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroup: HeaderGroup<ExtendedEntry>; headerGroup: CuesheetHeaderGroup;
cuesheetMode: AppMode; cuesheetMode: AppMode;
hideIndexColumn: boolean; hideIndexColumn: boolean;
} }
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</SortableCell> </SortableCell>
); );
})} })}
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</TableCell> </TableCell>
); );
})} })}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types'; import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './EventRow.module.scss'; import style from './EventRow.module.scss';
@@ -25,7 +25,7 @@ interface EventRowProps {
skip: boolean; skip: boolean;
parent: EntryId | null; parent: EntryId | null;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -133,7 +133,7 @@ function EventRow({
data-testid={`cuesheet-cell-${cell.column.id}`} data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id} data-column-id={cell.column.id}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,12 +1,12 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties, memo } from 'react'; import { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './GroupRow.module.scss'; import style from './GroupRow.module.scss';
@@ -15,7 +15,7 @@ interface GroupRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -76,7 +76,7 @@ function GroupRow({
}} }}
role='cell' role='cell'
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './MilestoneRow.module.scss'; import style from './MilestoneRow.module.scss';
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -102,7 +102,7 @@ function MilestoneRow({
}} }}
tabIndex={-1} tabIndex={-1}
> >
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())} {canRender && <FlexRender cell={cell} />}
</td> </td>
); );
})} })}
@@ -1,9 +1,8 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { CSSProperties, ReactNode } from 'react'; import { CSSProperties, ReactNode } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { CuesheetHeaderCell } from '../cuesheetTable.features';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
} }
interface DraggableProps { interface DraggableProps {
header: Header<ExtendedEntry, unknown>; header: CuesheetHeaderCell;
} }
export function Draggable({ header }: DraggableProps) { export function Draggable({ header }: DraggableProps) {
@@ -1,4 +1,3 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types'; import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies'; import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
import DurationInput from './DurationInput'; import DurationInput from './DurationInput';
import EditableImage from './EditableImage'; import EditableImage from './EditableImage';
import FlagCell from './FlagCell'; import FlagCell from './FlagCell';
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
); );
} }
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
); );
} }
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
); );
} }
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function LazyImage({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />; return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
} }
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
); );
} }
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) { function MakeFlagField({ row }: CuesheetCellContext) {
const event = row.original; const event = row.original;
if (!isOntimeEvent(event) || !event.flag) { if (!isOntimeEvent(event) || !event.flag) {
return null; return null;
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
return <FlagCell />; return <FlagCell />;
} }
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeCustomField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
customFields: CustomFields, customFields: CustomFields,
cuesheetMode: AppMode, cuesheetMode: AppMode,
preset: URLPreset | undefined, preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] { ): CuesheetColumnDef[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = []; const columnsDef: CuesheetColumnDef[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode); const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) { if (canRead('flag')) {
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5'; import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import type { CuesheetColumn } from '../cuesheetTable.features';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss'; import style from './CuesheetTableSettings.module.scss';
@@ -37,7 +36,7 @@ type TableModeControls = {
}; };
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
} }
interface ColumnSettingsProps { interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -203,7 +202,7 @@ function ColumnSettings({
return ( return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}> <Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} /> <Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
{columnHeader as ReactNode} {columnHeader as ReactNode}
</Editor.Label> </Editor.Label>
); );
@@ -12,7 +12,7 @@
transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] { &[data-background-rundown='true'] {
background-color: rgba($ontime-color, 0.5); background-color: $bg-active;
.apart::before { .apart::before {
content: 'BACKGROUND EDIT'; content: 'BACKGROUND EDIT';
@@ -0,0 +1,74 @@
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 { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table'; import { ColumnSizingState, Updater } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { debounce } from '../../../common/utils/debounce'; import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage'; import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from './cuesheetTable.features';
type TableRoot = 'editor' | 'cuesheet'; type TableRoot = 'editor' | 'cuesheet';
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
}; };
} }
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') { export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]); const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
+8
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList'; import TitleList from './title-list/TitleList';
@@ -21,7 +22,9 @@ export default function Editor() {
<TimerControl /> <TimerControl />
<MessageControl /> <MessageControl />
</div> </div>
<EditableRundownScopeProvider rundownId={null}>
<Rundown /> <Rundown />
</EditableRundownScopeProvider>
</div> </div>
); );
} }
@@ -29,6 +32,8 @@ export default function Editor() {
if (layoutMode === EditorLayoutMode.TRACKING) { if (layoutMode === EditorLayoutMode.TRACKING) {
return ( return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}> <div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
{/* the titles and the rundown share a scope, so they follow one cursor */}
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}> <div className={styles.rundownLayout}>
<div className={styles.titlesPanel}> <div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} /> <TitleList mode={AppMode.Run} />
@@ -37,6 +42,7 @@ export default function Editor() {
<Rundown /> <Rundown />
</div> </div>
</div> </div>
</EditableRundownScopeProvider>
<TrackingPlaybackBar /> <TrackingPlaybackBar />
</div> </div>
); );
@@ -44,6 +50,7 @@ export default function Editor() {
return ( return (
<div id='panels' className={styles.panelContainer}> <div id='panels' className={styles.panelContainer}>
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}> <div className={styles.rundownLayout}>
<div className={styles.titlesPanel}> <div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} /> <TitleList mode={AppMode.Edit} />
@@ -52,6 +59,7 @@ export default function Editor() {
<Rundown /> <Rundown />
</div> </div>
</div> </div>
</EditableRundownScopeProvider>
</div> </div>
); );
} }
@@ -3,11 +3,14 @@
.error { .error {
padding-inline: 0.5rem; padding-inline: 0.5rem;
font-size: 1rem; font-size: 1rem;
height: 3rem; // rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem;
} }
.entry[data-selected='true'] { .entry[data-selected='true'] {
@@ -18,21 +21,47 @@
color: $label-gray; color: $label-gray;
} }
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error { .error {
color: $error-red; color: $error-red;
} }
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data { .data {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'index cue' 'index cue'
'index title'; 'index title'
'index match';
column-gap: 1rem; column-gap: 1rem;
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
min-width: 0;
.index { .index {
grid-area: index; grid-area: index;
background-color: var(--color, $gray-1000); // background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
border-radius: 2px; border-radius: 2px;
padding-block: 0.25rem; padding-block: 0.25rem;
width: 3.5rem; width: 3.5rem;
@@ -42,14 +71,33 @@
.title { .title {
grid-area: title; grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.cue { .cue {
grid-area: cue; grid-area: cue;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $label-gray; color: $label-gray;
max-height: 1em;
min-height: 0; min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
} }
} }
@@ -63,13 +111,14 @@
color: $label-gray; color: $label-gray;
} }
.filterHint { .count {
text-align: right; text-align: right;
white-space: nowrap;
} }
.em { .go {
color: $ui-white; white-space: nowrap;
margin-inline: 0.25rem; padding-left: 1rem;
} }
.hints { .hints {
@@ -98,7 +147,7 @@
flex-direction: column; flex-direction: column;
} }
.filterHint { .count {
text-align: left; text-align: left;
} }
} }
+104 -39
View File
@@ -1,11 +1,12 @@
import { useDebouncedCallback } from '@mantine/hooks'; import { MaybeString } from 'ontime-types';
import { SupportedEntry } from 'ontime-types'; import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd'; import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import style from './Finder.module.scss'; import style from './Finder.module.scss';
@@ -15,46 +16,76 @@ interface FinderProps {
} }
export default function Finder({ isOpen, onClose }: FinderProps) { export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder(); const [search, setSearch] = useState('');
const [selected, setSelected] = useState(0); const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const debouncedFind = useDebouncedCallback(find, 100); /**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => { const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results // all operations need results
if (results.length === 0) { if (results.length === 0) {
return; return;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length); setSelectedId(results[(activeIndex + 1) % results.length].id);
} }
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length); setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
submit(); submit(activeEntry);
} }
}; };
const submit = () => { const submit = (entry: FinderResult | undefined) => {
const selectedEvent = results[selected]; if (!entry) {
select(selectedEvent); return;
}
select(entry);
onClose(); onClose();
}; };
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => { /** Scopes the search to a single field, or back to all fields when tapped again */
const target = event.target as HTMLElement; const handleFilter = (filterKey: string) => {
const li = target.closest('li'); setFilter((previous) => (previous === filterKey ? null : filterKey));
if (li) { inputRef.current?.focus();
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
}; };
const hiddenResults = total - results.length;
return ( return (
<Modal <Modal
title='' title=''
@@ -63,35 +94,68 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop showBackdrop
bodyElements={ bodyElements={
<div onKeyDown={navigate}> <div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' /> <Input
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}> ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
{error && <li className={style.error}>{error}</li>} {error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>} {!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 && {results.map((entry) => {
results.map((entry, index) => { const isSelected = activeEntry?.id === entry.id;
const isSelected = selected === index; // the title and cue are already on the row, a match anywhere else needs showing
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
const displayCue = 'cue' in entry ? entry.cue : '';
return ( return (
<li <li
key={entry.id} key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry} className={style.entry}
data-testid='finder-result'
data-selected={isSelected} data-selected={isSelected}
data-index={index} onClick={() => submit(entry)}
onClick={submit} onPointerEnter={() => setSelectedId(entry.id)}
> >
<div className={style.data}> <div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}> <div className={style.index} style={getAccessibleColour(entry.colour)}>
{displayIndex} {entry.eventIndex ?? '-'}
</div> </div>
<div className={style.cue}>{displayCue}</div> <div className={style.cue}>{entry.cue}</div>
<div className={style.title}>{entry.title}</div> <div className={style.title}>{entry.title}</div>
{showMatch && (
<div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
</div> </div>
{isSelected && <span>Go </span>} )}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li> </li>
); );
})} })}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul> </ul>
</div> </div>
} }
@@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close Close
</span> </span>
</div> </div>
<div className={style.filterHint}> {total > 0 && (
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or <div className={style.count} data-testid='finder-count'>
<span className={style.em}>title</span> {hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div> </div>
)}
</div> </div>
} }
/> />
@@ -0,0 +1,162 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+220 -200
View File
@@ -1,239 +1,259 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import {
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; /** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
type FilterableGroup = { const indexFilter = 'index';
type: SupportedEntry.Group;
id: EntryId;
index: number;
title: string;
colour: string;
};
type FilterableEvent = { /** Everything except delays, which carry no text to search */
type: SupportedEntry.Event; type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
id: EntryId; id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number; index: number;
eventIndex: number; /** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string; title: string;
/** groups have no cue */
cue: string; cue: string;
colour: string; colour: string;
parent: MaybeString; parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
}; };
type FilterableMilestone = { type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; const noResults: SearchOutcome = { results: [], error: null, total: 0 };
export default function useFinder() { /** Groups are the only searchable entry with neither a cue nor a parent */
const { data, rundownId } = useFlatRundown(); function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
const [results, setResults] = useState<FilterableEntry[]>([]); return {
const [error, setError] = useState<MaybeString>(null); id: entry.id,
const lastSearchString = useRef(''); index,
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** Filters the rundown to a given evaluation */
const find = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex, eventIndex,
title: event.title, title: entry.title,
cue: event.cue, cue: 'cue' in entry ? entry.cue : '',
colour: event.colour, colour: entry.colour,
parent: event.parent, parent: 'parent' in entry ? entry.parent : null,
} satisfies FilterableEvent); match,
break; } satisfies FinderResult;
}
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
} }
eventIndex++; const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
} }
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
} }
return { results, error: null }; // groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
} }
/** Returns maxResults of OntimeEvents that match the cue field */ // custom fields are named by the project, so these can only be reached generically
function searchByCue(searchString: string) { for (const [key, value] of Object.entries(entry.custom)) {
// indexes exposed to the UI are 1-based const definition = customFields[key];
let eventIndex = 1; if (definition?.type !== 'text') {
// limit amount of results we show continue;
let remaining = maxResults; }
const results: FilterableEvent[] = []; const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
return null;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
let eventIndex = 0;
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const entry = data[i]; const entry = data[i];
if (isOntimeEvent(entry)) { if (!isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) { continue;
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
} }
eventIndex++; eventIndex++;
} else if (isOntimeGroup(entry)) { if (eventIndex === target) {
if (entry.title.toLowerCase().includes(searchString)) { return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
} }
} }
return noResults;
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
} }
return { results, error: null }; const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
} }
},
[data], const match = findMatch(entry, customFields, filterKey, searchString);
); if (match === null) {
continue;
}
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
return { results, error: null, total };
}
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data } = useFlatRundown();
const { data: customFields } = useCustomFields();
const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
const customFilters = Object.entries(customFields)
.filter(([_key, field]) => field.type === 'text')
.map(([key, field]) => ({ key, label: field.label || key }));
return [...staticFilters, ...customFilters];
}, [customFields]);
const { results, error, total, appliedFilter } = useMemo(() => {
if (data.length === 0) {
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
}
const normalised = searchValue.trim().toLowerCase();
if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
/**
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
*/
const { filterKey, searchString } = activeFilter
? { filterKey: activeFilter, searchString: normalised }
: parseQuery(normalised, filters);
if (filterKey === indexFilter) {
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
}
if (searchString === '') {
// a filter is selected, but there is nothing to match on yet
return { ...noResults, appliedFilter: filterKey };
}
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
}, [data, customFields, filters, searchValue, activeFilter]);
const select = useCallback( const select = useCallback(
(selectedEvent: FilterableEntry) => { (result: FinderResult) => {
selectAndRevealEntry({ selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
}, },
[selectAndRevealEntry], [selectAndRevealEntry],
); );
/** clear results when source data changes */ return { select, results, error, total, filters, appliedFilter };
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
} }
@@ -46,7 +46,6 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData} eventData={eventData}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId} resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/> />
); );
} }
@@ -56,21 +55,14 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[]; eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null; selectedEventId: string | null;
resolvedFollowEventId: string | null; resolvedFollowEventId: string | null;
rundownId: string;
} }
function TitleListContent({ function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
'use memo'; 'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null); const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null); const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); const selectAndRevealEntry = useSelectAndRevealEntry();
// Calculate current event info // Calculate current event info
const currentEventInfo = useMemo(() => { const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types'; import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
+18
View File
@@ -195,6 +195,24 @@
font-weight: 600; font-weight: 600;
} }
.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 ===================*/
.logo { .logo {
position: absolute; position: absolute;
+17
View File
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket'; import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -30,6 +31,7 @@ import {
getTotalTime, getTotalTime,
} from './timer.utils'; } from './timer.utils';
import { TimerData, useTimerData } from './useTimerData'; import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss'; import './Timer.scss';
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime, freezeOvertime,
freezeMessage, freezeMessage,
hidePhase, hidePhase,
endSound,
font, font,
keyColour, keyColour,
timerColour, timerColour,
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} /> <ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} /> <div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && ( {!hideMessage && (
@@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div> </div>
); );
} }
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -0,0 +1,34 @@
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);
});
});
+14 -1
View File
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils'; import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None // manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [ const timerDisplayOptions: SelectOption[] = [
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean', type: 'boolean',
defaultValue: false, 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: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
], ],
}, },
{ {
@@ -169,7 +179,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
{ {
id: 'timerColour', id: 'timerColour',
title: 'Timer Colour', title: 'Timer Colour',
description: 'Timer colour. Default: #f6f6f6', description: 'Timer colour. Default: #F6F6F6',
type: 'colour', type: 'colour',
defaultValue: 'f6f6f6', defaultValue: 'f6f6f6',
}, },
@@ -193,6 +203,7 @@ type TimerOptions = {
freezeOvertime: boolean; freezeOvertime: boolean;
freezeMessage: string; freezeMessage: string;
hidePhase: boolean; hidePhase: boolean;
endSound: EndSound;
font?: string; font?: string;
keyColour?: string; keyColour?: string;
timerColour?: string; timerColour?: string;
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source // Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None); const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return { return {
hideClock: isStringBoolean(getValue('hideClock')), hideClock: isStringBoolean(getValue('hideClock')),
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')), freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '', freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')), hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined, font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')), keyColour: makeColourString(getValue('keyColour')),

Some files were not shown because too many files have changed in this diff Show More