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

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