Compare commits

...

61 Commits

Author SHA1 Message Date
Claude 3134e5027a fix(mcp): do not set event end actions unless explicitly requested
End actions (load-next, play-next) create playback automations that remove
operator control between events and can surprise users. The agenda-building
prompt previously encouraged play-next for back-to-back segments, causing the
agent to add end actions proactively.

Update the endAction field description and the create_rundown_from_agenda
prompt so end actions are only applied when the user explicitly asks for
automatic cueing or chaining; a seamless schedule no longer implies automation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3XRF6QwHZNB5o69xJ7UB
2026-07-01 09:16:32 +00:00
Claude 94024a7cdf feat(mcp): add custom field management tools
Adds three new MCP tools for managing project-level custom field definitions:
- ontime_create_custom_field: create a field with label, type, colour; key is auto-derived from label
- ontime_update_custom_field: rename or recolour an existing field (cascades key renames to all entries)
- ontime_delete_custom_field: remove a field and its values from all entries across all rundowns

Also improves the assertKnownCustomFields error to guide the agent toward creating the
missing field rather than just reporting it as unknown, and adds a manage_custom_fields
prompt for guided field management workflows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3XRF6QwHZNB5o69xJ7UB
2026-07-01 09:24:00 +02:00
Carlos Valente 9a6feed49b refactor: improve groups and time end 2026-06-28 09:08:24 +02:00
Carlos Valente c6cf9f2bf7 fixup! refactor: add to correct section 2026-06-28 08:48:14 +02:00
Carlos Valente 2f73303554 refactor: simplify object creation 2026-06-28 08:46:11 +02:00
Carlos Valente 96943ca528 refactor: add to correct section 2026-06-28 08:45:54 +02:00
Carlos Valente 8cfffd32c7 fix: duplicate rundowns from service layer 2026-06-27 16:30:04 +02:00
Carlos Valente 24a606a246 fixup! improve linting and documentation 2026-06-27 16:27:59 +02:00
Carlos Valente c322028821 docs 2026-06-27 16:27:59 +02:00
Carlos Valente 9f40d962ea improve linting and documentation 2026-06-27 16:27:59 +02:00
Claude 1e851ae40d refactor(mcp): port tools to current services with typed translation handlers
- Tool handlers are now a typed map of thin translation wrappers: wire
  arguments are cast once at the boundary to types derived (Pick) from
  ontime-types, and all service calls are fully typed — a change to a
  service signature or domain type now fails compilation in api-mcp
  instead of drifting silently. No more 'as never' casts.
- Entry mutations target the loaded rundown explicitly, matching the
  rundownId-first service signatures introduced with secondary rundowns.
- Rundown create/rename/delete/duplicate call the rundown service;
  mcp.service.ts and its duplicated router logic are deleted.
- ontime_create_entry supports all entry types (event, delay, milestone,
  group) and entry tools accept flag and custom field values, matching
  what the workflow prompts already instruct agents to do. Tool names
  use entry terminology consistently with the service layer.
- ontime_create_project description fixed: creating a project switches
  to it.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 2bcda2dfa4 refactor(mcp): scope bearer authentication to the MCP route
Reverts the change to the shared authenticate middleware, which made
every authenticated route accept Authorization: Bearer tokens. Bearer
support now lives in a wrapper middleware inside api-mcp, mounted only
on /mcp; all other requests fall through to the app middleware
unchanged. Removing the api-mcp module restores stock auth behaviour.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude e2a2ea1d24 refactor(rundown): extract rundown rename/delete/duplicate into service layer
Moves the logic previously inlined in the router handlers into
rundown.service.ts so other consumers (such as the MCP server) can reuse
it without duplication. Status codes and error messages are unchanged.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 5be596b83e refactor(mcp): dispatch map for resources, userPrompt helper, enrich prompt guidance
mcp.resources.ts: replace 6-branch if-chain with a RESOURCE_READERS dispatch map.
Each resource is now a one-liner { mimeType, read } entry; handleReadResource
collapses to a 3-line lookup with a single throw.

mcp.prompts.ts: extract repeated message-wrapping boilerplate into userPrompt()
helper, reducing each prompt from ~10 lines of structure to a single call.

Enrich all four prompts with operational domain knowledge:
- create_rundown_from_agenda: entry type selection (event/milestone/delay/group),
  timerType and endAction guidance, linkStart chain semantics, flag usage, colour
  conventions
- bulk_edit_rundown: timeStrategy (lock-end vs lock-duration), endAction automation
  risk, clarified time-shift mechanics
- validate_rundown: totalDelay/totalDays checks, linkStart chain validation,
  play-next chain disclosure, flag audit
- restructure_rundown: group awareness (entries vs order arrays), insert order type,
  move-out vs move-in patterns

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ab15a65585 simplify: unify 405 handlers, fix double-read in renameRundown, guard mcpClientConfig
- mcp.router.ts: extract identical GET/DELETE 405 responses into methodNotAllowed
- mcp.service.ts: pass the already-patched rundown object to initRundown instead
  of re-fetching it from the data provider
- McpSection.tsx: compute mcpClientConfig only when mcpEndpointUrl is truthy

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 65ef442a9d refactor(mcp): consolidate documentation into mcp.schema.ts
Single file to update when the data model changes:
- EVENT_TIMER_FIELDS and EVENT_WRITABLE_FIELDS (tool JSON schemas) moved from mcp.tools.ts
- ONTIME_SCHEMA_MARKDOWN and ONTIME_DOCS_MARKDOWN moved from mcp.resources.ts
- mcp.tools.ts and mcp.resources.ts now import from mcp.schema.ts
- Removed inline data model notes from the agenda prompt; replaced with a reference
  to the ontime://schema resource
- Fixed inaccurate enum values in schema (Milestone type added, TimerType/EndAction
  corrected to match ontime-types)

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 71a7f26493 refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude b850a51204 mcp: add ontime://docs resource for documentation discoverability
Exposes a curated index of docs.getontime.no topic URLs as the
ontime://docs MCP resource, and adds a "Further reading" footer to
ontime://schema so agents reading the data model know where to find
deeper documentation.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 0094c15da2 feat(mcp): expose rundown and schema as readable MCP resources
Adds five resources so the agent can load context once per session
instead of re-fetching via tools, and so it can ground its answers in
Ontime's data model without guessing:

- ontime://schema — markdown reference for the event/rundown/group
  model, time format (ms from midnight), cue conventions, colours,
  playback states
- ontime://rundown/current — full currently-loaded rundown (JSON)
- ontime://rundowns — every rundown in the project plus the loaded ID
- ontime://project/info — project metadata
- ontime://project/custom-fields — custom field definitions

Declares the resources capability on the MCP server and registers
resources/list and resources/read handlers that dispatch by URI.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude e7d62f44e7 feat(mcp): batch rundown edits, project file management, workflow prompts
Adds 8 tools and 3 prompts to cover four priority pre-production flows:
build-from-agenda (batch create), bulk edit, validate, restructure.

Tools:
- create_events_batch, batch_update_events — avoid per-event round trips
- list_projects, load_project, create_project, rename_project,
  duplicate_project, delete_project — project file management

Prompts:
- bulk_edit_rundown — teaches the agent to use batch_update_events vs
  per-event updates based on whether fields are shared
- validate_rundown — pre-show checks for cue gaps, overlaps, missing
  fields, duration anomalies
- restructure_rundown — teaches reorder_event sequencing
- create_rundown_from_agenda — updated to use create_events_batch

Safety:
- Mutating tools now attach a playback warning to their response when
  timer state is not stopped, so the agent can relay it to the user
- create_automation description mentions the in-app test button

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3c8d7e51f5 chore: ignore .claude/ metadata directory
https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3da3950fce fix(mcp): session ID mismatch, rename_rundown data loss, DELETE status
- Session ID: deterministic UUID passed to sessionIdGenerator so the
  map key matches the mcp-session-id header sent to clients; previously
  two independent randomUUID() calls produced different IDs, breaking
  all post-initialize requests
- rename_rundown: use spread { ...rundown, title } instead of narrow
  structuredClone cast that silently stripped order/entries/revision
- DELETE /mcp: return 204 No Content instead of 200 with body

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ac3694ce4d feat: add MCP server for AI agent integration
Adds a Streamable HTTP MCP server at /mcp that exposes 19 tools
covering rundown entries, rundown management, runtime state, project
info, automations, and custom fields. Includes Bearer token auth
support and a UI card in Settings > Sharing showing the endpoint URL
and Claude Desktop config.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Alex Christoffer Rasmussen 41a09bf5c3 fix: send refetch on all rundown edits (#2098)
* fix(server): send refetch on all rundown edits

* fix(test): cuesheet tabing

* chore: remove unneeded async

* refactor: small cleanups

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* chore: remove unneded async/await

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-06-20 12:21:11 +02:00
Claude 5dbe7afae8 feat(client): add rundown default to inherit parent group colour 2026-06-20 10:46:34 +02:00
Carlos Valente 6633cfc869 chore: minor server package upgrades 2026-06-20 09:15:23 +02:00
Carlos Valente d127367df9 chore: minor client package upgrades 2026-06-20 09:15:23 +02:00
Carlos Valente b21ec41ffd chore: upgrade base-ui 2026-06-20 09:15:23 +02:00
Carlos Valente 66292bc0cf chore: upgrade react packages 2026-06-20 09:15:23 +02:00
Alex Christoffer Rasmussen b946b245c2 feat: create the first event from the cuesheet (#2105)
* feat: create the first event from the cuesheet

* chore: cleanup css

* test: cuesheet beckground edit from empty state

* feat: only show +event button when user is in edit mode and have full write perms
2026-06-17 22:31:13 +02:00
Carlos Valente c238147b75 chore: bump node version 2026-06-11 18:58:00 +02:00
Alex Christoffer Rasmussen 5f33c993f6 bump: v4.9.0 (#2095) 2026-06-07 13:53:25 +02:00
Carlos Valente 0e6d6cd416 refactor: allow secondary rundowns (#2086)
* refactor: allow secondary rundowns

* ui: move dropdown

* feat: follow loaded

* ui: background edit warning

ui: fix disable radio button

* feat(ui): add loaded sufix in the rundown list

* feat(ui): add direct link to background edit from rundown manager

* fix: better fallback

* fix: default to is isCurrentRundown for nav bar colour

* chore: rename navigate to cuesheet

---------

Co-authored-by: alex-arc <ac@omnivox.dk>
2026-06-07 13:33:38 +02:00
Carlos Valente 199dea5df6 fix(cuesheet): prevent timer cells from submitting on tab-out or escape (#2083) 2026-06-07 12:59:18 +02:00
Alex Christoffer Rasmussen 5a3ea9f7d0 fix: playwright ci test (#2094) 2026-05-28 09:50:20 +02:00
Alex Christoffer Rasmussen 9ae49a7175 chore: pnpm 11 + dependency cleanup (#2089)
* core: upgrade to pnpm 11 and nodejs to 22.22.2

* chore: allow builds

* chore: remove unused

* chore: update actions

* chore: add stub for vitest

* reafactor: small optimization
2026-05-17 21:01:16 +02:00
Carlos Valente d30d9cfd4e refactor: account for tick size on translate 2026-05-13 20:26:15 +02:00
Carlos Valente a0e7b9b665 refactor: simplify spacing between help menu items 2026-05-13 20:26:15 +02:00
Carlos Valente 1dcb7fb08d refactor: fix Info text flow 2026-05-13 20:26:15 +02:00
Carlos Valente a7e84b9903 refactor: fixed css editor size 2026-05-13 20:26:15 +02:00
Carlos Valente 3bebbf9ce9 refactor: invalidate changed rundown 2026-05-11 21:25:15 +02:00
Carlos Valente ab8da07518 refactor: improve memoisation on table rows 2026-05-11 21:25:15 +02:00
Carlos Valente c8bae76121 refactor: add empty state for autoamtion form 2026-05-11 20:59:50 +02:00
Carlos Valente 2873698959 bump version to 4.8.0 2026-05-11 19:55:33 +02:00
Cameron Slipp 741ebef90e refactor: improve automation UX 2026-05-11 19:55:01 +02:00
Claude 0741f46aca feat: add self-contained legacy timer viewer for old browsers
Adds timer-legacy.html — a zero-dependency static page that connects
to the Ontime WebSocket and displays the live countdown for browsers
that cannot parse the Vite-built React bundle (Safari < 14, Chrome < 85).

Also adds a synchronous pre-React guard in index.html that redirects
/timer visitors to the legacy page when Promise.any is unavailable,
matching the Vite build target floor of Safari 14 / Chrome 85.

https://claude.ai/code/session_013MfdwdjdDUnr3akpGSGXa4
2026-05-09 22:33:34 +02:00
Carlos Valente 5a33ffad92 refactor: improve background colour inheritance in safari 2026-05-09 22:32:04 +02:00
Claude 562f00dc7e fix: add legacy plugin to support iOS 14 2026-05-09 22:31:30 +02:00
Carlos Valente e50ab0150e docs: add link to youtube 2026-05-09 22:25:32 +02:00
Joel Wetzell fe44b33da2 Merge pull request #2078 from cpvalente/macos-icon
refactor: icon compatibility with macos tahoe
2026-05-09 08:15:08 -05:00
Joel Wetzell 22e392ba9d change macOS runner version in build workflow 2026-05-08 09:22:31 -05:00
Joel Wetzell 4997602e9b refactor: icon compatibility with macos tahoe 2026-05-07 20:23:26 -05:00
Joel Wetzell bdb8d4d89a Merge pull request #2077 from cpvalente/update-electron-builder
update electron-builder to v26.9.1
2026-05-07 16:24:39 -05:00
Joel Wetzell b1d5ee12e4 update electron-builder to v26.9.1 2026-05-07 07:50:49 -05:00
Alex Christoffer Rasmussen a5e733246d fix: format clock from preset (#2066)
* fix: format clock from preset

* fix: add to all views

* chore: format

* refactor: return flat value from common.options.ts
2026-05-03 18:43:58 +02:00
Alex Christoffer Rasmussen 1a08b39b8b feat: auto cue re-numbering (#2016)
* feat: update auto cue numbering

* feat: renumber from ui

* refactor: patchEntries is not used

* chore: format

* fix: correct cue at top of group

* fix: handle precision

* refactor dialog

* bump limit for performance time test

* extract type

* add class name to lable

* fix rebase

* refator: extract renumering logic

* chore: comments for getIntegerAndFraction function

* chore: add the for renumber mutation

* fix: fraction match precision

* refactor: small cleanup

* refactor: use more narrow validator

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-05-03 18:39:00 +02:00
Carlos Valente ba1c3235b3 refactor: improve gsheet import UX 2026-05-03 18:17:17 +02:00
Alex Christoffer Rasmussen 7c1d2f4554 refactor: small cleanups (#2059)
* refactor: remove unneeded async

* chore: add express Router type to all routes

* refactor: don't use index in react key

* refactor: correctly get error message in excel route

* refactor: avoid exporting muteable values
2026-04-26 17:32:50 +02:00
Alex Christoffer Rasmussen 23a44b2f04 refactor: improve the amount of GET requests for translation assets (#2058)
* refactor: replace traslation hook refetch time with stale time

* refactor: make it the service responsebillety to send refetch keys for asset changes
2026-04-14 09:30:27 +02:00
Carlos Valente 2d3c8e7eb2 chore: update CI actions 2026-04-09 21:30:27 +02:00
Carlos Valente 2f138acad7 fix: extracted rundown entry editor shows correct colour 2026-04-09 06:51:18 +02:00
166 changed files with 6915 additions and 2678 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
@@ -20,13 +20,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+3 -3
View File
@@ -12,16 +12,16 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+14 -14
View File
@@ -7,23 +7,23 @@ on:
jobs:
build_macos:
runs-on: macOS-latest
runs-on: macOS-26
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -44,7 +44,7 @@ jobs:
timeout-minutes: 60
- name: Upload macOS build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-macos-build
@@ -54,7 +54,7 @@ jobs:
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
@@ -67,14 +67,14 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -83,7 +83,7 @@ jobs:
run: pnpm dist-win
- name: Upload Windows build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-windows-build
@@ -93,7 +93,7 @@ jobs:
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: './apps/electron/dist/ontime-win64.exe'
@@ -104,14 +104,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -120,7 +120,7 @@ jobs:
run: pnpm dist-linux
- name: Upload Linux build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-linux-build
@@ -131,7 +131,7 @@ jobs:
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
+3 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
@@ -18,13 +18,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+19 -11
View File
@@ -13,16 +13,20 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -52,16 +56,20 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -74,30 +82,30 @@ jobs:
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
- run: pnpm playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: pnpm playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: automated-screenshots
path: automated-screenshots/
+3
View File
@@ -54,3 +54,6 @@ ontime-data/
# temporary write files
**.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.15.1
v22.22.3
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.15.1
ARG NODE_VERSION=22.22.2
FROM node:${NODE_VERSION}-alpine
# Set environment variables
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.7.0",
"version": "4.9.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+9 -1
View File
@@ -4,9 +4,10 @@
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
@@ -17,6 +18,13 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
var b = document.querySelector('base');
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
window.location.replace(base + '/timer-legacy' + window.location.search);
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+20 -19
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.7.0",
"version": "4.9.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.3.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -12,28 +12,27 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.9",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"axios": "^1.18.0",
"csv-stringify": "^6.6.0",
"qrcode": "^1.5.4",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.72.0",
"react-icons": "5.5.0",
"react-router": "^7.11.0",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.9"
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -51,7 +50,8 @@
"production": [
">0.2%",
"not dead",
"not op_mini all"
"not op_mini all",
"safari >= 14.1"
],
"development": [
"last 1 chrome version",
@@ -64,8 +64,10 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-legacy": "^8.0.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"happy-dom": "^20.9.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
@@ -73,7 +75,6 @@
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "catalog:"
}
}
+1 -1
View File
@@ -15,5 +15,5 @@
"start_url": "./",
"display": "",
"theme_color": "#121212",
"background_color": "#ffffff"
"background_color": "#101010"
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#2B5ABC",
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
+38 -12
View File
@@ -1,5 +1,13 @@
import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import {
EntryId,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
RenumberCues,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
@@ -26,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
throw new Error('Invalid rundown payload');
}
return res.data;
}
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
/**
* HTTP request to fetch all entries in the given rundown
*/
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
}
/**
@@ -111,6 +117,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
/**
* HTTP request to renumber cues for multiple events
*/
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
}
export type ReorderEntry = {
entryId: EntryId;
destinationId: EntryId;
@@ -182,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
}
// #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
@@ -1,10 +1,9 @@
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
/* eslint-disable react/destructuring-assignment */
import React from 'react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { runtimeStore } from '../../stores/runtime';
import { getConnectionState, getReconnectAttempts } from '../../utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -37,7 +36,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({
error,
store: appState,
hasSocket: { hasConnected, reconnectAttempts },
hasSocket: { hasConnected: getConnectionState(), reconnectAttempts: getReconnectAttempts() },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
}
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => {
if (typeof initialText === 'undefined') {
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit
*/
const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
event.stopPropagation();
}
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => {
onChange: (event) => handleChange(event.target.value),
onBlur: (event) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
handleSubmit(event.target.value);
}
},
onKeyDown: keyHandler,
@@ -8,9 +8,12 @@
padding-top: 10vh;
}
.empty {
width: 100%;
opacity: 0.8;
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1em;
}
.text {
@@ -1,18 +1,33 @@
import EmptyImage from '../../../assets/images/empty.svg?react';
import { SupportedEntry } from 'ontime-types';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
text: string;
handleAddNew?: (type: SupportedEntry) => void;
}
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
</td>
</tr>
</tbody>
@@ -64,8 +64,8 @@ function SectionContents({ options, collapsed }: SectionContentsProps) {
function HiddenContents({ options }: { options: ParamField[] }) {
return (
<>
{options.map((option, index) => {
return <ParamInput key={option.title + index} paramField={option} />;
{options.map((option) => {
return <ParamInput key={option.id} paramField={option} />;
})}
</>
);
@@ -26,3 +26,14 @@ export const showLeadingZeros: ParamField = {
type: 'boolean',
defaultValue: false,
};
export type TimeOptions = {
timeformat: string | null;
};
/**
* Helper to get value of 'timeformat' from either source, prioritizing defaultValues
*/
export function getTimeOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
return defaultValues?.get('timeformat') ?? searchParams.get('timeformat');
}
@@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { langEn } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getUserTranslation } from '../../common/api/assets';
import { TRANSLATION } from '../../common/api/constants';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? langEn, status, refetch };
}
@@ -12,6 +12,7 @@ import {
renameRundown,
} from '../api/rundown';
//TODO: make suspends so we don't have to deal with no value all over
/**
* Project rundowns
*/
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
};
/**
* Normalised rundown data
* Normalised rundown data for the currently loaded rundown.
*
* 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() {
const queryClient = useQueryClient();
@@ -34,12 +38,13 @@ export default function useRundown() {
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
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 });
@@ -114,3 +119,21 @@ export function useRundownAuxData() {
}, [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,54 @@
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],
);
}
+62 -8
View File
@@ -16,6 +16,7 @@ import {
TimeStrategy,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types';
import {
MILLIS_PER_SECOND,
@@ -41,6 +42,7 @@ import {
patchReorderEntry,
postAddEntry,
postCloneEntry,
patchRenumberCues,
putBatchEditEvents,
putEditEntry,
requestApplyDelay,
@@ -62,9 +64,16 @@ export type EventOptions = Partial<{
}>;
/**
* Gather utilities for actions on entries
* Gather utilities for actions on entries in the loaded rundown.
*/
export const useEntryActions = () => {
export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -74,15 +83,16 @@ export const useEntryActions = () => {
defaultDangerTime,
defaultTimerType,
defaultEndAction,
inheritGroupColour,
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (loadedRundownId) {
return getRundownQueryKey(loadedRundownId);
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
return CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient]);
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -231,6 +241,14 @@ export const useEntryActions = () => {
}
}
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
const parentId = resolveInsertParent(rundownData, newEntry);
const maybeParent = parentId ? rundownData.entries[parentId] : null;
if (maybeParent && isOntimeGroup(maybeParent)) {
newEntry.colour = maybeParent.colour;
}
}
try {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -246,6 +264,7 @@ export const useEntryActions = () => {
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
inheritGroupColour,
addEntryMutation,
],
);
@@ -523,6 +542,39 @@ export const useEntryActions = () => {
[batchUpdateEventsMutation, getCurrentRundownData],
);
const { mutateAsync: renumberCuesMutation } = useMutation({
mutationFn: ([rundownId, body]: Parameters<typeof patchRenumberCues>) => patchRenumberCues(rundownId, body),
onMutate: async () => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
return { previousRundown, queryKey };
},
onSuccess: (response, _variables, context) => {
if (!response.data || !context?.queryKey) return;
const updatedRundown = response.data;
queryClient.setQueryData<Rundown>(context.queryKey, updatedRundown);
},
onError: (_error, _vars, context) => {
if (context?.previousRundown) queryClient.setQueryData<Rundown>(context.queryKey, context.previousRundown);
},
});
const renumberCues = useCallback(
async (eventIds: EntryId[], prefix: string, start: string, increment: string) => {
const rundown = getCurrentRundownData();
const rundownId = rundown?.id;
if (!rundownId) throw new Error('Rundown not initialized');
try {
await renumberCuesMutation([rundownId, { ids: eventIds, prefix, start, increment }]);
} catch (error) {
logAxiosError('Error renumbering cues', error);
throw error;
}
},
[getCurrentRundownData, renumberCuesMutation],
);
/**
* Calls mutation to delete an entry
* @private
@@ -947,6 +999,7 @@ export const useEntryActions = () => {
groupEntries,
move,
reorderEntry,
renumberCues,
swapEvents,
updateEntry,
updateTimer,
@@ -963,12 +1016,13 @@ export const useEntryActions = () => {
groupEntries,
move,
reorderEntry,
renumberCues,
swapEvents,
updateEntry,
updateTimer,
],
);
};
}
/**
* Utility to optimistically delete entries from client cache
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
defaultDangerTime: string;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
dangerTime: '00:01:00', // 60000 same as backend
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
};
enum EditorSettingsKeys {
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
DefaultDangerTime = 'ontime-default-danger-time',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
inheritGroupColour: booleanFromLocalStorage(
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
setInheritGroupColour: (inheritGroupColour) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
};
});
@@ -40,6 +40,15 @@ describe('getRouteFromPreset()', () => {
options: {},
},
];
const disabledPresets: URLPreset[] = [
{
enabled: false,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {},
},
];
it('checks if the current location matches an enabled preset', () => {
// we make the current location be the alias
@@ -47,6 +56,21 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('checks if the current location matches an enabled preset target', () => {
const location = resolvePath('/timer');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('does not redirect disabled presets', () => {
const location = resolvePath('/demopage');
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
});
it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull();
});
it('returns null if the current location is the exact match of an unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
@@ -81,6 +105,11 @@ describe('getRouteFromPreset()', () => {
const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
});
describe('cuesheet presets', () => {
+30 -30
View File
@@ -3,9 +3,7 @@ import {
Log,
MaybeNumber,
MessageTag,
ProjectRundownsList,
RefetchKey,
Rundown,
RuntimeStore,
WsPacketToClient,
WsPacketToServer,
@@ -16,16 +14,17 @@ import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA,
CURRENT_RUNDOWN_QUERY_KEY,
PROJECT_RUNDOWNS,
REPORT,
RUNDOWN,
RUNTIME,
TRANSLATION,
URL_PRESETS,
VIEW_SETTINGS,
getRundownQueryKey,
PROJECT_RUNDOWNS,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
@@ -51,8 +50,10 @@ const socketConfig = {
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
export let hasConnected = false;
export let reconnectAttempts = 0;
export const getConnectionState = () => hasConnected;
export const getReconnectAttempts = () => reconnectAttempts;
let hasConnected = false;
let reconnectAttempts = 0;
export const connectSocket = () => {
websocket = new WebSocket(websocketUrl);
@@ -179,7 +180,7 @@ export const connectSocket = () => {
}
case MessageTag.Refetch: {
// the refetch message signals that the rundown has changed in the server side
const { target, revision } = payload;
const { target, revision, rundownId } = payload;
switch (target) {
case RefetchKey.All:
invalidateAllCaches();
@@ -194,7 +195,7 @@ export const connectSocket = () => {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
case RefetchKey.Rundown: {
maybeInvalidateRundownCache(revision);
maybeInvalidateRundownCache(revision, rundownId);
break;
}
case RefetchKey.UrlPresets:
@@ -212,6 +213,9 @@ export const connectSocket = () => {
case RefetchKey.Settings:
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
break;
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
default: {
target satisfies never;
break;
@@ -230,34 +234,30 @@ export const connectSocket = () => {
};
};
/**
* When we receive a refetch message for the rundown
* check which rundown needs to be invalidated
*/
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
?.loaded;
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
if (revision === cachedRundown?.revision) {
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return;
}
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (loadedRundownId) {
// Keep bootstrap alias in sync with the ID-based cache
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
} else {
// During bootstrap, loadedRundownId is not yet known.
// Invalidate any ID-based rundown caches that may have been seeded early.
ontimeQueryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
});
if (revision === cachedRundown?.revision) {
// we already have the latest change
return;
}
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
}
export function sendSocket<T extends MessageTag | ApiActionTag>(
+5 -4
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
@@ -75,8 +75,9 @@ function resolveTimeFormat(fallback12: string, fallback24: string): string {
}
type FormatOptions = {
format12: string;
format24: string;
format12?: string;
format24?: string;
override?: MaybeString;
};
/**
@@ -97,7 +98,7 @@ export const formatTime = (
return '...';
}
const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
const timeFormat = options?.override ?? resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
const isNegative = milliseconds < 0;
+3 -3
View File
@@ -204,7 +204,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
/**
* Check if current location is a preset path
*/
export function isPresetPath(location: Path): boolean {
const segments = location.pathname.split('/').filter(Boolean);
return segments[0] === 'preset';
function isPresetPath(location: Path): boolean {
const firstSegment = location.pathname.split('/').find(Boolean);
return firstSegment === 'preset';
}
+1
View File
@@ -9,6 +9,7 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const youtubeUrl = 'https://www.youtube.com/@ontimeapp';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -53,10 +53,6 @@ ul {
background-color: $red-400;
}
}
&.split {
margin-top: 1rem;
}
}
.secondary {
@@ -42,16 +42,9 @@ interface PanelListItemProps {
location?: string;
}
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
return (
<Fragment key={panel.id}>
@@ -7,6 +7,7 @@ import {
githubUrl,
subredditUrl,
websiteUrl,
youtubeUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
@@ -89,10 +89,9 @@ export default function AutomationSettingsForm({
<Panel.Section>
<Info>
<p>Control Ontime and share its data with external systems in your workflow.</p>
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
<br />
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
<Panel.Divider />
<Panel.Section>
<Info>
Custom fields allow for additional information to be added to an event.
<br />
<br />
To use custom fields as a data source in an{' '}
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
<span>Custom fields allow for additional information to be added to an event.</span>
<span>
To use custom fields as a data source in an{' '}
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
</span>
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
@@ -52,8 +52,9 @@ export default function CustomViews() {
<Panel.Section>
<Info>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</strong>.
<br />
<span>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</strong>.
</span>
External imports are not allowed, include all assets inside the html file.
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
</Info>
@@ -9,6 +9,7 @@ import {
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { TbGhost3 } from 'react-icons/tb';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
await downloadAsExcel(rundownId, title);
};
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
return (
<>
<Panel.Section>
@@ -153,7 +157,7 @@ export default function ManageRundowns() {
</td>
<td>
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
items={[
{
type: 'item',
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{
type: 'item',
icon: TbGhost3,
label: 'Edit in cuesheet',
onClick: () => navigateToCuesheet(id),
},
{ type: 'divider' },
{
type: 'destructive',
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
defaultDangerTime,
defaultTimerType,
defaultEndAction,
inheritGroupColour,
setDefaultDuration,
setLinkPrevious,
setTimeStrategy,
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
setDangerTime,
setDefaultTimerType,
setDefaultEndAction,
setInheritGroupColour,
} = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration);
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
/>
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Inherit group colour'
description='Whether new events and milestones inherit the colour of their parent group'
/>
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Timer strategy'
@@ -15,23 +15,25 @@ import Input from '../../../../../common/components/input/input/Input';
import Tag from '../../../../../common/components/tag/Tag';
import { openLink } from '../../../../../common/utils/linkUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import { extractSheetId, getPersistedSheetId, persistSheetId } from './gsheetUtils';
import style from './SourcesPanel.module.scss';
interface GSheetSetupProps {
onCancel: () => void;
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
closedByUser: boolean;
}
export default function GSheetSetup(props: GSheetSetupProps) {
const { onCancel, onSheetLoaded } = props;
const { onCancel, onSheetLoaded, closedByUser } = props;
const [file, setFile] = useState<File | null>(null);
const [sheetId, setSheetId] = useState('');
const [sheetId, setSheetId] = useState(getPersistedSheetId);
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
const [authKey, setAuthKey] = useState<string | null>(null);
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
const [authLink, setAuthLink] = useState('');
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
const [authError, setAuthError] = useState('');
const [worksheetError, setWorksheetError] = useState('');
const pollTimeoutRef = useRef<number | null>(null);
@@ -62,6 +64,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const loadWorksheetOptions = useCallback(
async (nextSheetId: string) => {
const worksheetOptions = await getWorksheetOptions(nextSheetId);
persistSheetId(nextSheetId);
onSheetLoaded(nextSheetId, worksheetOptions);
setWorksheetError('');
},
@@ -71,6 +74,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const pollUntilAuthenticated = useCallback(
async (attempts: number = 0) => {
clearPollTimeout();
if (closedByUser) return;
try {
const result = await verifyAuthenticationStatus();
@@ -82,13 +86,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
pollTimeoutRef.current = window.setTimeout(() => {
pollUntilAuthenticated(attempts + 1);
}, 2000);
} else {
setLoading('');
return; // Keep authKey for next poll
}
// Polling timed out
setAuthKey(null);
setLoading('');
return;
}
if (result.authenticated === 'authenticated') {
if (result.authenticated === 'authenticated' && result.sheetId) {
setLoading('load-sheet');
try {
await loadWorksheetOptions(result.sheetId);
} catch (error) {
@@ -96,13 +103,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
}
}
setAuthKey(null);
setLoading('');
} catch (error) {
setAuthError(maybeAxiosError(error));
setAuthKey(null);
setLoading('');
}
},
[clearPollTimeout, loadWorksheetOptions],
[clearPollTimeout, loadWorksheetOptions, closedByUser],
);
/** check if the current session has been authenticated */
@@ -150,7 +159,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
};
/**
* Requests connection to google auth
* Requests a device code from Google. The user can copy it before opening the browser.
*/
const handleConnect = async () => {
if (!file) return;
@@ -171,7 +180,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
};
/**
* Open google auth
* Opens the Google verification page and starts polling for completion.
*/
const handleAuthenticate = () => {
setLoading('authenticate');
@@ -180,7 +189,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
clearPollTimeout();
clearAuthFallbackTimeout();
// open link and schedule a check for when the user focuses again
openLink(authLink);
authFallbackTimeoutRef.current = window.setTimeout(() => {
if (document.hasFocus()) {
@@ -269,18 +277,19 @@ export default function GSheetSetup(props: GSheetSetupProps) {
</Panel.ListGroup>
)}
<Panel.ListGroup className={style.setupBlock}>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Description>Enter ID of sheet to synchronize</Panel.Description>
<Panel.Error>{worksheetError}</Panel.Error>
<Input
fluid
value={sheetId}
placeholder='Sheet ID'
placeholder='Sheet ID or Google Sheets URL'
onChange={(event) => {
setWorksheetError('');
setSheetId(event.target.value);
setSheetId(extractSheetId(event.target.value));
}}
disabled={isLoading || canAuthenticate}
/>
<div className={style.setupHint}>Paste a Google Sheets URL or the sheet ID from the URL bar.</div>
</Panel.ListGroup>
{isAuthenticated ? (
<Panel.ListGroup className={style.setupBlock}>
@@ -304,18 +313,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
</Panel.ListGroup>
) : (
<Panel.ListGroup className={style.setupBlock}>
<Panel.Description>Authenticate this Ontime session with Google</Panel.Description>
<Panel.Description>Copy the device code, then authenticate with Google</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
{isAuthenticating && <span>Authenticating...</span>}
<CopyTag copyValue={authKey ?? ''} disabled={!canAuthenticate}>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
<CopyTag copyValue={authKey ?? ''}>{authKey}</CopyTag>
<Button onClick={handleAuthenticate} disabled={isLoading} loading={loading === 'authenticate'}>
<IoShieldCheckmarkOutline />
Authenticate
</Button>
</Panel.InlineElements>
<div className={style.setupHint}>Open the browser prompt, complete the code flow, then come back here.</div>
<div className={style.setupHint}>Copy the code, then open the browser prompt to complete the flow.</div>
</Panel.ListGroup>
)}
</Panel.Section>
@@ -37,12 +37,15 @@ type ActiveSource =
kind: 'excel';
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
}
| {
kind: 'gsheet';
sheetId: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
title: string;
closedByUser: boolean;
};
export default function SourcesPanel() {
@@ -73,6 +76,7 @@ export default function SourcesPanel() {
kind: 'excel',
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
});
setImportFlow('excel');
setHasFile('done');
@@ -106,7 +110,13 @@ export default function SourcesPanel() {
};
const cancelImportFlow = () => {
resetFlow();
if (activeSource && activeSource.kind === 'gsheet') {
// Return to GSheetSetup so the user can change the sheet ID or revoke auth
setActiveSource({ ...activeSource, closedByUser: true });
setError('');
} else {
resetFlow();
}
};
const handleFinished = () => {
@@ -177,15 +187,22 @@ export default function SourcesPanel() {
sheetId,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
title: worksheetOptions.title ?? '',
closedByUser: false,
});
}, []);
const closedByUser = activeSource?.closedByUser ?? false;
const isGSheetFlow = importFlow === 'gsheet';
const showInput = importFlow === 'none';
const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && activeSource === null;
const showImportWorkspace = activeSource !== null;
const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet';
const showImportWorkspace = activeSource !== null && !closedByUser;
const importModalTitle = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return 'Import spreadsheet';
return activeSource.title ? `Sync: ${activeSource.title}` : 'Synchronize with Google Sheet';
})();
const sourceKey = (() => {
if (!activeSource) return null;
if (activeSource.kind === 'excel') return 'excel';
@@ -195,7 +212,7 @@ export default function SourcesPanel() {
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
<Panel.SubHeader>Synchronize your rundown with an external source</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<div className={style.introStack}>
@@ -233,7 +250,7 @@ export default function SourcesPanel() {
</section>
<section className={style.sourceCard}>
<div className={style.sourceHeader}>
<h4 className={style.sourceTitle}>Synchronise with Google</h4>
<h4 className={style.sourceTitle}>Synchronize with Google</h4>
</div>
<p className={style.sourceDescription}>
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
@@ -241,7 +258,7 @@ export default function SourcesPanel() {
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
<IoCloudOutline />
Synchronise with Google
Synchronize with Google
</Button>
</section>
</div>
@@ -257,7 +274,9 @@ export default function SourcesPanel() {
</Button>
</div>
)}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} />}
{isGSheetFlow && (
<GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} closedByUser={closedByUser} />
)}
<Modal
isOpen={showImportWorkspace}
title={importModalTitle}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { extractSheetId } from '../gsheetUtils';
describe('extractSheetId()', () => {
it('extracts the ID from a full Google Sheets URL', () => {
const url = 'https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZ/edit#gid=0';
expect(extractSheetId(url)).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
});
it('extracts the ID when the URL has no trailing path', () => {
expect(extractSheetId('https://docs.google.com/spreadsheets/d/abc123')).toBe('abc123');
});
it('handles IDs with hyphens and underscores', () => {
const url = 'https://docs.google.com/spreadsheets/d/1a-B_c2/edit';
expect(extractSheetId(url)).toBe('1a-B_c2');
});
it('returns a raw sheet ID unchanged', () => {
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
});
it('trims whitespace from the input', () => {
expect(extractSheetId(' 1aBcDeFg ')).toBe('1aBcDeFg');
});
it('trims whitespace from a pasted URL', () => {
const url = ' https://docs.google.com/spreadsheets/d/1aBcDeFg/edit ';
expect(extractSheetId(url)).toBe('1aBcDeFg');
});
it('strips query params from a raw ID', () => {
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ?edit=1')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
});
it('strips fragment from a raw ID', () => {
expect(extractSheetId('1aBcDeFg#gid=0')).toBe('1aBcDeFg');
});
it('returns empty string for empty input', () => {
expect(extractSheetId('')).toBe('');
expect(extractSheetId(' ')).toBe('');
});
});
@@ -0,0 +1,29 @@
import { makeStageKey } from '../../../../../common/utils/localStorage';
// Matches the document ID segment from a Google Sheets URL:
// https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0
// ^^^^ captured group
// IDs consist of alphanumeric chars, hyphens, and underscores.
const sheetIdPattern = /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/;
const lastSheetIdKey = makeStageKey('gsheet:lastSheetId');
/**
* Extracts a Google Sheet ID from a full URL, or returns the raw input if it doesn't match.
*/
export function extractSheetId(input: string): string {
const trimmed = input.trim();
const match = trimmed.match(sheetIdPattern);
if (match) return match[1];
// Strip query params (?...) and fragments (#...) in case the user
// pasted a partial URL or an ID with trailing junk like "abc123?edit=1"
return trimmed.split(/[?#]/)[0];
}
export function getPersistedSheetId(): string {
return localStorage.getItem(lastSheetIdKey) ?? '';
}
export function persistSheetId(sheetId: string) {
localStorage.setItem(lastSheetIdKey, sheetId);
}
@@ -16,7 +16,7 @@ export function getWarningText(warning: MappingWarning): string {
case 'invalid-name':
return 'Column cannot be converted into an Ontime field name';
case 'name-collision':
return 'Column name resolves to a duplicate column';
return 'Column name resolves to a duplicate Ontime field';
default:
return '';
}
@@ -50,7 +50,7 @@ export default function SheetImportMappingPane({
return (
<section className={style.mappingPane}>
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
<span className={style.mappingPaneTitle}>Fields</span>
<span className={style.mappingPaneTitle}>Column mapping</span>
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
<IoAdd />
@@ -58,7 +58,7 @@ describe('getImportWarnings()', () => {
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
});
it('warns when a custom header resolves to an existing Ontime field name', () => {
it('warns when a custom header resolves to a built-in or duplicate imported field name', () => {
const values = createDefaultFormValues();
values.custom = [
{ ontimeName: '', importName: 'Artist-1' },
@@ -67,12 +67,20 @@ describe('getImportWarnings()', () => {
{ ontimeName: '', importName: 'Presenter-1' },
];
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1'], ['Presenter 1']);
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1']);
expect(warnings['custom.0.importName']).toBeUndefined();
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
expect(warnings['custom.3.importName']).toStrictEqual({ kind: 'name-collision' });
expect(warnings['custom.3.importName']).toBeUndefined();
});
it('does not give false warnings for pre-existing custom fields', () => {
const values = createDefaultFormValues();
values.custom = [{ ontimeName: '', importName: 'Video Info' }];
const warnings = getImportWarnings(values, ['Video Info']);
expect(warnings['custom.0.importName']).toBeUndefined();
});
});
@@ -127,26 +127,26 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
);
}
export function getPersistedImportState(sourceKey: string): ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return createDefaultFormValues();
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(raw);
if (isPersistedFormValues(parsed)) {
return parsed;
return { values: parsed, isPersisted: true };
}
// Invalid schema - delete malformed data
localStorage.removeItem(storageKey);
return createDefaultFormValues();
return { values: createDefaultFormValues(), isPersisted: false };
} catch {
// Parse error - delete corrupted data
localStorage.removeItem(storageKey);
return createDefaultFormValues();
return { values: createDefaultFormValues(), isPersisted: false };
}
}
@@ -156,11 +156,9 @@ export function getPersistedImportState(sourceKey: string): ImportFormValues {
export function getImportWarnings(
values: ImportFormValues,
detectedSpreadsheetColumns: string[],
existingCustomFieldLabels: string[] = [],
): Record<string, MappingWarning | undefined> {
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
const existingLabels = new Set(existingCustomFieldLabels.map((label) => label.trim().toLowerCase()).filter(Boolean));
const seenColumns = new Set<string>();
const seenDerivedLabels = new Set<string>();
const warnings: Record<string, MappingWarning | undefined> = {};
@@ -200,11 +198,7 @@ export function getImportWarnings(
warnings[key] = { kind: 'missing' };
} else {
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
if (
builtInLabels.has(normalisedDerivedLabel) ||
existingLabels.has(normalisedDerivedLabel) ||
seenDerivedLabels.has(normalisedDerivedLabel)
) {
if (builtInLabels.has(normalisedDerivedLabel) || seenDerivedLabels.has(normalisedDerivedLabel)) {
warnings[key] = { kind: 'name-collision' };
}
}
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils';
import useCustomFields from '../../../../../../common/hooks-query/useCustomFields';
import { formatDuration } from '../../../../../../common/utils/time';
import {
type ImportFormValues,
@@ -118,10 +117,15 @@ export function useSheetImportForm({
onApply,
onExport,
}: UseSheetImportFormProps) {
const initialFormValues = useMemo(() => {
const persisted = getPersistedImportState(sourceKey);
const worksheet = getPreferredWorksheet(worksheetNames, persisted.worksheet, initialMetadata?.worksheet ?? '');
return { ...persisted, worksheet };
const { initialFormValues, skipAutoPreview } = useMemo(() => {
const { values, isPersisted } = getPersistedImportState(sourceKey);
const worksheet = getPreferredWorksheet(worksheetNames, values.worksheet, initialMetadata?.worksheet ?? '');
// Skip auto-preview when restoring a persisted mapping that was saved for a different worksheet.
const hasWorksheetChanged = values.worksheet !== worksheet;
return {
initialFormValues: { ...values, worksheet },
skipAutoPreview: isPersisted && hasWorksheetChanged,
};
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
const {
@@ -139,7 +143,6 @@ export function useSheetImportForm({
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
const values = watch();
const { data: existingCustomFields } = useCustomFields();
// --- Worksheet metadata via react-query ---
const queryClient = useQueryClient();
@@ -167,16 +170,14 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const existingCustomFieldLabels = useMemo(
() => Object.values(existingCustomFields).map((field) => field.label),
[existingCustomFields],
);
const warnings = getImportWarnings(values, headers, existingCustomFieldLabels);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
const autoPreviewFiredRef = useRef(false);
// Rehydrate the form from persisted/default state whenever the source context changes.
useEffect(() => {
autoPreviewFiredRef.current = false;
reset(initialFormValues);
dispatch({ type: 'reset' });
}, [initialFormValues, reset]);
@@ -225,6 +226,16 @@ export function useSheetImportForm({
[previewImport],
);
// Auto-preview on mount once metadata is ready.
useEffect(() => {
if (autoPreviewFiredRef.current) return;
if (skipAutoPreview) return;
if (!isValid || headers.length === 0) return;
autoPreviewFiredRef.current = true;
handleSubmit(handlePreview)();
}, [skipAutoPreview, isValid, headers, handleSubmit, handlePreview]);
const handleApply = useCallback(async () => {
if (!state.preview) return;
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
useEffect(() => {
const baseUrl = isOntimeCloud
? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl('');
});
}, [infoData]);
const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
: '';
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Divider />
<Panel.Field
title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.Card>
</Panel.Section>
);
}
@@ -4,6 +4,7 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
import ViewSettings from './ViewSettings';
@@ -13,6 +14,7 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
return (
@@ -30,6 +32,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={customViewsRef}>
<CustomViews />
</div>
<div ref={mcpRef}>
<McpSection />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
@@ -20,4 +20,5 @@
border: 1px solid $gray-1100;
border-radius: 3px;
overflow: hidden;
height: 100%;
}
@@ -7,7 +7,6 @@ export type SettingsOption = {
id: string;
label: string;
secondary?: Readonly<SettingsOption[]>;
split?: boolean;
highlight?: string;
};
@@ -20,13 +19,13 @@ const staticOptions = [
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__port', label: 'Server port' },
{ id: 'settings__mcp', label: 'MCP Server' },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
],
},
{
id: 'project',
label: 'Project',
split: true,
secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
@@ -46,7 +45,6 @@ const staticOptions = [
{
id: 'automation',
label: 'Automation',
split: true,
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
@@ -56,7 +54,6 @@ const staticOptions = [
{
id: 'sharing',
label: 'Sharing and reporting',
split: true,
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{
@@ -69,7 +66,6 @@ const staticOptions = [
{
id: 'network',
label: 'Network',
split: true,
secondary: [
{
id: 'network__log',
@@ -84,12 +80,10 @@ const staticOptions = [
{
id: 'about',
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
@@ -103,18 +97,12 @@ export function useAppSettingsMenu() {
const options: Readonly<SettingsOption[]> = useMemo(
() =>
staticOptions.map((option) => ({
...option,
// if we are in docker don't show the port option
secondary:
'secondary' in option
? isDocker && option.id === 'settings'
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
: [...option.secondary]
: undefined,
staticOptions.map((option) =>
// if there is an update then highlight the about setting
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
})),
option.id === 'about' && data.hasUpdates
? Object.assign({}, option, { highlight: 'New version available' })
: option,
),
[data],
);
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
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 { handleLinks } from '../../common/utils/linkUtils';
@@ -16,6 +17,7 @@ import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditMo
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement';
import RenumberCuesDialog from './renumber-cues-dialog/RenumberCuesDialog';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
@@ -103,6 +105,8 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -111,7 +115,8 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog />
</div>
);
}
@@ -1,7 +1,6 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
import EventEditor from './EventEditor';
import GroupEditor from './GroupEditor';
import MilestoneEditor from './MilestoneEditor';
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
interface CuesheetEntryEditorProps {
entryId: string;
rundown: Rundown;
}
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
const { data } = useRundown();
export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
if (rundown.order.length === 0) {
return null;
}
const event = data.entries[entryId];
const event = rundown.entries[entryId];
return event ?? null;
}, [entryId, data.order, data.entries]);
}, [entryId, rundown.entries, rundown.order.length]);
if (isOntimeEvent(entry)) {
return (
@@ -104,6 +104,7 @@
/* approximating the style of a disabled input */
.textLikeInput {
color: $gray-200;
background: transparent;
justify-content: start;
width: 7.5em;
@@ -1,4 +1,3 @@
import { sanitiseCue } from 'ontime-utils';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
@@ -24,10 +23,6 @@ export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActionsContext();
const cueSubmitHandler = (_field: string, newValue: string) => {
updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
};
const flagSubmitHandler = (newValue: boolean) => {
updateEntry({ id: eventId, flag: newValue });
};
@@ -48,7 +43,7 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
field='cue'
label='Cue'
initialValue={cue}
submitHandler={cueSubmitHandler}
submitHandler={textSubmitHandler}
maxLength={10}
/>
<div>
@@ -4,18 +4,6 @@
gap: 0.75rem;
}
.section,
.formSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sectionTitle {
font-size: $aux-text-size;
color: $label-gray;
}
.triggerList {
display: flex;
flex-direction: column;
@@ -25,25 +13,13 @@
overflow: hidden;
}
.triggerForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.formFields {
.triggerHeader {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
align-items: end;
}
.formActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
min-height: 2rem;
grid-template-columns: 8rem 1fr 2rem;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: $aux-text-size;
color: $label-gray;
}
.trigger {
@@ -51,40 +27,22 @@
display: grid;
grid-template-columns: 8rem 1fr 2rem;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
&:not(:first-child) {
border-top: 1px solid $white-10;
}
&[data-duplicate] {
> button:not(:last-child) {
border-color: $orange-500;
}
}
}
.triggerMeta {
min-width: 0;
}
.metaLabel {
color: $label-gray;
.duplicateMessage {
padding-left: 0.75rem;
font-size: $aux-text-size;
line-height: 1.2;
margin-bottom: 0.125rem;
}
.automationTitle {
overflow-wrap: anywhere;
}
.validationError,
.validationSuccess {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: $aux-text-size;
}
.validationError {
color: $red-500;
}
.validationSuccess {
color: $green-500;
color: $orange-500;
}
@@ -1,11 +1,9 @@
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
import { Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { IoAdd, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
@@ -21,174 +19,108 @@ interface EventEditorTriggersProps {
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const { updateEntry } = useEntryActionsContext();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const showTriggers = triggers.length > 0;
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
value: id,
label: title,
}));
const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
const duplicateIds = new Set<string>();
const seen = new Map<string, string>();
for (const trigger of triggers) {
const key = `${trigger.trigger}:${trigger.automationId}`;
if (seen.has(key)) {
duplicateIds.add(trigger.id);
} else {
seen.set(key, trigger.id);
}
}
const handleAdd = () => {
if (!hasAutomationOptions) return;
updateEntry({
id: eventId,
triggers: [
...triggers,
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
],
});
};
const handleDelete = (triggerId: string) => {
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
};
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
updateEntry({ id: eventId, triggers: newTriggers });
};
return (
<div className={style.triggers}>
{automationsEnabled === false && (
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
)}
{showTriggers && (
<div className={style.section}>
<div className={style.sectionTitle}>Applied automations</div>
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</div>
{automationsEnabled && !hasAutomationOptions && (
<Info>
<Info.Title>No automations available</Info.Title>
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
</Info>
)}
<Editor.Panel className={style.formSection}>
<div className={style.sectionTitle}>Add automation</div>
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</Editor.Panel>
</div>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
automations: NormalisedAutomation;
}
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEntry({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
const validationLabel = validationError ?? 'Ready to add automation';
const triggerOptions = useMemo(
() => [
{ value: null, label: 'Select lifecycle' },
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
],
[], // eventTriggerOptions is a constant, no need for dependency
);
const automationOptions = useMemo(
() => [
{ value: null, label: 'Select Automation' },
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
],
[automations], // This needs to be a dependency as it can change
);
return (
<div className={style.triggerForm}>
<div className={style.formFields}>
<div>
<Editor.Label>Lifecycle</Editor.Label>
<Select
value={cycleValue}
onValueChange={(value) => {
if (value !== null) setCycleValue(value);
}}
options={triggerOptions}
/>
</div>
<div>
<Editor.Label>Automation</Editor.Label>
<Select
value={automationId ?? null}
onValueChange={(value) => {
if (value !== null) setAutomationId(value);
}}
options={automationOptions}
/>
</div>
</div>
<div className={style.formActions}>
<div className={validationError ? style.validationError : style.validationSuccess}>
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
<span>{validationLabel}</span>
</div>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add automation
</Button>
</div>
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
automations: NormalisedAutomation;
}
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActionsContext();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEntry({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEntry],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div className={style.triggerList}>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
{triggers.length > 0 && (
<div className={style.triggerList}>
<div className={style.triggerHeader}>
<span>Lifecycle</span>
<span>Automation</span>
</div>
{triggers.map((trigger) => {
const isDuplicate = duplicateIds.has(trigger.id);
const lifecycleOptions = isDuplicate
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
: triggerOptions;
const automationOptions = isDuplicate
? allAutomationOptions.map((opt) =>
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
)
: allAutomationOptions;
return (
<div key={id} className={style.trigger}>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Lifecycle</div>
<div>{triggerLifeCycle}</div>
</div>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Automation</div>
<div className={style.automationTitle}>{automationTitle}</div>
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
<Select
value={trigger.trigger}
onValueChange={(value) => {
if (value !== null) handleChange(trigger.id, 'trigger', value);
}}
options={lifecycleOptions}
/>
<Select
value={trigger.automationId}
onValueChange={(value) => {
if (value !== null) handleChange(trigger.id, 'automationId', value);
}}
options={automationOptions}
/>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<IoTrash />
</IconButton>
</div>
);
})}
</Fragment>
))}
</div>
)}
{duplicateIds.size > 0 && (
<span className={style.duplicateMessage}>
* Duplicate combinations will only fire once per lifecycle event.
</span>
)}
{hasAutomationOptions && (
<Button variant='ghosted' onClick={handleAdd}>
<IoAdd /> Add automation
</Button>
)}
</div>
);
}
@@ -0,0 +1,22 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/ontimeStyles' as *;
.fields {
display: flex;
flex-direction: column;
gap: 1rem;
padding-inline: 0.5rem;
margin-bottom: 0.5rem;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
}
.error {
padding-inline: 0.5rem;
font-size: $inner-section-text-size;
color: $error-red;
margin: 0;
}
@@ -0,0 +1,131 @@
import { RenumberCues } from 'ontime-types';
import { useForm } from 'react-hook-form';
import { create } from 'zustand';
import { maybeAxiosError } from '../../../common/api/utils';
import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useRundown from '../../../common/hooks-query/useRundown';
import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
import style from './RenumberCuesDialog.module.scss';
type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() {
'use memo';
const { data } = useRundown();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<RenumberCueData>();
const onSubmit = async (data: RenumberCueData) => {
clearErrors();
try {
const { prefix, start, increment } = data;
const orderedEvents = orderEntries(Array.from(selectedEvents), flatOrder);
await renumberCues(orderedEvents, prefix, start, increment);
onClose();
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title='Renumber cues'
showCloseButton
showBackdrop
bodyElements={
<form id='renumber-cues-form' onSubmit={handleSubmit(onSubmit)} className={style.fields}>
<div className={style.field}>
<label className={style.label}>
Prefix
<Input
{...register('prefix')}
type='text'
maxLength={8}
height='large'
fluid
autoComplete='off'
placeholder='A'
/>
</label>
</div>
<div className={style.field}>
<label className={style.label}>
Start
<Input
{...register('start')}
type='number'
required
step={0.001}
height='large'
fluid
autoComplete='off'
placeholder='10'
/>
</label>
</div>
<div className={style.field}>
<label className={style.label}>
Increment
<Input
{...register('increment')}
type='number'
required
step={0.001}
height='large'
fluid
autoComplete='off'
placeholder='0.1'
/>
</label>
</div>
{errors.root && <p className={style.error}>{errors.root.message}</p>}
</form>
}
footerElements={
<>
<Button type='button' variant='subtle-white' onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' variant='primary' form='renumber-cues-form' loading={isSubmitting}>
Renumber
</Button>
</>
}
/>
);
}
interface RenumberCuesDialogState {
isOpen: boolean;
onClose: () => void;
onOpen: () => void;
}
export const useRenumberCuesDialogStore = create<RenumberCuesDialogState>()((set) => ({
isOpen: false,
onClose: () => {
set({ isOpen: false });
},
onOpen: () => {
set({ isOpen: true });
},
}));
@@ -13,13 +13,14 @@ import {
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { TbFlagFilled } from 'react-icons/tb';
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import RundownEventInner from './RundownEventInner';
@@ -99,6 +100,7 @@ export default function RundownEvent({
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
@@ -143,6 +145,13 @@ export default function RundownEvent({
disabled: parent !== null,
},
{ type: 'divider' },
{
type: 'item',
label: 'Renumber cues',
icon: TbListNumbers,
onClick: openRenumberDialog,
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete',
@@ -1,39 +0,0 @@
import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import {
ColumnSettings,
ViewSettings,
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
import { usePersistedRundownOptions } from '../rundown.options';
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
interface EditorTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
export default function EditorTableSettings({
columns,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: EditorTableSettingsProps) {
const options = usePersistedRundownOptions();
return (
<Toolbar.Root className={style.tableSettings}>
<ViewSettings optionsStore={options} />
<ColumnSettings
columns={columns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
</Toolbar.Root>
);
}
@@ -1,7 +1,10 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
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 CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -13,6 +16,8 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -30,12 +35,14 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
}));
function getLoadedRundownData() {
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (loadedRundownId) {
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId));
}
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
+1
View File
@@ -81,6 +81,7 @@ html,
user-select: none;
-webkit-app-region: drag;
isolation: isolate;
background-color: $ui-black; // prevent the safari from showing white background
}
// reset button styles
+1 -1
View File
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
},
});
return Sentry.withSentryReactRouterV6Routing(Routes);
return Sentry.withSentryReactRouterV7Routing(Routes);
};
+13 -11
View File
@@ -24,16 +24,18 @@ export default function ViewLoader({ children }: PropsWithChildren) {
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
return (
<Suspense
fallback={
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Loader />
</>
}
>
<OverrideStyles />
{children}
</Suspense>
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Suspense
fallback={
<>
<Loader />
</>
}
>
<OverrideStyles />
{children}
</Suspense>
</>
);
}
+13 -8
View File
@@ -43,7 +43,7 @@ export default function BackstageLoader() {
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
const { getLocalizedString } = useTranslation();
const { mainSource, secondarySource, extraInfo } = useBackstageOptions();
const { mainSource, secondarySource, extraInfo, timeformat } = useBackstageOptions();
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize();
@@ -71,18 +71,20 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
// gather timer data
const isPendingStart = getIsPendingStart(time.playback, time.phase);
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
const startedAt = isPendingStart
? formatTime(time.secondaryTimer, { override: timeformat })
: formatTime(time.startedAt, { override: timeformat });
const scheduledStart = (() => {
if (showNow) return undefined;
if (!hasEvents) return undefined;
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
})();
const scheduledEnd = (() => {
if (showNow) return undefined;
if (!hasEvents) return undefined;
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
})();
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
@@ -107,7 +109,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
<BackstageClock />
<BackstageClock timeformat={timeformat} />
</div>
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
@@ -131,7 +133,10 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
{isOvertime(time.current) ? (
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
) : (
<SuperscriptTime time={formatTime(time.expectedFinish)} className='time-entry__value' />
<SuperscriptTime
time={formatTime(time.expectedFinish, { override: timeformat })}
className='time-entry__value'
/>
)}
</div>
<div className='timer-gap' />
@@ -213,12 +218,12 @@ function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
);
}
function BackstageClock() {
function BackstageClock({ timeformat }: { timeformat: string | null }) {
const { getLocalizedString } = useTranslation();
const clock = useAutoTickingClock();
// gather timer data
const formattedClock = formatTime(clock);
const formattedClock = formatTime(clock, { override: timeformat });
return (
<div className='clock-container'>
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import {
getTimeOption,
getTimeOptionsFromParams,
TimeOptions,
} from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import {
@@ -71,7 +75,7 @@ type BackstageOptions = {
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
extraInfo: string | null;
};
} & TimeOptions;
/**
* Utility extract the view options from URL Params
@@ -85,6 +89,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
extraInfo: getValue('extra-info'),
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
};
}
+2 -1
View File
@@ -101,6 +101,7 @@ export function getPropertyValue(
type FormattingOptions = {
removeSeconds: boolean;
removeLeadingZero: boolean;
clockFormat?: MaybeString;
};
export function getFormattedTimer(
@@ -114,7 +115,7 @@ export function getFormattedTimer(
}
if (timerType === TimerType.Clock) {
return formatTime(timer);
return formatTime(timer, { override: options?.clockFormat });
}
let timeToParse = timer;
@@ -139,11 +139,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
}
function CountdownClock() {
const { timeformat } = useCountdownOptions();
const { getLocalizedString } = useTranslation();
const clock = useAutoTickingClock();
// gather timer data
const formattedClock = formatTime(clock);
const formattedClock = formatTime(clock, { override: timeformat });
return (
<div className='clock-container'>
@@ -2,7 +2,11 @@ import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import {
getTimeOption,
getTimeOptionsFromParams,
TimeOptions,
} from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
@@ -91,7 +95,7 @@ type CountdownOptions = {
secondarySource: keyof OntimeEvent | null;
showExpected: boolean;
hidePast: boolean;
};
} & TimeOptions;
/**
* Utility extract the view options from URL Params
@@ -115,6 +119,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
showExpected: isStringBoolean(getValue('showExpected')),
hidePast: isStringBoolean(getValue('hidePast')),
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
};
}
@@ -14,3 +14,7 @@
'table';
color: $ui-white;
}
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure();
const entryActions = useEntryActions();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<EntryActionsProvider actions={entryActions}>
<EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal />
<EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
);
@@ -1,18 +1,40 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
</CuesheetDnd>
);
}
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { AppMode } from '../../../ontimeConfig';
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
describe('getEffectiveCuesheetMode()', () => {
it('honors the stored mode when the user can change mode on the current rundown', () => {
const permissions = { canChangeMode: true };
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
});
it('forces Run mode when the preset forbids mode changes', () => {
const permissions = { canChangeMode: false };
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
});
it('forces Edit mode for background rundowns without changing the permission policy', () => {
const permissions = { canChangeMode: true };
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
});
});
@@ -0,0 +1,21 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
});
it('keeps the stored selection when it still exists in the current project', () => {
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
});
});
@@ -1,11 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal() {
function EntryEditModal({ rundown }: EntryEditModalProps) {
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -19,7 +24,7 @@ function EntryEditModal() {
onClose={closeModal}
title='Edit entry'
showCloseButton
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
/>
);
}
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
@@ -14,21 +14,20 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss';
@@ -36,30 +35,41 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { data, status } = useFlatRundownWithMetadata();
const { updateEntry, updateTimer } = useEntryActionsContext();
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
const optionsStore = useOptions();
const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
@@ -70,7 +80,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
() => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
const event = flatRundown[rowIndex];
if (!event) {
return;
@@ -101,7 +111,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
hideIndexColumn,
},
}),
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
[cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
@@ -109,7 +119,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({
data,
data: flatRundown,
columns,
columnResizeMode: 'onChange',
state: {
@@ -137,13 +147,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return;
}
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) {
return;
}
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, data, selectedEventId]);
}, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => {
@@ -152,7 +162,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return;
}
const eventIndex = data.findIndex((event) => event.id === entryId);
const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
if (eventIndex === -1) {
return;
}
@@ -165,7 +175,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return () => {
setScrollHandler(null);
};
}, [data, setScrollHandler]);
}, [flatRundown, setScrollHandler]);
/**
* To improve performance on resizing, we memoise the column sizes
@@ -194,8 +204,9 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
listeners,
rows,
table,
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
}),
[columnSizeVars, cursor, listeners, rows, table],
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
);
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
@@ -217,7 +228,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
});
}, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !data || status === 'pending';
const isLoading = !flatRundown || status === 'pending';
if (isLoading) {
return <EmptyPage text='Loading...' />;
@@ -225,26 +236,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return (
<>
{tableRoot === 'editor' ? (
<EditorTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
) : (
<CuesheetTableSettings
columns={allLeafColumns}
cuesheetMode={cuesheetMode}
setCuesheetMode={setCuesheetMode}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<CuesheetTableHeaderToolbar
columns={allLeafColumns}
optionsStore={optionsStore}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
insertElement={insertElement}
modeControls={
tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/>
<TableVirtuoso
ref={virtuosoRef}
data={data}
data={flatRundown}
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey}
@@ -264,10 +276,13 @@ interface CuesheetVirtuosoContext {
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
handleAddNew?: (type: SupportedEntry) => void;
}
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
return <EmptyTableBody text='No data in rundown' />;
const EmptyPlaceholder = memo(function EmptyPlaceholder({
context,
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
});
const CuesheetTableElement = memo(function CuesheetTableElement({
@@ -49,6 +49,10 @@ function DurationInput({
setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
};
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback(
(newValue: string) => {
@@ -95,8 +99,10 @@ function DurationInput({
ref={inputRef}
initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/>
) : (
<TextLikeInput
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
}
};
if (!initialValue && readOnly) {
return null;
}
if (!initialValue) {
return (
<Input
variant='ghosted'
className={style.imageInput}
fluid
readOnly={readOnly}
// we disable the field to prevent receiving focus
disabled={readOnly}
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
@@ -1,7 +1,7 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, useMemo } from 'react';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -30,7 +30,8 @@ interface EventRowProps {
hasCursor?: boolean;
}
export default function EventRow({
export default memo(EventRow);
function EventRow({
rowId,
id,
eventIndex,
@@ -55,24 +56,25 @@ export default function EventRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
const { color, backgroundColor } = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
const accessible = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
const rowBgColour: string | undefined = useMemo(() => {
let rowBgColour: string | undefined;
if (isLoaded) {
return '#087A27'; // $active-green
rowBgColour = '#087A27'; // $active-green
} else if (colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
return colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
if (accessibleBg !== null) {
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
}
}
return;
return {
rowBgColour,
backgroundColor: accessible.backgroundColor,
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
};
}, [colour, isLoaded]);
return (
@@ -1,6 +1,6 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties } from 'react';
import { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -20,7 +20,8 @@ interface GroupRowProps {
hasCursor?: boolean;
}
export default function GroupRow({
export default memo(GroupRow);
function GroupRow({
groupId,
colour,
rowId,
@@ -1,7 +1,7 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties } from 'react';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -25,7 +25,8 @@ interface MilestoneRowProps {
hasCursor?: boolean;
}
export default function MilestoneRow({
export default memo(MilestoneRow);
function MilestoneRow({
entryId,
isPast,
parentBgColour,
@@ -45,17 +46,12 @@ export default function MilestoneRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
let rowBgColour: string | undefined;
if (colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
const rowBgColour = useMemo(() => {
if (!colour) return undefined;
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBg === null) return undefined;
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
}, [colour]);
return (
<tr
@@ -8,13 +8,24 @@ interface SingleLineCellProps {
fieldId?: string;
fieldLabel?: string;
allowSubmitSameValue?: boolean;
submitOnTab?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
handleTabCancel?: () => void;
}
const SingleLineCell = forwardRef(
(
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
{
initialValue,
fieldId,
fieldLabel,
allowSubmitSameValue,
submitOnTab,
handleUpdate,
handleCancelUpdate,
handleTabCancel,
}: SingleLineCellProps,
inputRef,
) => {
const ref = useRef<HTMLInputElement | null>(null);
@@ -25,7 +36,9 @@ const SingleLineCell = forwardRef(
allowKeyboardNavigation: true,
submitOnEnter: true, // single line should submit on enter
submitOnCtrlEnter: true,
submitOnTab,
onCancelUpdate: handleCancelUpdate,
onTabCancel: handleTabCancel,
});
// expose a subset of the methods to the parent
@@ -50,6 +50,10 @@ function TimeInputDuration({
setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
};
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback(
(newValue: string) => {
@@ -96,8 +100,10 @@ function TimeInputDuration({
ref={inputRef}
initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/>
) : (
<TextLikeInput
@@ -11,78 +11,92 @@ import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
type TableHeaderOptionsStore = {
hideTableSeconds: boolean;
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
setOption: <K extends keyof TableHeaderOptionValues>(key: K, value: TableHeaderOptionValues[K]) => void;
};
type TableHeaderOptionValues = Pick<
TableHeaderOptionsStore,
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
isCurrentRundown?: boolean;
};
export interface ViewSettingsProps {
optionsStore: CuesheetOptions;
}
export interface ColumnSettingsProps {
interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
insertElement?: ReactNode;
modeControls?: TableModeControls;
showShare?: boolean;
}
export default function CuesheetTableSettings({
export default function CuesheetTableHeaderToolbar({
columns,
cuesheetMode,
setCuesheetMode,
optionsStore,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: CuesheetTableSettingsProps) {
insertElement,
modeControls,
showShare = false,
}: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const options = usePersistedCuesheetOptions();
const toggleCuesheetMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0);
if (!newValue) return;
setCuesheetMode(newValue);
if (!newValue || !modeControls) return;
modeControls.setCuesheetMode(newValue);
};
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return (
<Toolbar.Root className={style.tableSettings}>
<ViewSettings optionsStore={options} />
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={optionsStore} />
<ColumnSettings
columns={columns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
{canChangeMode && (
<ToggleGroup
value={[cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={cx([style.group, style.apart])}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
{modeControls && canChangeMode && (
<div className={style.apart}>
{insertElement}
<ToggleGroup
value={[modeControls.cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={style.group}
disabled={!modeControls.isCurrentRundown}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
</div>
)}
{canShare && (
{showShare && canShare && (
<>
<Editor.Separator orientation='vertical' />
<CuesheetShareModal />
@@ -92,9 +106,18 @@ export default function CuesheetTableSettings({
);
}
export function ViewSettings({ optionsStore }: ViewSettingsProps) {
const options = optionsStore;
interface ViewSettingsProps {
optionsStore: TableHeaderOptionsStore;
}
interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function ViewSettings({ optionsStore }: ViewSettingsProps) {
return (
<Popover.Root>
<Popover.Trigger
@@ -115,15 +138,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideTableSeconds}
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
defaultChecked={optionsStore.hideTableSeconds}
onCheckedChange={(checked) => optionsStore.setOption('hideTableSeconds', checked)}
/>
Hide seconds in table
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideIndexColumn}
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
defaultChecked={optionsStore.hideIndexColumn}
onCheckedChange={(checked) => optionsStore.setOption('hideIndexColumn', checked)}
/>
Hide index column
</Editor.Label>
@@ -133,15 +156,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.showDelayedTimes}
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
defaultChecked={optionsStore.showDelayedTimes}
onCheckedChange={(checked) => optionsStore.setOption('showDelayedTimes', checked)}
/>
Show delayed times
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideDelays}
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
defaultChecked={optionsStore.hideDelays}
onCheckedChange={(checked) => optionsStore.setOption('hideDelays', checked)}
/>
Hide delay entries
</Editor.Label>
@@ -151,7 +174,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
);
}
export function ColumnSettings({
function ColumnSettings({
columns,
handleResetResizing,
handleResetReordering,
@@ -177,6 +200,7 @@ export function ColumnSettings({
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
@@ -8,6 +8,24 @@
gap: 1rem;
font-size: $inner-section-text-size;
border-radius: 3px 3px 0 0;
transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] {
background-color: rgba($ontime-color, 0.5);
.apart::before {
content: 'BACKGROUND EDIT';
color: $ui-white;
animation: blinker 2s ease-in-out infinite;
}
}
}
@keyframes blinker {
50% {
color: transparent;
}
}
.sectionTitle {
@@ -43,6 +61,9 @@
.apart {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
}
/** styles should match RundownHeader.module.scss */
@@ -70,12 +91,17 @@
font-size: calc(1rem - 2px);
font-weight: 600;
&[data-disabled] {
cursor: not-allowed;
opacity: 0.4;
}
&:focus-visible {
background: transparent;
outline: 1px solid $blue-500;
}
&:hover:not(:disabled):not(:active) {
&:hover:not([data-disabled]):not(:active) {
background: $gray-1000;
}
@@ -87,7 +113,7 @@
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
&:hover:not([data-disabled]):not(:active) {
background: $blue-600;
}
}
@@ -5,12 +5,33 @@ import { useCallback, useEffect, useMemo } from 'react';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
import type { CuesheetPermissions } from './useTablePermissions';
import { useCuesheetPermissions } from './useTablePermissions';
type CuesheetPolicyOptions = {
// Run mode is only meaningful for the loaded rundown — background rundowns force Edit.
canRunMode: boolean;
};
export function getEffectiveCuesheetMode(
permissions: Pick<CuesheetPermissions, 'canChangeMode'>,
storedMode: AppMode,
{ canRunMode }: CuesheetPolicyOptions,
) {
if (!permissions.canChangeMode) {
return AppMode.Run;
}
return canRunMode ? storedMode : AppMode.Edit;
}
/**
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
*/
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
export function useApplyCuesheetPolicy(
preset: URLPreset | undefined,
{ canRunMode }: CuesheetPolicyOptions,
): {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
} {
@@ -26,16 +47,14 @@ export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
const cuesheetMode = getEffectiveCuesheetMode(permissions, storedCuesheetMode, { canRunMode });
const setCuesheetMode = useCallback(
(mode: AppMode) => {
if (!permissions.canChangeMode) {
return;
}
if (!permissions.canChangeMode || !canRunMode) return;
setStoredCuesheetMode(mode);
},
[permissions.canChangeMode, setStoredCuesheetMode],
[canRunMode, permissions.canChangeMode, setStoredCuesheetMode],
);
// Keep the shared permissions store aligned with the active preset policy.
@@ -0,0 +1,65 @@
import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
});
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
const { loaded: loadedRundownId } = projectRundowns;
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
return {
loadedRundownId,
selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
setStoredSelectedRundownId(rundownId);
});
},
};
}
export function useDirectLinkToBackgroundEdit() {
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
async (rundownId: string) => {
await navigate('/cuesheet');
startTransition(() => setStoredSelectedRundownId(rundownId));
},
[setStoredSelectedRundownId, navigate],
);
}
@@ -80,6 +80,7 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
@@ -45,6 +45,9 @@ $studio-idle: $red-1300;
top: 0;
top: 50%;
left: 50%;
margin-top: -0.5rem;
margin-left: -0.5rem;
}
.tick--active {
+7 -4
View File
@@ -6,6 +6,7 @@ import { useStudioClockSocket } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import { formatTime } from '../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { useStudioOptions } from './studio.options';
import { getLargeClockData } from './studioClock.utils';
import './StudioClock.scss';
@@ -18,6 +19,7 @@ interface StudioClockProps {
}
export default function StudioClock({ hideCards }: StudioClockProps) {
const { timeformat } = useStudioOptions();
const isSmallScreen = useIsSmallScreen();
const clock = useAutoTickingClock();
const { playback } = useStudioClockSocket();
@@ -25,10 +27,10 @@ export default function StudioClock({ hideCards }: StudioClockProps) {
// if we are on mobile and have to show the cards
if (isSmallScreen && !hideCards) {
return <StudioClockMobile clock={clock} onAir={onAir} />;
return <StudioClockMobile clock={clock} onAir={onAir} timeformat={timeformat} />;
}
const { seconds, display, meridian } = getLargeClockData(clock);
const { seconds, display, meridian } = getLargeClockData(clock, timeformat);
return (
<div className='studio__clock'>
@@ -62,10 +64,11 @@ export default function StudioClock({ hideCards }: StudioClockProps) {
interface StudioClockMobileProps {
clock: number;
onAir: boolean;
timeformat: string | null;
}
function StudioClockMobile({ clock, onAir }: StudioClockMobileProps) {
const displayClock = formatTime(clock);
function StudioClockMobile({ clock, onAir, timeformat }: StudioClockMobileProps) {
const displayClock = formatTime(clock, { override: timeformat });
return (
<div className='studio__clock studio__clock--small'>
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import {
getTimeOption,
getTimeOptionsFromParams,
TimeOptions,
} from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
@@ -47,7 +51,7 @@ export const getStudioOptions = (timeFormat: string, customFields: CustomFields)
type StudioOptions = {
mainSource: keyof OntimeEvent | null;
hideCards: boolean;
};
} & TimeOptions;
/**
* Utility extract the view options from URL Params
@@ -60,6 +64,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
hideCards: isStringBoolean(getValue('hideCards')),
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
};
}
@@ -5,9 +5,9 @@ import { formatTime } from '../../common/utils/time';
/**
* Gathers display elements for the large studio clock
*/
export function getLargeClockData(clock: number) {
export function getLargeClockData(clock: number, timeformat: string | null) {
const [display, meridian] = (() => {
const formatted = formatTime(clock);
const formatted = formatTime(clock, { override: timeformat });
if (formatted.endsWith('AM')) {
return [formatted.slice(0, -2), 'AM'];
}
@@ -37,7 +37,7 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId();
const { mainSource } = useTimelineOptions();
const { mainSource, timeformat } = useTimelineOptions();
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -56,7 +56,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
<TimelineClock />
<TimelineClock timeformat={timeformat} />
</div>
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
@@ -71,12 +71,12 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
);
}
function TimelineClock() {
function TimelineClock({ timeformat }: { timeformat: string | null }) {
const { getLocalizedString } = useTranslation();
const clock = useAutoTickingClock();
// gather timer data
const formattedClock = formatTime(clock);
const formattedClock = formatTime(clock, { override: timeformat });
return (
<div className='clock-container'>
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import {
getTimeOption,
getTimeOptionsFromParams,
TimeOptions,
} from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
@@ -55,7 +59,7 @@ type TimelineOptions = {
mainSource: keyof OntimeEvent | null;
hidePast: boolean;
fixedSize: boolean;
};
} & TimeOptions;
/**
* Utility extract the view options from URL Params
@@ -69,6 +73,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
mainSource: getValue('main') as keyof OntimeEvent | null,
hidePast: isStringBoolean(getValue('hidePast')),
fixedSize: isStringBoolean(getValue('fixedSize')),
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
};
}
+6 -4
View File
@@ -1,4 +1,4 @@
import { OntimeView, TimerType } from 'ontime-types';
import { MaybeString, OntimeView, TimerType } from 'ontime-types';
import { useMemo } from 'react';
import { FitText } from '../../common/components/fit-text/FitText';
@@ -69,6 +69,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
font,
keyColour,
timerColour,
timeformat,
} = useTimerOptions();
const { getLocalizedString } = useTranslation();
@@ -106,6 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
clockFormat: timeformat,
});
const currentAux = (() => {
@@ -164,7 +166,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
</div>
)}
{showClock && <TimerAutoTickingClock />}
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{showEndMessage ? (
@@ -212,9 +214,9 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
);
}
function TimerAutoTickingClock() {
function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
const autoTickingClock = useAutoTickingClock();
const formattedClock = formatTime(autoTickingClock);
const formattedClock = formatTime(autoTickingClock, { override: clockFormat });
const { getLocalizedString } = useTranslation();
return (
+4 -1
View File
@@ -6,8 +6,10 @@ import { useSearchParams } from 'react-router';
import type { SelectOption } from '../../common/components/select/Select';
import {
getTimeOption,
getTimeOptionsFromParams,
hideTimerSeconds,
showLeadingZeros,
TimeOptions,
} from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
@@ -194,7 +196,7 @@ type TimerOptions = {
font?: string;
keyColour?: string;
timerColour?: string;
};
} & TimeOptions;
/**
* Utility extract the view options from URL Params
@@ -229,6 +231,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
timerColour: makeColourString(getValue('timerColour')),
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
};
}
+5 -1
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import legacy from '@vitejs/plugin-legacy';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2';
@@ -21,6 +22,9 @@ export default defineConfig({
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
},
plugins: [
legacy({
targets: ['safari >= 14.1'],
}),
react({
babel: {
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
@@ -83,7 +87,7 @@ export default defineConfig({
},
test: {
globals: true,
environment: 'jsdom',
dom: true,
},
build: {
outDir: './build',

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