Compare commits

...

150 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
Carlos Valente 176e53dd7f refactor: scope electron build job 2026-04-06 21:49:12 +02:00
Carlos Valente 6a31d06c02 bump version to 4.7.0 2026-04-06 21:19:34 +02:00
Carlos Valente f739ba122b refactor: improve sheet flow management 2026-04-06 21:19:23 +02:00
Carlos Valente f53cb687bf refactor: improve sheet import UX 2026-04-06 21:19:23 +02:00
Carlos Valente 42dcf35f45 refactor: extract metadata from sheet 2026-04-06 21:19:23 +02:00
Carlos Valente 37dc9a620d feat: keep clock ticking on server drops 2026-04-06 20:17:05 +02:00
Brian Rodgers 9e771a2f24 refactor: format times in editor based on project time format 2026-04-06 19:50:41 +02:00
alex-arc b19e9d23ba feat: show current rundown in project info page
feat: swap project description for rundown title in editor and cuesheet overview
2026-04-06 19:50:08 +02:00
Carlos Valente 7e3a9d634b refactor: vertical titles for small timeline area 2026-04-06 19:49:30 +02:00
Carlos Valente 8c14c330f5 refactor: cache is rundown aware 2026-04-04 14:58:58 +02:00
Alex Christoffer Rasmussen 3022ec2acd feat: wide modal for cuesheet share and css editor (#2051) 2026-04-02 17:16:52 +02:00
Carlos Valente cb9528d894 refactor: create wide modal variant 2026-03-30 22:22:24 +02:00
Carlos Valente 97cb41dc5a refactor: composable info element 2026-03-30 22:22:24 +02:00
Carlos Valente 8985c8604f feat: create autocomplete component 2026-03-30 22:22:24 +02:00
Carlos Valente 2f95aa0c4f chore: update base-ui 2026-03-30 22:22:24 +02:00
Carlos Valente 5d0e96163f chore: update react hook form 2026-03-30 22:22:24 +02:00
Carlos Valente 5e98fdb2e9 fix: sanitise project data on load 2026-03-29 20:22:24 +02:00
Carlos Valente 23298bbe85 fix: resolve path to public assets in cloud 2026-03-28 17:15:46 +01:00
Carlos Valente cc48ac8fd3 fix: start does not reflow when playback starts 2026-03-27 21:27:49 +01:00
Carlos Valente 2d9909577e refactor: add confirmation dialogs for destructive actions 2026-03-27 19:40:24 +01:00
Carlos Valente 3838bdc54d fix: prevent hide index column from leaking between cuesheet and editor 2026-03-27 19:37:57 +01:00
Alex Christoffer Rasmussen c7d8b137ca chore: tooling (#2040)
* chore: only run release action if we have a tag

* chore: typescript 6 is out of beta

* chore: update flaky test

* chore: update pnpm

* chore: update playwright and node types

* chore: migrate tsup to tsdown

* chore: update node to node 22.22.2(LTS)

* chore: fix lockfile

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-27 19:31:41 +01:00
Carlos Valente 3a0df0b491 refactor: introduce codemirror for CSS editor (#2039)
* refactor: introduce codemirror for CSS editor

* chore: fix formatting
2026-03-26 15:25:32 +01:00
Carlos Valente 2331205c93 chore: demo parallel rundowns 2026-03-25 18:42:23 +01:00
Carlos Valente 224d3600b2 fix: prevent race conditions on build (#2036)
* fix: prevent race conditions on build

* refactor: upload artifacts and keep for 1 day

* chore: remove deprecated settings

* Revert "refactor: icon compatibility with macos tahoe"

This reverts commit a7a78702b1.
2026-03-25 11:03:41 +01:00
Carlos Valente 7d469038bf fix: enable cjs interop while we migrate packages (#2034) 2026-03-25 10:49:43 +01:00
Carlos Valente 1c78397f18 chore: bump version to 4.6.0 (#2023) 2026-03-24 11:13:44 +01:00
Alex Christoffer Rasmussen 73eff20d08 chore: format (#2030)
* chore: format

* chore: add formatter in CI check

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-24 09:49:22 +01:00
Carlos Valente 7e3aaa8c30 feat: UI access for custom views (#2020)
* style: consistent casing on menu

* refactor: bundle demo from code

* feat: allow uploading custom views
2026-03-24 09:19:45 +01:00
Carlos Valente 95536902f5 feat: Improve automation flow and add ontime playback actions (#2025) (#2024)
* fix: prevent add filter from submitting form

* refactor: event clarifies whether automations exist but are disabled

* refactor: improve readability of automation form

* refactor: add affordance for field warnings

* refactor: improve visibility of automation off state

* refactor: apply warning styles to other feature toggles

* Adding playback actions to automations (#2024)

* adding intial automation actions

* cleaning up

* fixing formatting

* ran oxfmt

* switching action names to playback- to match the dropdown strings

* fixing flicker that was caused by scroll arrows gettting unmounted

---------

Co-authored-by: Cameron Slipp <cdslipp@gmail.com>
2026-03-24 09:13:37 +01:00
Alex Christoffer Rasmussen cf206e6220 feat: Vite 8 (#2018)
* chore: upgrade vite

* fix: inconsistentCjsInterop for ReactQR

* chore: e2e tests should use relativve path and get host from playwright config

* refactor: drop qr code dependency

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-24 09:11:57 +01:00
Alex Christoffer Rasmussen 4662cc8a26 feat: improve smart time entry (#2029)
Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-23 21:23:36 +01:00
Cameron Slipp 8c52556cc0 chore: oxc info to DEVELOPMENT.md (#2027)
* Adding info on formatting and linting

* fixing md links
2026-03-23 15:57:14 +01:00
Carlos Valente b1da877dc7 refactor: consistent style for warning 2026-03-22 20:47:00 +01:00
Carlos Valente d8cef11a25 fix: server port settings has lifecycle 2026-03-22 20:47:00 +01:00
Carlos Valente ef5c0a371e refactor: improve app quit in windows 2026-03-21 18:50:17 +01:00
Carlos Valente b2b7855114 refactor: improve css editor loading suspense 2026-03-21 09:38:02 +01:00
Carlos Valente 129efc33b9 chore: upgrade prism dependencies 2026-03-21 09:38:02 +01:00
Joel Wetzell 6a40c66a7b upgrade vite and related dependencies (#2003) 2026-03-20 20:04:39 +01:00
Carlos Valente 2806177c72 fix: prevent wrapping in time inputs 2026-03-17 21:21:34 +01:00
Carlos Valente 4408bdb0d3 refactor: improve shutdown cleanup 2026-03-17 20:11:45 +01:00
Alex Christoffer Rasmussen 871a6b46c8 feat: automatic refetch css override (#1588)
* feat: send refect on css change

* feat: revamp ViewLoader

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-17 20:08:33 +01:00
Carlos Valente fd39cab845 fix: prevent sheet name too long in excel export 2026-03-16 19:05:32 +01:00
Carlos Valente 1cee679397 fix: pending roll sets group expected times as due 2026-03-14 08:43:22 +01:00
Carlos Valente a7a78702b1 refactor: icon compatibility with macos tahoe 2026-03-13 08:10:48 +01:00
Carlos Valente 26d887c1a6 fix: prevent issues with midnight and day recognition on dst kick in 2026-03-11 07:20:42 +01:00
Alex Christoffer Rasmussen ceb490c60a fix: edit custom fields with type image (#1997) 2026-03-08 19:31:00 +01:00
Carlos Valente 1849b4d39f Deps migration (#1988)
* chore: migrate eslint to oxlint

* chore: migrate prettier to oxfmt

* chore: migrate typescript

* chore: toThrow should have a expected value

* chore: cast test value as Day

* chore: small title fix

* chore: mocks should be hoisted

* chore: incorrect async useage

* chore: test should be inside description

* chore: test sohuld include an expeced

* chore: oxfmt

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-08 16:22:12 +01:00
alex-Arc 9516ac21bb fix: utf-8 from project file 2026-03-07 08:25:34 +01:00
alex-Arc 0e173137e8 add build-in translations 2026-03-07 08:25:23 +01:00
Joel Wetzell 1202ef3e80 add translation keys for timer message labels 2026-03-07 08:25:23 +01:00
Carlos Valente 2ef0b81743 refactor: extract midnight cross rules 2026-03-03 20:48:53 +01:00
Carlos Valente 5a5450d7ef refactor: use Day type in client 2026-03-03 20:48:53 +01:00
Carlos Valente 6f1f10ccff fix: backdate rundown start when rolling 2026-03-03 20:48:53 +01:00
Carlos Valente 5ff7861968 fix: use rundown day for calculations 2026-03-03 20:48:53 +01:00
Carlos Valente f1a1c358bc fix: infer day offset from epoch 2026-03-03 20:48:53 +01:00
Carlos Valente efd19353e9 fix: roll resets day 2026-03-03 20:48:53 +01:00
Carlos Valente a29a493678 fix: add current day when ticking over midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 27b179276d fix: skip handles events across midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 155cf48a17 fix: roll loads events that cross midnight 2026-03-03 20:48:53 +01:00
Alex Christoffer Rasmussen 358ad79ae4 Automation should use event store for data (#1962)
* fix: automation should use RuntimeStore for data

* add: aux timer template help
2026-03-02 20:54:46 +01:00
Alex Christoffer Rasmussen c1fcdf7065 Remove serverport from project file (#1957)
* feat: get server port from app satate or env

optional startup port from env will always override

function for parsing port from env

populate default port in app state

add test for migration

* bump version
2026-03-02 06:02:29 -08:00
Carlos Valente 1fe58e21be fix: onStart triggered by pending roll start 2026-02-28 10:32:19 +01:00
Carlos Valente 3c0e7ba4d5 chore: fix import across packages 2026-02-27 20:32:02 +01:00
Carlos Valente e8f159d894 feat: create branded types to determine different handling of time 2026-02-27 20:32:02 +01:00
Carlos Valente 168d7103b1 fix: prevent settings reflow on small width screens 2026-02-23 20:20:07 +01:00
Carlos Valente 63d7250a70 refactor: consistent focus styling 2026-02-23 20:19:49 +01:00
Carlos Valente 88f21e2186 refactor: improve timer styling in playback controls 2026-02-23 20:19:49 +01:00
Carlos Valente d9a18b0c42 fix: path comparison considers core params 2026-02-23 15:23:49 +01:00
Carlos Valente df588b8845 refactor: extract cuesheet permissions 2026-02-23 15:23:49 +01:00
Carlos Valente 5be504ce8f fix: prevent presets losing params 2026-02-23 15:23:49 +01:00
Carlos Valente 6268e327b9 refactor: mask cuesheet presets 2026-02-23 15:23:49 +01:00
Carlos Valente 1c81b3a23c refactor: hide edit button for users with no write permissions 2026-02-23 15:23:49 +01:00
Carlos Valente d28895ce9c refactor: prevent import map collision across stages 2026-02-23 15:22:56 +01:00
Carlos Valente 1a23287863 refactor: osc disabled by default to prevent collisions 2026-02-17 21:23:34 +01:00
Alex Christoffer Rasmussen 0b3ba65df3 feat: allow existing custom fields to keep its type on excel import (#1976) 2026-02-17 07:59:27 +08:00
Carlos Valente c3a7cd6a39 bump version to 4.4.2 2026-02-16 06:58:08 +01:00
Carlos Valente 39e6c15adf fix: hide now and next cards if there is no content 2026-02-16 06:58:08 +01:00
Carlos Valente aec6673b07 refactor: improve connection handling
- only invalidate caches on reconnect
- add jitter to avoid overwhelming server and allow cache to stabilise
- prevent multiple reconnect timers
2026-02-14 12:08:04 +01:00
Carlos Valente 9e8cb1c755 refactor: improve request cancellation and timeout handling 2026-02-14 12:08:04 +01:00
Carlos Valente cdf02f7c3f bump version to 4.4.1 2026-02-14 12:07:45 +01:00
Carlos Valente 77779a4648 refactor: debounce writing to disk 2026-02-14 09:30:24 +01:00
Carlos Valente e16ec3f388 refactor: prevent suspending page on first add 2026-02-14 08:56:16 +01:00
Carlos Valente 63cfc9ec19 refactor: optimistic add elements in UI 2026-02-14 08:56:16 +01:00
Carlos Valente 4827117163 refactor: extract entry creation logic 2026-02-14 08:56:16 +01:00
Carlos Valente 5777ef3532 refactor: extract parent resolution 2026-02-14 08:56:16 +01:00
Carlos Valente 80a3b04493 refactor: export rundown placement logic 2026-02-14 08:56:16 +01:00
Carlos Valente 7c5fdd3b19 refactor: remove unused exports 2026-02-14 08:56:16 +01:00
Carlos Valente 31fd8e5200 fix: prevent infinite render in settings forms 2026-02-13 21:12:29 +01:00
702 changed files with 21190 additions and 12541 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# Ignore config files
.prettier*
# Ignore formatter config files
.oxfmt*
# Ignore install stuff
**/yarn.lock
-2
View File
@@ -1,2 +0,0 @@
"ONTIME_VERSION.js"
dist/
-53
View File
@@ -1,53 +0,0 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020
},
"env": {
"es6": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"overrides": [
{
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
}
],
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prettier/prettier": [
"warn",
{
"endOfLine": "auto"
}
]
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
github: cpvalente
buy_me_a_coffee: cpvalente
buy_me_a_coffee: cpvalente
+3 -3
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
@@ -20,13 +20,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+4 -4
View File
@@ -12,16 +12,16 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -64,4 +64,4 @@ jobs:
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+57 -26
View File
@@ -7,19 +7,23 @@ on:
jobs:
build_macos:
runs-on: macOS-latest
runs-on: macOS-26
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -29,11 +33,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
@@ -44,8 +43,19 @@ jobs:
run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60
- name: Upload macOS build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-macos-build
path: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
@@ -57,24 +67,34 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Upload Windows build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-windows-build
path: |
./apps/electron/dist/ontime-win64.exe
./apps/electron/dist/win-unpacked/
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
@@ -84,24 +104,35 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Upload Linux build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-linux-build
path: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
+3 -3
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
@@ -18,13 +18,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+22 -11
View File
@@ -13,16 +13,20 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -33,6 +37,9 @@ jobs:
run: pnpm install --frozen-lockfile
# Run code quality
- name: Run formatter
run: pnpm format:check
- name: Run linter
run: pnpm lint
@@ -49,16 +56,20 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -71,30 +82,30 @@ jobs:
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
- run: pnpm playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: pnpm playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: automated-screenshots
path: automated-screenshots/
+6 -1
View File
@@ -33,10 +33,12 @@ e2e/tests/fixtures/tmp/*
# production
build/
dist/
*.tsbuildinfo
# bundled assets
translations.json
override.css
**/external/demo/index.html
# working stuff
**/TODO.md
@@ -51,4 +53,7 @@ ontime-data/
**/ONTIME_VERSION.js
# temporary write files
**.tmp
**.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.15.1
v22.22.3
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120,
"sortPackageJson": false,
"sortImports": {},
"ignorePatterns": [
"build",
"coverage",
"dist",
"node_modules",
"playwright-report",
"pnpm-lock.yaml",
"**/*.toml",
"**/*.json",
"!tsconfig.common.json",
"!turbo.json",
"!package.json"
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn",
"suspicious": "off",
"pedantic": "off",
"perf": "warn",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"caughtErrors": "none"
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
]
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"apps/client/build/**",
"apps/electron/assets/**",
"**/ONTIME_VERSION.js"
]
}
-14
View File
@@ -1,14 +0,0 @@
build
coverage
dist
node_modules
playwright-report
pnpm-lock.yaml
**/*.toml
**/*.json
!tsconfig.common.json
!turbo.json
!package.json
-9
View File
@@ -1,9 +0,0 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+31 -22
View File
@@ -2,16 +2,16 @@
Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
- **client**: A React app for Ontime's UI and web clients
- **electron**: An electron app which facilitates the cross-platform distribution of Ontime
- **server**: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~22)
- __pnpm__ (~10)
- __docker__ (only necessary to run and build docker images)
- **node** (~22)
- **pnpm** (~10)
- **docker** (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -20,10 +20,10 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
- **Install the project dependencies** by running `pnpm i`
- **Create a local build** by running `pnpm build`, this will populate local dependencies
- **Run dev mode** by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend
@@ -31,8 +31,9 @@ The previous command will start the development servers for both the client, ser
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
- **Run the React UI** by running `pnpm dev --filter=ontime-ui`
- **Run the nodejs server** by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
@@ -77,9 +78,9 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `pnpm build`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
- **Install the project dependencies** by running `pnpm i`
- **Build the UI and server** by running `pnpm build`
- **Create the package** by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
The build distribution assets will be at `.apps/electron/dist`
@@ -90,23 +91,31 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d`
- **Build docker image from** by running `docker build -t getontime/ontime .`
- **Run docker image from compose** by running `docker-compose up -d`
Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
- **List running processes** by running `docker ps`
- **Kill running process** by running `docker kill <process-id>`
## FORMATTING AND LINTING
Ontime uses [oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) for formatting and [oxlint](https://oxc.rs/docs/guide/usage/linter.html) for linting. Look for an [oxc extension](https://oxc.rs/docs/guide/usage/linter/editors) for your editor of choice.
- **Run formatter** by running `pnpm format`
- **Run linter** by running `pnpm lint`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
- [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
- [ ] You have ran code formatting and linting in all your changes
- [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
- [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.15.1
ARG NODE_VERSION=22.22.2
FROM node:${NODE_VERSION}-alpine
# Set environment variables
+7 -1
View File
@@ -7,6 +7,7 @@
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
@@ -17,14 +18,17 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -43,6 +47,7 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship
## Main features
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
@@ -111,7 +116,8 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
- [Ontime website](https://getontime.no)
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
+5
View File
@@ -1,19 +1,24 @@
# Terms of sponsorship
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
## Why sponsor
Ontime is and will remain free, regardless of your decision to contribute.
Regardless, you may choose to provide a monetary contribution:
### Improve user experience
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
### Cool stuff
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
### Priorities
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
Additional funding will be considered according to the needs at the time.
-16
View File
@@ -1,16 +0,0 @@
{
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"plugins": [],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "vitest"]
}
-9
View File
@@ -1,9 +0,0 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+9 -1
View File
@@ -1,6 +1,7 @@
# Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers.
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
@@ -10,33 +11,40 @@ The CLI tool is a fast and lightweight way of installing Ontime and is perfect f
## Getting started
### Prerequisites
You will need to have installed the correct version of node.js
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system
Install globally in your system
```bash
npm install -g @getontime/cli
```
... and run using the installed script
```bash
ontime
```
Or install and run ontime (the installation here is temporary for the duration of the session)
```bash
npx install @getontime/cli
```
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
+21 -26
View File
@@ -1,27 +1,22 @@
{
"name": "@getontime/cli",
"version": "4.4.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
],
"devDependencies": {
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
}
}
"name": "@getontime/cli",
"version": "4.9.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
]
}
-2
View File
@@ -1,2 +0,0 @@
node_modules
build
-142
View File
@@ -1,142 +0,0 @@
{
"settings": {
"react": {
"version": "detect"
}
},
"env": {
"browser": true,
"node": true
},
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"plugins": [
"react",
"react-compiler",
"simple-import-sort",
"@tanstack/query"
],
"rules": {
"react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [
"error",
{
"ignoreRefs": true,
"allowArrowFunctions": true,
"allowFunctions": false,
"allowBind": false,
"ignoreDOMComponents": true
}
],
"react/jsx-boolean-value": [
"error",
"never",
{
"always": []
}
],
"react/jsx-handler-names": [
"off",
{
"eventHandlerPrefix": "handle",
"eventHandlerPropPrefix": "on"
}
],
"react/no-danger": "warn",
"react/no-deprecated": [
"error"
],
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-undef": "error",
"react/jsx-closing-tag-location": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-unescaped-entities": "error",
"react/jsx-tag-spacing": [
"error",
{
"closingSlash": "never",
"beforeSelfClosing": "always",
"afterOpening": "never",
"beforeClosing": "never"
}
],
"react/jsx-space-before-closing": [
"off",
"always"
],
"react/jsx-curly-brace-presence": [
"error",
{
"props": "never",
"children": "never"
}
],
"react/destructuring-assignment": [
"error",
"always"
],
"react/no-unsafe": "off",
"react/prop-types": [
"error",
{
"ignore": [],
"customValidators": [],
"skipUndeclared": true
}
],
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
},
"overrides": [
// override "simple-import-sort" config
{
"files": [
"*.js",
"*.jsx",
"*.ts",
"*.tsx"
],
"rules": {
"simple-import-sort/imports": [
"error",
{
"groups": [
// Packages `react` related packages come first.
[
"^react",
"^@?\\w"
],
// Internal packages.
[
"^(@|components)(/.*|$)"
],
// Side effect imports.
[
"^\\u0000"
],
// Parent imports. Put `..` last.
[
"^\\.\\.(?!/?$)",
"^\\.\\./?$"
],
// Other relative imports. Put same-folder imports and `.` last.
[
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$"
],
// Style imports.
[
"^.+\\.?(css)$"
]
]
}
]
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "react", "vitest"]
}
-9
View File
@@ -1,9 +0,0 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+14 -6
View File
@@ -3,20 +3,28 @@
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
var b = document.querySelector('base');
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
window.location.replace(base + '/timer-legacy' + window.location.search);
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+33 -41
View File
@@ -1,44 +1,46 @@
{
"name": "ontime-ui",
"version": "4.4.0",
"version": "4.9.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.0.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"axios": "^1.18.0",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.9"
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"lint": "oxlint --quiet --type-aware",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"build": "vite build",
"test": "vitest",
"test:pipeline": "vitest run",
@@ -48,7 +50,8 @@
"production": [
">0.2%",
"not dead",
"not op_mini all"
"not op_mini all",
"safari >= 14.1"
],
"development": [
"last 1 chrome version",
@@ -57,32 +60,21 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"@vitejs/plugin-legacy": "^8.0.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^20.9.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
}
+3 -3
View File
@@ -11,9 +11,9 @@
"type": "image/png"
}
],
"scope": "/",
"start_url": "/",
"scope": "./",
"start_url": "./",
"display": "",
"theme_color": "#121212",
"background_color": "#ffffff"
"background_color": "#101010"
}
+2 -4
View File
@@ -1,10 +1,8 @@
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
+5 -3
View File
@@ -1,17 +1,19 @@
import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router';
// apply global axios config defaults
import './common/api/axios.config';
import AppRouter from './AppRouter';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import { baseURI } from './externals';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
+5 -5
View File
@@ -1,18 +1,18 @@
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext';
import { useClientPath } from './common/hooks/useClientPath';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
import { getRouteFromPreset } from './common/utils/urlPresets';
import { getIsNavigationLocked, sessionScope } from './externals';
import Log from './features/log/Log';
import { initializeSentry } from './sentry.config';
import Loader from './views/common/loader/Loader';
import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader';
import { getIsNavigationLocked, sessionScope } from './externals';
import { initializeSentry } from './sentry.config';
const Timer = lazy(() => import('./views/timer/Timer'));
const Countdown = lazy(() => import('./views/countdown/Countdown'));
+6 -6
View File
@@ -2,16 +2,16 @@ import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import { TRANSLATION, apiEntryUrl, customTranslationsURL } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data;
}
@@ -35,8 +35,8 @@ export async function restoreCSSContents(): Promise<string> {
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data;
}
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
return status >= 200 && status < 300;
};
axios.defaults.timeout = axiosConfig.shortTimeout;
+5 -2
View File
@@ -3,16 +3,21 @@ import { serverURL } from '../../externals';
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_SERVER_PORT = ['appServerPort'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const CUSTOM_VIEWS = ['customViews'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
@@ -21,9 +26,7 @@ export const TRANSLATION = ['translation'];
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data;
}
+58
View File
@@ -0,0 +1,58 @@
import axios from 'axios';
import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const customViewsPath = `${apiEntryUrl}/custom-views`;
export async function getCustomViews(options?: RequestOptions): Promise<CustomViewsListResponse> {
const response = await axios.get<CustomViewsListResponse>(customViewsPath, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.shortTimeout,
});
return response.data;
}
export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise<MessageResponse> {
const formData = new FormData();
formData.append('indexHtml', file);
return (
await axios.post<MessageResponse>(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
})
).data;
}
export async function downloadCustomView(slug: string, options?: RequestOptions): Promise<void> {
const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
downloadBlob(response.data, `${slug}-index.html`);
}
export async function restoreDemoView(options?: RequestOptions): Promise<MessageResponse> {
return (
await axios.post<MessageResponse>(`${customViewsPath}/restore-demo`, null, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
})
).data;
}
export async function deleteCustomView(slug: string, options?: RequestOptions): Promise<void> {
await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
});
}
+9 -3
View File
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename });
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
}
/**
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
+45 -14
View File
@@ -1,20 +1,28 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
* Uploads an Excel file and returns worksheet names plus metadata for the initial worksheet.
*/
export async function upload(file: File): Promise<string[]> {
export async function upload(file: File, requestOptions?: RequestOptions): Promise<SpreadsheetWorksheetOptions> {
const formData = new FormData();
formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, {
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -22,24 +30,47 @@ export async function upload(file: File): Promise<string[]> {
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
export async function importRundownPreview(
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<SpreadsheetPreviewResponse> {
const response: AxiosResponse<SpreadsheetPreviewResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
export async function getWorksheetMetadata(
worksheet: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetMetadata> {
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
`${excelPath}/metadata`,
{ worksheet },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
+3 -2
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
@@ -10,8 +11,8 @@ export type HasUpdate = {
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+11 -4
View File
@@ -2,22 +2,28 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
/**
* HTTP request to mutate project data
*/
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
export async function postProjectData(data: ProjectData): Promise<ProjectData> {
const response = await axios.post(projectPath, data);
return response.data;
}
/**
@@ -27,6 +33,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
+4 -4
View File
@@ -2,16 +2,16 @@ import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
import { REPORT, apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
@@ -0,0 +1,4 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -0,0 +1,8 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+43 -16
View File
@@ -1,7 +1,16 @@
import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import {
EntryId,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
RenumberCues,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
@@ -11,33 +20,31 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
}
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
/**
* HTTP request to fetch all entries in the given rundown
*/
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
}
/**
@@ -110,6 +117,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
/**
* HTTP request to renumber cues for multiple events
*/
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
}
export type ReorderEntry = {
entryId: EntryId;
destinationId: EntryId;
@@ -181,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
}
// #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data;
}
+19 -3
View File
@@ -1,15 +1,16 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { PortInfo, Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data;
}
@@ -26,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(options?: RequestOptions): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
+68 -11
View File
@@ -1,8 +1,15 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import type {
AuthenticationStatus,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +30,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
@@ -31,6 +39,8 @@ export const requestConnection = async (
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -53,24 +63,71 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
requestOptions?: RequestOptions,
): Promise<SpreadsheetPreviewResponse> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
/**
* Fetches derived metadata for a single worksheet by reading its rows and detecting headers.
*/
export const getWorksheetMetadata = async (
sheetId: string,
worksheet: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetMetadata> => {
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
`${sheetsPath}/${sheetId}/metadata`,
{ worksheet },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* Fetches the available worksheets for a Google Sheet. Metadata is loaded lazily per worksheet.
*/
export const getWorksheetOptions = async (
sheetId: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetOptions> => {
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(
`${sheetsPath}/${sheetId}/worksheet-options`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
export const uploadRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data;
}
@@ -0,0 +1,61 @@
.positioner {
outline: 0;
}
.popup {
font-size: 1rem;
box-sizing: border-box;
padding: 2px;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
color: $ui-white;
&[data-empty][data-hide-empty] {
display: none;
}
}
.list {
box-sizing: border-box;
max-height: 20rem;
max-height: var(--available-height);
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
scroll-padding-block: 1.5rem;
outline: 0;
}
.item,
.empty:not(:empty) {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
line-height: 1rem;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: break-word;
&[data-highlighted] {
background-color: $blue-700;
}
}
.itemLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.empty {
color: $gray-400;
}
@@ -0,0 +1,121 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ReactNode, Ref } from 'react';
import { useEffect, useRef, useState } from 'react';
import { cx } from '../../utils/styleUtils';
import type { InputProps } from '../input/input/Input';
import { getScrollParent } from './autocompleteInput.utils';
import inputStyles from '../input/input/Input.module.scss';
import styles from './AutocompleteInput.module.scss';
export interface AutocompleteInputProps extends Omit<InputProps, 'value' | 'defaultValue' | 'onChange'> {
options: string[];
value: string;
onValueChange: (value: string) => void;
emptyLabel?: string;
trailingElement?: (option: string) => ReactNode;
inputRef?: Ref<HTMLInputElement>;
openOnFocus?: boolean;
}
export default function AutocompleteInput({
className,
disabled,
emptyLabel,
fluid,
height = 'medium',
inputRef,
onValueChange,
options,
openOnFocus = false,
trailingElement,
value,
variant = 'subtle',
...inputProps
}: AutocompleteInputProps) {
const internalInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const { onFocus, ...restInputProps } = inputProps;
const handleInputRef = (node: HTMLInputElement | null) => {
internalInputRef.current = node;
if (!inputRef) {
return;
}
if (typeof inputRef === 'function') {
inputRef(node);
return;
}
inputRef.current = node;
};
// close the popover when the parent scrollable container scrolls or the window resizes
useEffect(() => {
if (!open) {
return;
}
const scrollTarget = getScrollParent(internalInputRef.current);
const handleScroll = () => setOpen(false);
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
scrollTarget.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, [open]);
return (
<BaseAutocomplete.Root
items={options}
autoHighlight
highlightItemOnHover
disabled={disabled}
open={open}
value={value}
onOpenChange={setOpen}
onValueChange={onValueChange}
>
<BaseAutocomplete.Input
ref={handleInputRef}
onFocus={(event) => {
onFocus?.(event);
if (openOnFocus) {
setOpen(true);
}
}}
className={cx([
inputStyles.input,
inputStyles[variant],
inputStyles[height],
fluid && inputStyles.fluid,
className,
])}
{...restInputProps}
/>
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={styles.positioner}>
<BaseAutocomplete.Popup className={styles.popup} data-hide-empty={emptyLabel ? undefined : ''}>
<BaseAutocomplete.List className={styles.list}>
<BaseAutocomplete.Collection>
{(option: string) => (
<BaseAutocomplete.Item key={option} value={option} className={styles.item}>
<span className={styles.itemLabel}>{option}</span>
{trailingElement?.(option)}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
{emptyLabel && <BaseAutocomplete.Empty className={styles.empty}>{emptyLabel}</BaseAutocomplete.Empty>}
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
</BaseAutocomplete.Root>
);
}
@@ -0,0 +1,14 @@
export function getScrollParent(node: HTMLElement | null): HTMLElement | Window {
let parent = node?.parentElement ?? null;
while (parent) {
const { overflowY } = window.getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent;
}
parent = parent.parentElement;
}
return window;
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import { IoCheckmark } from 'react-icons/io5';
import style from './Checkbox.module.scss';
@@ -2,8 +2,8 @@ import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket';
import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
@@ -1,6 +1,6 @@
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton';
@@ -1,6 +1,6 @@
import { Menu as BaseMenu } from '@base-ui/react/menu';
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui/react/menu';
import style from './DropdownMenu.module.scss';
@@ -1,10 +1,9 @@
/* eslint-disable react/destructuring-assignment */
import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import React from 'react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { runtimeStore } from '../../stores/runtime';
import { getConnectionState, getReconnectAttempts } from '../../utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -37,7 +36,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({
error,
store: appState,
hasSocket: { hasConnected, reconnectAttempts },
hasSocket: { hasConnected: getConnectionState(), reconnectAttempts: getReconnectAttempts() },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -1,6 +1,6 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { setClientRemote } from '../../hooks/useSocket';
import { useClientStore } from '../../stores/clientStore';
@@ -16,6 +16,33 @@
}
}
.content {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.title,
.body {
margin: 0;
}
.title {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.body {
color: $gray-400;
line-height: 1.5;
}
.footer {
width: fit-content;
}
.info {
svg {
color: $info-blue;
@@ -10,13 +10,37 @@ interface InfoProps {
type?: 'info' | 'warning' | 'error';
}
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
interface InfoSectionProps {
className?: string;
}
function InfoTitle({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <p className={cx([style.title, className])}>{children}</p>;
}
function InfoBody({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <p className={cx([style.body, className])}>{children}</p>;
}
function InfoFooter({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <div className={cx([style.footer, className])}>{children}</div>;
}
function InfoRoot({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
return (
<div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div>
<div className={style.content}>{children}</div>
</div>
);
}
const Info = Object.assign(InfoRoot, {
Title: InfoTitle,
Body: InfoBody,
Footer: InfoFooter,
});
export default Info;
@@ -1,6 +1,6 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
import { RefObject, useEffect } from 'react';
import Textarea, { type TextareaProps } from '../textarea/Textarea';
@@ -1,7 +1,7 @@
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types';
import { UseControllerProps, useController } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
@@ -1,4 +1,4 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { InputHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { Popover } from '@base-ui/react/popover';
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui/react/popover';
import PopoverContents from '../../popover/Popover';
@@ -1,10 +1,10 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
}
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => {
if (typeof initialText === 'undefined') {
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit
*/
const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
event.stopPropagation();
}
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => {
onChange: (event) => handleChange(event.target.value),
onBlur: (event) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
handleSubmit(event.target.value);
}
},
onKeyDown: keyHandler,
@@ -1,4 +1,4 @@
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { TextareaHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
@@ -1,7 +1,8 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cx } from '../../../utils/styleUtils';
import { formatTime, getFormatFromSettings } from '../../../utils/time';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
@@ -16,6 +17,7 @@ interface TimeInputProps<T extends string> {
align?: 'left' | 'center';
delayed?: boolean;
className?: string;
shouldFormat?: boolean;
}
export default function TimeInput<T extends string>({
@@ -28,10 +30,12 @@ export default function TimeInput<T extends string>({
align = 'center',
delayed,
className,
shouldFormat,
}: TimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
const formatAs12Hr = useMemo(() => shouldFormat && getFormatFromSettings() === '12', [shouldFormat]);
/**
* @description Resets input value to given
@@ -39,10 +43,12 @@ export default function TimeInput<T extends string>({
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
} else if (shouldFormat) {
setValue(formatTime(time));
} else {
setValue(millisToString(time));
}
}, [time]);
}, [time, shouldFormat]);
/**
* @description Selects input text on focus
@@ -139,9 +145,10 @@ export default function TimeInput<T extends string>({
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxLength={formatAs12Hr ? 11 : 8}
style={{
textAlign: align,
width: formatAs12Hr ? '7.5em' : '6.5em',
}}
/>
);
@@ -18,7 +18,7 @@
}
&:focus {
outline: none;
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
}
@@ -3,7 +3,7 @@
top: 10vh;
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
@@ -11,12 +11,22 @@
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
border-radius: $component-border-radius-md;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
outline: none;
}
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.backdrop {
position: fixed;
inset: 0;
@@ -30,8 +40,6 @@
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
@@ -39,6 +47,11 @@
justify-content: space-between;
}
.titleText {
font-size: 1rem;
font-weight: 600;
}
.body {
display: flex;
flex-direction: column;
@@ -48,6 +61,13 @@
overflow-y: auto;
}
.wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
}
.footer {
padding-block: 1rem;
display: flex;
@@ -1,7 +1,8 @@
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import style from './Modal.module.scss';
@@ -11,6 +12,7 @@ interface ModalProps {
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
size?: 'default' | 'wide';
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
@@ -21,6 +23,7 @@ export default function Modal({
title,
showCloseButton,
showBackdrop,
size = 'default',
bodyElements,
footerElements,
onClose,
@@ -35,9 +38,9 @@ export default function Modal({
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.modal}>
<BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
<div className={style.title}>
{title}
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
@@ -45,7 +48,7 @@ export default function Modal({
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
{footerElements ? <div className={style.footer}>{footerElements}</div> : null}
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
@@ -1,9 +1,9 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
@@ -13,7 +13,6 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
@@ -1,11 +1,10 @@
import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
interface ViewNavigationMenuProps {
/** prevent navigation */
@@ -24,7 +24,9 @@ export default function NavigationMenuItem({
role='button'
onClick={onClick}
onKeyDown={(event) => {
isKeyEnter(event) && onClick();
if (isKeyEnter(event)) {
onClick();
}
}}
>
{children}
@@ -5,7 +5,7 @@
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1000;
@@ -27,4 +27,3 @@
font-size: 1rem;
margin-bottom: 0.5rem;
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui/react/popover';
import { PropsWithChildren } from 'react';
import style from './Popover.module.scss';
@@ -2,7 +2,6 @@ import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage';
interface ProtectRouteProps {
@@ -0,0 +1,22 @@
@use '@/theme/viewerDefs' as *;
.square {
border-radius: $component-border-radius-sm;
background-color: $viewer-color;
display: flex;
align-items: center;
justify-content: center;
}
.blink {
animation: blink 1s ease-in-out infinite alternate;
}
@keyframes blink {
0% {
opacity: 10%;
}
100% {
opacity: 100%;
}
}
@@ -0,0 +1,43 @@
import { toString } from 'qrcode';
import { Suspense, use } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './QrCode.module.scss';
interface QRCodeProps {
value: string;
size: number;
}
export default function QRCode({ value, size }: QRCodeProps) {
'use memo';
const svgPromise = toString(value, {
width: size,
margin: 2,
color: { dark: '#101010', light: '#cfcfcf' },
});
return (
<Suspense fallback={<QrFallback size={size} />}>
<QrSvg svgPromise={svgPromise} size={size} />
</Suspense>
);
}
function QrFallback({ size }: { size: number }) {
'use memo';
return <span style={{ width: size + 2, height: size + 2 }} className={cx([style.blink, style.square])} />;
}
function QrSvg({ svgPromise, size }: { svgPromise: Promise<string>; size: number }) {
'use memo';
const svg = use(svgPromise);
return (
<span
className={style.square}
style={{ width: size + 2, height: size + 2 }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
@@ -15,6 +15,11 @@ $thumb-color-hover: $white-60;
height: 100%;
width: 100%;
overscroll-behavior: contain;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
.scrollbar {
@@ -1,5 +1,5 @@
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { cx } from '../../utils/styleUtils';
@@ -125,6 +125,11 @@
font-size: 0.5rem;
display: grid;
place-content: center;
visibility: hidden;
&[data-visible] {
visibility: visible;
}
&::before {
content: '';
@@ -1,6 +1,6 @@
import { Select as BaseSelect } from '@base-ui/react/select';
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui/react/select';
import { cx } from '../../utils/styleUtils';
@@ -29,7 +29,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} keepMounted />
<BaseSelect.Popup className={styles.popup}>
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
@@ -43,7 +43,7 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
))}
</BaseSelect.List>
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} keepMounted />
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
@@ -10,7 +10,7 @@
.text {
display: block;
margin-inline: auto;;
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: 600px;
@@ -8,9 +8,12 @@
padding-top: 10vh;
}
.empty {
width: 100%;
opacity: 0.8;
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1em;
}
.text {
@@ -1,18 +1,33 @@
import EmptyImage from '../../../assets/images/empty.svg?react';
import { SupportedEntry } from 'ontime-types';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
text: string;
handleAddNew?: (type: SupportedEntry) => void;
}
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
</td>
</tr>
</tbody>
@@ -2,13 +2,13 @@
position: relative;
display: flex;
align-items: center;
/* Reset and base styles */
appearance: none;
border: 1px solid transparent;
border-radius: 99px;
background-color: $gray-1100;
/* Transitions */
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
@@ -1,9 +1,17 @@
.tag {
font-size: calc(1rem - 3px);
letter-spacing: 0.5px;
background-color: $gray-900;
color: $ui-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
.default {
background-color: $gray-900;
color: $ui-white;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
}
@@ -1,11 +1,14 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Tag.module.scss';
interface TagProps {
className?: string;
variant?: 'default' | 'warning';
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
return <span className={cx([style.tag, style[variant], className])}>{children}</span>;
}
@@ -1,7 +1,7 @@
.tooltip {
font-size: calc(1rem - 3px);
background-color: $ui-white;
color: $ui-black;
color: $ui-black;
padding: 0.125rem 0.5rem;
border-radius: 2px;
line-height: 1.25em;
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import { PropsWithChildren } from 'react';
import style from './Tooltip.module.scss';
@@ -6,7 +6,6 @@ import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './viewParams.types';
@@ -44,7 +43,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
return (
<MultiOption
paramField={paramField}
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
options={optionFromParams.length ? optionFromParams : (paramField.defaultValue ?? [''])}
/>
);
}
@@ -1,15 +1,14 @@
import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types';
import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types';
import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
@@ -47,4 +47,4 @@
.hidden {
display: none;
}
}
@@ -1,8 +1,7 @@
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types';
@@ -65,8 +64,8 @@ function SectionContents({ options, collapsed }: SectionContentsProps) {
function HiddenContents({ options }: { options: ParamField[] }) {
return (
<>
{options.map((option, index) => {
return <ParamInput key={option.title + index} paramField={option} />;
{options.map((option) => {
return <ParamInput key={option.id} paramField={option} />;
})}
</>
);

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