Compare commits

...

72 Commits

Author SHA1 Message Date
Carlos Valente 1a7bc2da25 refactor(automation): trim recipe scaffolding 2026-09-11 20:06:20 +02:00
Carlos Valente 397b54a472 fix(automation): accept single-label OSC hosts 2026-09-11 20:06:01 +02:00
Carlos Valente dc2d193156 feat(automation): streamline automation management 2026-09-11 19:57:38 +02:00
Carlos Valente 0a12a64240 feat(automation): add guided automation recipes 2026-09-11 19:57:25 +02:00
Carlos Valente ab61d3434b feat(automation): label event triggers and outputs clearly 2026-09-11 19:56:56 +02:00
Carlos Valente 4601770d1d feat(automation): create global triggers with automations 2026-09-11 19:56:42 +02:00
Carlos Valente 123692827c feat(automation): support URL-encoded template values 2026-09-11 19:56:31 +02:00
Carlos Valente 1175b3c641 fix(report): harden empty report check 2026-09-05 20:51:53 +02:00
Carlos Valente afc3a44455 refactor(report): improve report summary 2026-09-05 20:35:44 +02:00
Carlos Valente bc6a321172 fix(cuesheet): prevent empty seconds field 2026-09-05 20:32:02 +02:00
Carlos Valente 977b01a072 fix: keep group visible as header for the running event 2026-09-05 09:24:42 +02:00
SMPTY dd718230ad fix(client): fix PWA manifest so "Install as App" works properly (#2192) 2026-09-05 09:15:08 +02:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
Carlos Valente 9806f97d27 test(rundown): improve rundown utils coverage 2026-08-25 22:41:37 +02:00
Carlos Valente b9683f00dc fix: prevent stale rundown cache on rename 2026-08-25 21:41:34 +02:00
Carlos Valente 63fdc50c50 docs(llms): document ongoing code migrations 2026-08-25 21:41:18 +02:00
Carlos Valente 5acfc4a122 docs(llms): setup agents documentation 2026-08-25 21:41:18 +02:00
Carlos Valente a84b8b5530 refactor(ui): polish navigation menu and view params editor 2026-08-23 13:42:21 +02:00
Carlos Valente c19aa4ca28 fix(cuesheet): prevent stale column width on refresh 2026-08-23 13:28:24 +02:00
Carlos Valente 805663e2ea chore(deps): migrate to @tanstack/react-table v9 2026-08-23 13:28:24 +02:00
Carlos Valente 0f0b32d444 accept bearer token 2026-08-21 20:52:11 +02:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
Alex Christoffer Rasmussen a006331fea Group duration context menu utils (#1748) 2026-08-09 16:45:47 +02:00
Carlos Valente ac0ef06459 bump version to 4.12.0 2026-08-09 10:44:13 +02:00
Carlos Valente 4d04fe35c3 refactor(e2e): improve test stability 2026-08-09 10:41:12 +02:00
Carlos Valente fb559ed9da refactor(progress bar): reset when event changes 2026-08-05 15:30:22 +02:00
Carlos Valente 24be49ef66 refactor(settings): polish settings UI 2026-08-02 13:44:28 +02:00
Carlos Valente f0c284b708 refactor(link): add row presentation variant 2026-08-02 13:44:28 +02:00
Carlos Valente 9e42f18299 feat(settings): add searchable navigation 2026-08-02 13:44:28 +02:00
Carlos Valente bdd815678b refactor(ui): extend link and modal variants 2026-08-02 13:44:28 +02:00
Carlos Valente 12fc76b8ff refactor(op): simplify UI elements 2026-08-02 12:55:49 +02:00
Carlos Valente 8ed0a1689e refactor(op): clarify settings text 2026-08-02 12:55:49 +02:00
Carlos Valente ba18b8dba7 refactor(ui): rundown title UI polish 2026-08-02 12:55:49 +02:00
Carlos Valente 608247157b refactor(rundown): cap rundown names to 64 characters 2026-08-02 12:55:49 +02:00
Carlos Valente 514b6c3a02 refactor(navigation): custom params indicators only for current view 2026-08-02 12:55:49 +02:00
Carlos Valente 2050735014 refactor(ui): title alignment is logo aware 2026-08-02 12:55:49 +02:00
Carlos Valente 76b1341432 refactor(logo): upload on submit form 2026-08-02 10:39:58 +02:00
Carlos Valente c7422fc7c7 refactor(logo): improve flow for managing logo 2026-08-02 10:39:58 +02:00
Carlos Valente c93b10cea9 chore: bump deepmerge-ts package 2026-08-02 10:39:58 +02:00
Carlos Valente 8a7f9530d2 chore: bump nanoid package 2026-08-02 10:39:58 +02:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
Carlos Valente a8c611911d refactor(rundown): new entries are appended to rundown 2026-07-22 07:17:39 +02:00
Carlos Valente 8363f06a5c chore(rundown): improve documentation of insert logic 2026-07-22 07:17:39 +02:00
will@t1d.dev 5f040092cb fix: timer-legacy view not served in desktop distributions
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.

Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
2026-07-19 10:02:45 +02:00
Carlos Valente 347c748dd9 feat(rundown): add shortcut to jump to current element 2026-07-18 17:03:02 +02:00
Carlos Valente f234a1f892 fix(rundown): prevent skipped events from influencing day calculations 2026-07-18 16:52:36 +02:00
Carlos Valente 0c5e87b7e7 fix(ui): prevent warning indicator from overflowing container 2026-07-18 16:52:36 +02:00
Carlos Valente 64082ceac0 refactor(rundown): improve visibility of count to end 2026-07-18 16:45:55 +02:00
Carlos Valente f598e8dab1 refactor(time control): improve handling of overtime and count to end 2026-07-18 16:45:55 +02:00
Carlos Valente 0c84a3ff9e fix: delays account for midnight 2026-07-18 16:45:55 +02:00
Carlos Valente c8760b5e9c bump version to 4.11.0 2026-07-18 15:24:25 +02:00
Claude 34360a5b8f feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-17 22:26:19 +02:00
Claude 5e6debcce0 fix(views): center timer digits with flexbox for embedded browser compatibility
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).

Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.

Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.

Fixes #2126

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
2026-07-17 20:59:49 +02:00
Carlos Valente c191786e50 refactor(navigation): clear params from editor drawer 2026-07-17 15:50:42 +02:00
Carlos Valente 6911e37c18 refactor(automation): improve automation UX 2026-07-17 10:53:45 +02:00
Claude 86b2132cdc feat(automation): expose group title and allow setting secondary text
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
2026-07-17 10:53:45 +02:00
Carlos Valente 25f0dba83a chore: add codegraph to gitignore 2026-07-16 10:46:10 +02:00
332 changed files with 14407 additions and 5796 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+46
View File
@@ -0,0 +1,46 @@
---
name: code-review
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
---
# Review Ontime changes
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
## Load relevant context
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
## Review order
1. Establish intent and affected runtime surfaces.
2. Read tests first. Identify claimed behaviour and coverage.
3. Trace implementation, errors, and state transitions.
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
5. Verify claimed checks. Never claim unobserved results.
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
## Findings
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
+22
View File
@@ -0,0 +1,22 @@
# Ontime agent guide
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
## Load only relevant guides
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
Load multiple guides when needed. Skip unrelated guides for mechanical work.
## Before handoff
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.10.0", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+4 -2
View File
@@ -7,13 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>ontime</title> <title>Ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+9 -8
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.10.0", "version": "4.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.6.0", "react-icons": "5.7.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,7 +60,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.1.1", "@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.0.1", "vite": "8.2.1",
"vite-plugin-compression2": "2.5.1", "vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
+9 -7
View File
@@ -1,19 +1,21 @@
{ {
"name": "ontime", "name": "Ontime",
"short_name": "ontime", "short_name": "Ontime",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "ontime-logo-192.png",
"type": "image/x-icon" "sizes": "192x192",
"type": "image/png"
}, },
{ {
"src": "ontime-logo.png", "src": "ontime-logo-512.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "", "display": "standalone",
"theme_color": "#121212", "theme_color": "#101010",
"background_color": "#101010" "background_color": "#101010"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -2
View File
@@ -4,6 +4,7 @@ import type {
AutomationDTO, AutomationDTO,
AutomationOutput, AutomationOutput,
AutomationSettings, AutomationSettings,
AutomationTriggerDTO,
Trigger, Trigger,
TriggerDTO, TriggerDTO,
} from 'ontime-types'; } from 'ontime-types';
@@ -57,8 +58,11 @@ export function deleteTrigger(id: string): Promise<void> {
/** /**
* HTTP request to create a new automation * HTTP request to create a new automation
*/ */
export async function addAutomation(automation: AutomationDTO): Promise<Automation> { export async function addAutomation(
const res = await axios.post(`${automationsPath}/automation`, automation); automation: AutomationDTO,
triggers: AutomationTriggerDTO[] = [],
): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers });
return res.data; return res.data;
} }
+3 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { OntimeReport } from 'ontime-types'; import type { ReportData } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -8,18 +8,13 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch all reports * HTTP request to fetch the report
*/ */
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> { export async function fetchReport(options?: RequestOptions): Promise<ReportData> {
const res = await axios.get(reportUrl, { signal: options?.signal }); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() { export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`); await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
+17
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -166,6 +176,13 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -1,24 +1,82 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-areas: grid-template-columns: minmax(0, 1fr) auto;
'label label' align-items: center;
'input btn'; gap: 0.75rem;
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
grid-area: btn; margin-left: auto;
} }
.label { .label {
color: $label-gray; color: $gray-200;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
} }
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -8,7 +9,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import ExternalLink from '../link/external-link/ExternalLink';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -44,6 +45,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -66,34 +68,46 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> <Info.Body>
Either by entering a custom path or selecting a URL Preset. Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
<br /> </Info.Body>
<br /> <Info.Footer>
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink> <ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
</Info.Footer>
</Info> </Info>
<div className={style.inlineEntry}> <section className={style.section}>
<span className={style.label}>Enter custom path</span> <div className={style.sectionHeader}>
<label className={style.textEntry}> <span className={style.label}>Enter custom path</span>
{origin} <span className={style.description}>Type the path the client should open.</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> </div>
</label>
<Button
variant='primary'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
</Button>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} <span className={style.origin}>{origin}</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect to custom path'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
@@ -115,7 +129,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </div>
</div> </section>
</> </>
} }
/> />
@@ -0,0 +1,13 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -5,6 +5,8 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -31,9 +33,22 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ bodyElements={
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} /> <div className={style.form}>
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
} }
footerElements={ footerElements={
<> <>
@@ -1,12 +1,16 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10%; top: 10vh;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -43,7 +47,8 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: min(80vh, 600px); flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -53,4 +58,5 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -0,0 +1,6 @@
.eyebrow {
font-size: $aux-text-size;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Eyebrow.module.scss';
interface EyebrowProps {
className?: string;
}
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
return <span className={cx([style.eyebrow, className])}>{children}</span>;
}
@@ -21,4 +21,15 @@
outline: none; outline: none;
box-shadow: 0 1px 0 0 currentColor; box-shadow: 0 1px 0 0 currentColor;
} }
&:hover .icon,
&:focus-visible .icon {
transform: translateX(0.25rem);
}
}
.icon {
flex: 0 0 1.5em;
font-size: 1em;
transition: transform $transition-time-action;
} }
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { HiArrowLongRight } from 'react-icons/hi2';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -10,9 +10,10 @@ interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; inline?: boolean;
className?: string;
} }
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) { export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -23,10 +24,11 @@ export default function ExternalLink({ href, inline, children }: ExternalLinkPro
href='#!' href='#!'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={cx([style.link, inline && style.inline])} className={cx([style.link, inline && style.inline, className])}
onClick={handleClick} onClick={handleClick}
> >
{children} <IoOpenOutline style={{ fontSize: '1em' }} /> {children}
<HiArrowLongRight className={style.icon} />
</a> </a>
); );
} }
@@ -4,10 +4,13 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; box-sizing: border-box;
min-width: min(880px, 90vw); width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(1200px, 90vw); max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -17,14 +20,21 @@
outline: none; outline: none;
} }
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide { .wide {
top: 4vh; top: 4vh;
min-width: min(1280px, 96vw); width: min(1800px, 98vw);
max-width: min(1600px, 96vw); height: min(88vh, calc(96vh - 1rem));
height: 88vh; min-height: 0;
max-height: 88vh;
display: flex;
flex-direction: column;
} }
.backdrop { .backdrop {
@@ -41,15 +51,19 @@
.title { .title {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.titleText { .titleText {
min-width: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere;
} }
.body { .body {
@@ -57,21 +71,23 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: 60vh; flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
overflow-y: auto; overflow-y: auto;
scroll-padding-block: 0.5rem;
} }
.wide .body { .wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden; overflow: hidden;
} }
.footer { .footer {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string; title?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
size?: 'default' | 'wide'; size?: 'small' | 'compact' | 'default' | 'wide';
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}> <BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}>
<div className={style.title}> <div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />} {title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && ( {showCloseButton && (
@@ -9,6 +9,7 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -24,6 +25,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete); const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -1,7 +1,3 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -23,14 +19,15 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -50,22 +47,51 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
padding-inline: 1.5rem; min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.note { .group {
margin-left: auto; display: flex;
color: $white-20; flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' onClick={onClose}> <Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
{supportsFullscreen && ( <MenuGroup label='This screen'>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
{fullscreen ? <IoContract /> : <IoExpand />} )}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
</NavigationMenuItem> </NavigationMenuItem>
)} </MenuGroup>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} /> <MenuGroup label='Ontime app'>
<EditorNavigation />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink <ClientLink
key={route.url} to='cuesheet'
to={route.url} current={location.pathname === '/cuesheet'}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined} postAction={isSmallScreen ? onClose : undefined}
> >
{route.label} <IoLockClosedOutline />
Cuesheet
</ClientLink> </ClientLink>
))} <ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} /> <PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
); );
} }
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation(); const location = useLocation();
const { data: urlPresets } = useUrlPresets(); const { data: urlPresets } = useUrlPresets();
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null; if (navPresets.length === 0) return null;
return ( return (
<> <MenuGroup label='Presets'>
<hr className={style.separator} />
{navPresets.map((preset) => ( {navPresets.map((preset) => (
<ClientLink <ClientLink
key={preset.alias} key={preset.alias}
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias} {preset.alias}
</ClientLink> </ClientLink>
))} ))}
</> </MenuGroup>
); );
} }
@@ -1,8 +1,8 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams'; import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -15,22 +15,37 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean; suppressSettings?: boolean;
} }
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params); const hasSavedChanges = hasCustomParams(searchParams);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
() => { (event) => {
if (isNavigationLocked) return; if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
menuHandler.toggle(); event.preventDefault();
toggleMenu();
}, },
{ preventDefault: true }, { preventDefault: false },
], ],
[ [
'mod + ,', 'mod + ,',
@@ -49,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges} hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
</> </>
); );
} }
@@ -11,6 +11,7 @@ import {
} from '../../../stores/savedViewParams'; } from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton'; import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -105,16 +106,20 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
{isCustomised && ( {isCustomised && (
<span className={style.trailing}> <span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' /> <span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<IconButton <Tooltip
variant='ghosted-white' text='Reset to default'
size='small' render={
className={style.clear} <IconButton
aria-label='Clear saved view settings' variant='ghosted-white'
title='Clear saved view settings' size='small'
onClick={clearViewSettings} className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
> >
<IoCloseOutline /> <IoCloseOutline />
</IconButton> </Tooltip>
</span> </span>
)} )}
</NavigationMenuItem> </NavigationMenuItem>
@@ -2,35 +2,68 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75rem 1.5rem; padding: 0.625rem 1.25rem;
gap: 0.5rem; gap: 0.625rem;
width: 100%; width: 100%;
border-left: 4px solid transparent; border-left: 3px solid transparent;
color: $action-text-color; color: $gray-200;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color; transition-property: color, background-color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover { &:hover {
color: $ontime-color; background-color: $white-3;
background-color: $gray-1350; color: $ui-white;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $border-color-ondark; background-color: $white-7;
} }
&:focus { &:focus-visible {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
background-color: $gray-1350; outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $gray-1300; background-color: $bg-active;
border-left: 4px solid $action-text-color; border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent'; import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event)) { if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); onClick();
} }
}} }}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import Switch from '../../switch/Switch';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onToggle: () => void;
}
/** A menu row which reflects, and toggles, an on/off state */
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
return (
<label className={style.link}>
{icon}
<span className={style.label}>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -1,22 +1,52 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header { .header {
font-size: calc(1rem - 2px); padding: 0 1.25rem 0.25rem;
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.5rem 0.5rem; padding: 0.25rem 1.25rem 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
// there is no point showing this if we only have one interface if (externalInterfaces.length === 0) {
if (data.networkInterfaces.length < 2) {
return null; return null;
} }
return ( return (
<> <div className={style.footer}>
<div className={style.header}>Accessible on external networks</div> <div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {externalInterfaces.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'> <div key={nif.name} className={style.interface}>
<span className={style.interfaceCopy}> <CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.interfaceCopy}>
</span> <span className={style.address}>{nif.address}</span>
</CopyTag> <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
); );
})} })}
</div> </div>
</> </div>
); );
} }
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import { getExternalInterfaces } from '../otherAddresses.utils';
describe('getExternalInterfaces', () => {
test('returns no alternatives when localhost is the only interface', () => {
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
});
test('does not treat an interface name as deployment information', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'cloud', address: '192.168.1.42' },
]),
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
});
test('returns non-local interfaces when local and external interfaces are available', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]),
).toEqual([
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]);
});
});
@@ -0,0 +1,6 @@
import { type NetworkInterface } from 'ontime-types';
/** Returns network addresses other than the localhost address injected by the info endpoint. */
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
}
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const { current, duration, className } = props; const progress = useAnimatedProgress(current, duration, eventId);
const progress = useAnimatedProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}> <div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -0,0 +1,11 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -0,0 +1,19 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100vh; height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,6 +11,11 @@
color: $ui-white; color: $ui-white;
} }
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning { .warning {
background-color: $orange-1300; background-color: $orange-1300;
color: $orange-300; color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'active' | 'warning';
} }
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -2,6 +2,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,37 +1,77 @@
.inline { .multiOptionList {
display: inline-flex; overflow-y: auto;
align-items: center; max-height: 12rem;
flex-wrap: wrap; background: $gray-1200;
gap: 1rem; border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.75rem;
padding-inline: 0.5rem; min-height: 2.5rem;
height: 2.5rem; padding: 0.5rem 0.75rem;
background: $gray-1050; background: transparent;
color: $ui-white; border: 0;
color: $gray-300;
line-height: 1em; line-height: 1em;
border-radius: $component-border-radius-md; cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover:not(:disabled):not(:active) { &:hover {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $ui-white;
} }
&:active:not(:disabled) { &:active {
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; }
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
} }
&::after { &::after {
content: ''; content: '';
margin-left: 0.25rem; flex: 0 0 auto;
width: 0.75em; margin-left: auto;
height: 0.75em; width: 0.625rem;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}> <div className={style.multiOptionList}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={style.toggleSelect} className={cx([style.toggleSelect, selected && style.selected])}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={paramState.includes(option.value)} checked={selected}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,13 +1,3 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -34,11 +24,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $gray-1250; background-color: $ui-black;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -58,36 +48,54 @@
} }
.header { .header {
padding-inline: 1rem; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding-inline: 1rem; padding: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding-inline: 1rem; padding: 0.75rem 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 0.75rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,8 +4,10 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -27,6 +29,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -36,6 +39,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -68,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Customise</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}> <Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,10 +1,21 @@
.presetSection { .presetSection {
background-color: $gray-1350; background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.25rem;
padding: 1rem 0.5rem; color: $gray-300;
margin-bottom: 1rem; }
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -14,12 +25,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active { &.active {
color: $blue-500; background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; margin-left: auto;
} }
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets'; import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
{viewPresets.map((preset) => { <div className={style.header}>
const active = searchParams.get('alias') === preset.alias; <Eyebrow>Saved presets</Eyebrow>
return ( </div>
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div className={style.presetList}>
<div>{preset.alias}</div> {viewPresets.map((preset) => {
<Button const active = searchParams.get('alias') === preset.alias;
variant={active ? 'ghosted' : 'subtle-white'} return (
onClick={() => handleRecall(preset)} <div key={preset.alias} className={cx([style.preset, active && style.active])}>
disabled={active} <span className={style.presetName}>{preset.alias}</span>
className={style.presetActions} {active ? (
> <span className={style.current}>Current</span>
{active ? 'Applied' : 'Apply'} ) : (
</Button> <Button
</div> variant='ghosted'
); size='small'
})} onClick={() => handleRecall(preset)}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,48 +1,118 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 2rem; gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
} }
} }
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label { .label {
margin-top: $section-spacing; display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
display: flex; &:not(:last-child) {
flex-direction: column; border-bottom: 1px solid $white-7;
gap: 0.25rem; }
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
} }
.title { .title {
font-size: $inner-section-text-size; color: $ui-white;
display: block;
width: 100%;
} }
.description { .description {
font-size: $inner-section-text-size; color: $gray-400;
display: block; font-size: $aux-text-size;
color: $modal-note-color; line-height: 1.4;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
} }
.hidden { .hidden {
@@ -1,7 +1,9 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
const handleCollapse = () => { // hidden options are inputs without a UI, there is nothing to frame them with
if (collapsible) { if (title === OptionTitle.Hidden) {
setCollapsed((prev) => !prev); return <HiddenContents options={options} />;
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <SectionHeader
<HiddenContents options={options} /> title={title}
) : ( isCustomised={options.some((option) => searchParams.has(option.id))}
<> collapsed={isCollapsed}
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
{title} />
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} {/* collapsed options stay mounted: the form reads its values from the DOM */}
</div> <div className={cx([style.options, isCollapsed && style.hidden])}>
<SectionContents options={options} collapsed={collapsed} /> {options.map((option) => (
</> <label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
)}
</section>
);
}
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span> <span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
</label> </label>
); ))}
})} </div>
</> </section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
); );
} }
@@ -0,0 +1,25 @@
import { TimerLifeCycle } from 'ontime-types';
/**
* User facing labels for the timer lifecycle
* Shared between the automation settings and the rundown event editor
* so that a lifecycle is named the same everywhere it is shown
*/
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
/**
* Resolves a lifecycle to its user facing label, falling back to the raw value
*/
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -1,17 +1,34 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types'; import type { ReportData } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants'; import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report'; import { fetchReport } from '../api/report';
const emptyReport: ReportData = {
eventReports: {},
rundown: null,
show: {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
actualStart: null,
actualEnd: null,
actualDuration: null,
},
};
export default function useReport() { export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({ const { data: report, refetch } = useQuery<ReportData>({
queryKey: REPORT, queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }), queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
return { data: data ?? {}, refetch }; return {
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
} }
@@ -1,26 +1,34 @@
import { MaybeNumber, Playback } from 'ontime-types'; import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress'; import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket'; import { useIsOnline, usePlayback } from './useSocket';
/** /**
* Returns the live completion percentage (0100) of a countdown, interpolated locally. * Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/ */
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number { export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback(); const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll; const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() }); const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0); const [, setTick] = useState(0);
const now = performance.now();
// there is only something to animate while a running timer is counting down towards 0 const hasAuthoritativeUpdate =
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null; baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
// re-anchor to the authoritative value whenever the server pushes a new timer update if (hasAuthoritativeUpdate) {
useEffect(() => { // Reset during render so an event change is reflected in this very paint.
baseline.current = { current, at: performance.now() }; baseline.current = { current, duration, eventId, playback, at: now };
}, [current, duration, playback]); }
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth // while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => { useEffect(() => {
@@ -34,8 +42,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber)
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [shouldAnimate]); }, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running // Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current; const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored; const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration); return getProgress(value, duration);
} }
+37 -8
View File
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -50,6 +49,7 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -60,9 +60,15 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries in the loaded rundown.
*/ */
@@ -120,7 +126,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -149,8 +155,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -193,7 +200,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -205,8 +212,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -284,7 +291,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => { async (entryId: EntryId, options?: ClientInsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -460,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd; return previousEnd;
} }
}, },
[getCurrentRundownData, updateEntryMutation, queryClient], [getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
); );
/** /**
@@ -1003,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1020,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} }
@@ -1057,7 +1086,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef>, scrollRef: RefObject<ScrollRef | null>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; followTrigger: MaybeString; // this would be an entry id or null
setScrollFlag?: (newValue: boolean) => void; getTopOffset: () => number;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset); // resolve the offset after layout, so that measured values are up to date
setScrollFlag?.(false); scrollToComponent(followRef, scrollRef, getTopOffset());
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(() => {
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { scrollToComponent(followRef, scrollRef, getTopOffset());
if (componentRef && containerRef) { }, [followRef, scrollRef, getTopOffset]);
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
+12
View File
@@ -139,6 +139,17 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -152,6 +163,7 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `ontime - ${title}`; document.title = `Ontime - ${title}`;
}, []); }, []);
} }
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -0,0 +1,23 @@
import type { OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getEventVariance } from '../report';
it('uses captured days when measuring an event across midnight', () => {
const report: OntimeEventReport = {
startedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 5 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
};
expect(getEventVariance(report)).toMatchObject({
actualDuration: 10 * MILLIS_PER_MINUTE,
delta: 0,
status: 'ontime',
});
expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' });
});
@@ -58,4 +58,16 @@ describe('formatDuration()', () => {
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s'); expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s'); expect(formatDuration(599702, false)).toBe('9m59s');
}); });
it('formats durations differently with and without seconds', () => {
expect(formatDuration(0, false)).toBe('0m');
expect(formatDuration(0, true)).toBe('0m');
expect(formatDuration(30 * MILLIS_PER_SECOND, false)).toBe('30s');
expect(formatDuration(30 * MILLIS_PER_SECOND, true)).toBe('');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, false)).toBe('2h30s');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, true)).toBe('2h');
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, false)).toBe(
'2h10m30s',
);
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, true)).toBe('2h10m');
});
}); });
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
/**
* Summarises an automation's outputs by kind so that a list row can say what the
* automation does without the user having to open the form.
* Shared between the automation settings panel and the rundown event editor.
*/
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
// keep a stable presentation order regardless of the order the user added outputs
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
+4
View File
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; return event.key === 'Enter';
} }
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
+29
View File
@@ -0,0 +1,29 @@
import type { MaybeNumber, OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
type EventVariance = {
actualDuration: MaybeNumber;
delta: number;
status: 'ontime' | 'over' | 'under' | 'not-run';
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
export function getReportTimePosition(time: number, day: number): number;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber {
return time === null || day === null ? null : day * dayInMs + time;
}
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) return notRun;
const start = getReportTimePosition(entry.startedAt, entry.startedAtDay);
const end = getReportTimePosition(entry.endedAt, entry.endedAtDay);
if (start === null || end === null) return notRun;
const actualDuration = end - start;
const delta = actualDuration - entry.scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' };
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
+9 -4
View File
@@ -3,7 +3,9 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -29,6 +31,11 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -180,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd expectedEnd,
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,6 +38,18 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
-35
View File
@@ -1,5 +1,3 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -32,39 +30,6 @@ declare global {
} }
} }
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/** /**
* Allow passing CSS Properties * Allow passing CSS Properties
*/ */
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
describe('matchesSettingsOptionQuery', () => {
const option = {
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'time zone'],
};
it('does not match an empty or whitespace-only query', () => {
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
});
it('matches keywords case-insensitively', () => {
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
});
});
@@ -1,9 +1,4 @@
.corner { $content-max-width: 1280px;
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper { .contentWrapper {
display: flex; display: flex;
@@ -14,9 +9,29 @@
position: relative; position: relative;
} }
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
// width is needed so the button can align to the right of the capped
// column, auto margins alone would shrink this to fit and centre it
width: 100%;
max-width: $content-max-width;
margin-inline: auto;
padding: 0 1rem 0.5rem;
box-sizing: border-box;
}
.content { .content {
margin: 1rem; box-sizing: border-box;
// as with .corner, the width is needed to fill the capped column, otherwise
// auto margins shrink this to fit and each panel ends up its own width
width: 100%;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;
padding-bottom: 300px; // room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
} }
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</div> </div>
); );
} }
@@ -1,3 +1,13 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs, .tabs,
ul { ul {
list-style: none; list-style: none;
@@ -6,22 +16,20 @@ ul {
} }
.tabs { .tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.primary, .primary,
.secondary { .secondary {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 2px;
&:focus { &:focus-visible {
background-color: $gray-1000; background-color: $gray-1000;
outline: 0; outline: 2px solid $blue-500;
outline-offset: -2px;
} }
&:hover { &:hover {
@@ -32,14 +40,21 @@ ul {
.primary { .primary {
font-size: 1rem; font-size: 1rem;
border-radius: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active { &.active {
color: $blue-400; color: $ui-white;
background-color: $gray-1100; border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
} }
&.highlight { &.highlight {
@@ -62,6 +77,9 @@ ul {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
&.active { &.active {
color: $blue-400; color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
} }
} }
@@ -1,10 +1,18 @@
import { Fragment } from 'react'; import { Fragment, useState } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu'; import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
@@ -16,23 +24,57 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return ( return (
<ul className={style.tabs}> <div className={style.container}>
{options.map((panel) => { <SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
const isSelected = selectedPanel === panel.id;
if (panel.highlight) { {results.length === 0 ? (
return ( <Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
<Tooltip key={panel.id} text={panel.highlight} render={<span />}> ) : (
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> <ul className={style.tabs}>
</Tooltip> {results.map((panel) => {
); const isSelected = selectedPanel === panel.id;
} if (panel.highlight) {
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />; return (
})} <Tooltip key={panel.id} text={panel.highlight} render={<span />}>
</ul> <PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
); );
} }
@@ -44,7 +86,15 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation(); const { setLocation } = useAppSettingsNavigation();
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]); const hasSelectedChild = Boolean(
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
return ( return (
<Fragment key={panel.id}> <Fragment key={panel.id}>
@@ -75,6 +125,7 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
} }
}} }}
className={secondaryClasses} className={secondaryClasses}
tabIndex={0}
role='button' role='button'
> >
{secondary.label} {secondary.label}
@@ -0,0 +1,24 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -0,0 +1,60 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}
@@ -1,24 +1,26 @@
$inner-padding: 1rem; $inner-padding: 1rem;
$card-padding: 2rem;
.header { .header {
font-size: 2rem; font-size: 1.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
font-weight: 600; font-weight: 600;
} }
.subheader { .subheader {
font-size: 1.5rem; font-size: 1.125rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.title { .title {
font-size: 1.375rem; font-size: 1rem;
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -35,10 +37,12 @@ $inner-padding: 1rem;
color: $ui-white; color: $ui-white;
} }
// width is owned by the panel content column, which caps and centres itself.
// sections must stay full width so top level cards line up with Panel.Header
// and nested sections fill their card rather than shrinking to fit
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 1024px;
} }
.indent { .indent {
@@ -51,8 +55,10 @@ $inner-padding: 1rem;
} }
.card { .card {
--panel-card-padding: #{$card-padding};
position: relative; position: relative;
padding: 2rem; padding: var(--panel-card-padding);
background-color: $white-3; background-color: $white-3;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
border-radius: 3px; border-radius: 3px;
@@ -76,10 +82,10 @@ $inner-padding: 1rem;
color: $error-red; color: $error-red;
} }
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad { .pad {
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
max-height: 550px;
overflow-y: scroll;
} }
.table { .table {
@@ -97,10 +103,13 @@ $inner-padding: 1rem;
} }
th { th {
font-weight: 400; font-weight: 600;
color: $gray-400; color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
text-transform: capitalize; text-transform: uppercase;
} }
th, th,
@@ -112,6 +121,17 @@ $inner-padding: 1rem;
background-color: $white-1; background-color: $white-1;
} }
// active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours
tr[class*='current'] {
background-color: $bg-active;
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
font-weight: 500;
}
}
// allow highlighting table elements // allow highlighting table elements
tr[data-warn='true'], tr[data-warn='true'],
td[data-warn='true'] { td[data-warn='true'] {
@@ -120,7 +140,7 @@ $inner-padding: 1rem;
} }
.listGroup { .listGroup {
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
> li:not(:last-child) { > li:not(:last-child) {
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
@@ -133,6 +153,17 @@ $inner-padding: 1rem;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 0; padding: 0.5rem 0;
&.interactive {
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
transition: background-color $transition-time-action;
&:hover,
&:focus-within {
background-color: $white-3;
}
}
} }
.fieldTitle { .fieldTitle {
@@ -163,14 +194,13 @@ $inner-padding: 1rem;
.divider { .divider {
border: none; border: none;
border-top: 1px solid $white-10; border-top: 1px solid $white-10;
margin: 1rem -2rem; margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
} }
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
width: 100%; inset: 0;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -188,19 +218,35 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.empty { .emptyState {
background-color: $black-10; padding: 3rem 1.5rem;
text-align: center; text-align: center;
color: $muted-gray; }
td { .emptyMessage {
padding-block: 5rem; width: min(30rem, 100%);
} margin-inline: auto;
}
button { .emptyTitle {
margin-top: 1rem; margin-bottom: 0.25rem;
margin-inline: auto; color: rgba($gray-200, 0.72);
} font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
} }
.inlineElements { .inlineElements {
@@ -239,14 +285,3 @@ $inner-padding: 1rem;
opacity: 0; opacity: 0;
} }
} }
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,7 +1,5 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './PanelUtils.module.scss';
@@ -62,16 +60,29 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) { export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return ( return (
<tr className={style.empty}> <div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<td colSpan={99}> <td colSpan={99}>
<div>{label ?? 'No data yet'}</div> <EmptyState {...props} />
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -81,8 +92,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>; return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children }: { children: ReactNode }) { export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={style.listItem}>{children}</li>; return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
} }
export function Field({ export function Field({
@@ -1,4 +1,4 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -11,6 +11,8 @@ import {
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -19,28 +21,49 @@ export default function AboutPanel() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph> <Panel.Divider />
Free, open-source software for managing rundowns and event timers <Info>Free, open-source software for managing rundowns and event timers.</Info>
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink> <Panel.ListGroup>
</Panel.Paragraph> <ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
<Panel.Paragraph> <ExternalLinkRow
Considering sponsoring our work href={githubSponsorUrl}
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink> title='GitHub Sponsors'
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink> description='Support development through GitHub'
</Panel.Paragraph> />
<Panel.Paragraph> <ExternalLinkRow
If you are looking for an online version of Ontime, consider trying out our cloud service href={buyMeACoffeeUrl}
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink> title='Buy Me a Coffee'
</Panel.Paragraph> description='Make a one-time contribution'
/>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion />
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Helpful links</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<ExternalLinkRow
href={documentationUrl}
title='Read the docs'
description='Setup guides and feature documentation'
/>
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,35 +1,47 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud } from '../../../../externals'; import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
if (isError) { if (isError) {
return ( return (
<Panel.Paragraph> <Panel.ListItem>
{`You are currently using Ontime version ${appVersion}`} <Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
<Panel.Error>Could not fetch version information</Panel.Error> </Panel.ListItem>
</Panel.Paragraph>
); );
} }
if (data.hasUpdates) { if (data.hasUpdates) {
return ( return (
<Panel.Paragraph> <Panel.ListItem>
{`You are currently using Ontime version ${appVersion}.`} <Panel.Field
<br /> title={
<br /> <>
{`A new version ${data.version} is available.`} <br /> <span className={style.updateIndicator} aria-hidden='true' />
{isOntimeCloud ? ( {`Ontime ${appVersion}`}
'You can restart your stage to get the latest available version.' </>
) : ( }
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink> description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.Paragraph> </Panel.ListItem>
); );
} }
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>; return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
} }
@@ -0,0 +1,74 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,52 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,15 @@
.link {
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
padding: 0.75rem var(--panel-card-padding, 2rem);
> :first-child {
flex: 1;
min-width: 0;
}
}
.action {
color: $action-text-color;
font-size: calc(1rem - 2px);
}
@@ -0,0 +1,21 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ExternalLinkRow.module.scss';
interface ExternalLinkRowProps {
href: string;
title: string;
description: string;
}
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
return (
<Panel.ListItem interactive>
<ExternalLink href={href} className={style.link}>
<Panel.Field title={title} description={description} />
<span className={style.action}>Open</span>
</ExternalLink>
</Panel.ListItem>
);
}
@@ -1,6 +1,27 @@
/**
* The wide modal body does not scroll, so the form owns it.
* Without this the form is simply clipped: four outputs is enough to put Save out of reach.
*/
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.formScroll {
height: 100%;
}
.outerColumn { .outerColumn {
display: flex;
flex-direction: column;
gap: 2rem; gap: 2rem;
margin-bottom: 2rem; font-size: calc(1rem - 1px);
color: $ui-white;
padding-block: 0.5rem;
// leaves the overlay scrollbar somewhere to sit without covering a field
padding-right: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -19,61 +40,84 @@
gap: 1rem; gap: 1rem;
} }
.titleSection, .titleSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem; grid-gap: 0.5rem;
button {
align-self: flex-end;
}
} }
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.filterSection, .card {
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px);
}
label { label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
} }
} }
.titleSection { /** shared shell for a single filter or output */
grid-template-columns: 1fr; .card {
} border: 1px solid $white-10;
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
border-left: 0.25rem solid $gray-1200; border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem; border-radius: $component-border-radius-md;
background-color: $black-10;
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
/** pushes the actions to the end of the header, and absorbs any overflow */
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: $aux-text-size;
color: $green-400;
}
.testError {
padding: 0 0.75rem 0.5rem;
}
.tagOsc {
background-color: $blue-1000;
color: $blue-300;
}
.tagHttp {
background-color: $green-1000;
color: $green-300;
}
.tagOntime {
background-color: $gray-1000;
color: $gray-200;
} }
@@ -1,52 +1,103 @@
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
HTTPOutput, AutomationFilter,
OSCOutput, TimerLifeCycle,
OntimeAction, Trigger,
isHTTPOutput, isHTTPOutput,
isOSCOutput, isOSCOutput,
isOntimeAction, isOntimeAction,
} from 'ontime-types'; } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; import {
addAutomation,
addTrigger,
deleteTrigger,
editAutomation,
testOutput,
} from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import TemplateInput from './template-input/TemplateInput'; import OscOutputForm from './OscOutputForm';
import OutputCard, { type TestState } from './OutputCard';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
/** how long a successful test keeps its confirmation on screen */
const testFeedbackDuration = 2000;
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
triggers: Trigger[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
/**
* The triggers the server holds for this automation, as far as this form knows.
*
* Seeded from a snapshot taken when the form opens, never from the live prop: settings are
* polled, so a trigger created elsewhere while this form is open must not be deleted by a
* save that never saw it. It then advances as each request succeeds, so a save that fails
* half way leaves only the outstanding work for the retry.
*/
const [syncedTriggers, setSyncedTriggers] = useState<Trigger[]>(() =>
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
);
const syncedCycles = useMemo(
() => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))),
[syncedTriggers],
);
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
const cyclesAreDirty =
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.includes(cycle));
const toggleCycle = (cycle: TimerLifeCycle) => {
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
};
const triggersToRemove = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
/**
* Test results are keyed by the field array id rather than the index:
* removing an output shifts every index after it, which would leave feedback on the wrong row
*/
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
clearErrors,
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
@@ -59,10 +110,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
} = useForm<AutomationDTO>({ } = useForm<AutomationDTO>({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
title: automation?.title ?? '', title: automation.title,
filterRule: automation?.filterRule ?? 'all', filterRule: automation.filterRule,
filters: automation?.filters ?? [], filters: automation.filters,
outputs: automation?.outputs ?? [], outputs: automation.outputs,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -92,6 +143,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title'); setFocus('title');
}, [setFocus]); }, [setFocus]);
// the timers outlive a fast close, clearing them avoids setting state on an unmounted form
useEffect(() => {
const timers = feedbackTimers.current;
return () => Object.values(timers).forEach(clearTimeout);
}, []);
const reportTest = (key: string, state: TestState) => {
setTestResults((prev) => ({ ...prev, [key]: state }));
clearTimeout(feedbackTimers.current[key]);
if (state.status === 'ok') {
feedbackTimers.current[key] = setTimeout(() => {
setTestResults((prev) => {
const { [key]: _discarded, ...rest } = prev;
return rest;
});
}, testFeedbackDuration);
}
};
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
const handleAddNewFilter = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -105,364 +178,364 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' }); appendOutput({ type: 'http', url: '' });
}; };
const handleAddnewOntimeAction = () => { const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
const handleTestOSCOutput = async (index: number) => { /**
* Sends a single output as configured, without saving the automation.
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
*/
const handleTest = async (index: number, key: string) => {
const values = getValues(`outputs.${index}`);
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return;
}
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
reportTest(key, { status: 'sending' });
try { try {
const values = getValues(`outputs.${index}`) as OSCOutput; // NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data
if (!values.targetIP || !values.targetPort || !values.address) { await testOutput(values);
return; reportTest(key, { status: 'ok', message: 'Sent' });
} } catch (error) {
await testOutput({ reportTest(key, { status: 'error', message: maybeAxiosError(error) });
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
} }
}; };
const handleTestHTTPOutput = async (index: number) => { /**
try { * Reconciles an existing automation's lifecycle selection against the global triggers.
const values = getValues(`outputs.${index}`) as HTTPOutput; * New automations and their triggers are created together in one request.
if (!values.url) { *
return; * Every request advances the synced snapshot as it succeeds, so pressing save again after
} * a failure half way through retries only what is left. Without that a retry would re-add
await testOutput({ * a trigger it already created, and re-delete one it already deleted, which the server
type: 'http', * rejects outright.
url: values.url, */
}); const syncTriggers = async (automationId: string, title: string) => {
} catch (_error) { for (const trigger of triggersToRemove) {
/** we dont handle errors here, users should use the network tab */ await deleteTrigger(trigger.id);
setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== trigger.id));
} }
};
const handleTestOntimeAction = async (index: number) => { const toAdd = selectedCycles.filter((cycle) => !syncedCycles.includes(cycle));
try { for (const cycle of toAdd) {
const values = getValues(`outputs.${index}`) as OntimeAction; const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
// NOTE: there is no meaningful validation to do here, we let the server deal with the data setSyncedTriggers((prev) => [...prev, created]);
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) { // a stale failure from the previous attempt would otherwise sit under a successful retry
await handleEdit(automation.id, { id: automation.id, ...values }); clearErrors('root');
} else {
await handleCreate(values); try {
if (!isAutomation(automation)) {
await addAutomation(
values,
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
);
refetch();
onClose();
return;
}
await editAutomation(automation.id, { id: automation.id, ...values });
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
return;
} }
try {
await syncTriggers(automation.id, values.title);
} catch (error) {
// the automation itself is saved, only its triggers failed. Keep the form open so the user can retry
refetch();
setError('root', { message: `Automation saved, but its triggers failed: ${maybeAxiosError(error)}` });
return;
}
refetch(); refetch();
onClose();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
}; };
const canSubmit = !isSubmitting && isDirty && isValid; const describeFilter = (index: number): string | null => {
const field = watch(`filters.${index}.field`);
if (!field) {
return null;
}
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field;
const operator = watch(`filters.${index}.operator`);
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator;
const value = watch(`filters.${index}.value`);
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`;
};
/**
* A failed save reports itself as a root error, which react-hook-form counts against
* isValid. Left alone that disables the very retry the message is asking the user to make,
* so a root error on its own does not block submitting again.
*/
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
return ( return (
<Panel.Indent <Modal
as='form' isOpen
name='automation-form' onClose={onClose}
onSubmit={handleSubmit(onSubmit)} showBackdrop
className={style.outerColumn} showCloseButton
onKeyDown={(event) => preventEscape(event, onClose)} size='wide'
> title={isEdit ? 'Edit automation' : 'Create automation'}
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader> bodyElements={
<div className={style.innerSection}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<h3>Automation options</h3> <ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
<div className={style.titleSection}> <div className={style.innerColumn}>
<label> <h3>Automation options</h3>
Title <div className={style.titleSection}>
<Input <label>
{...register('title', { required: { value: true, message: 'Required field' } })} Title
fluid <Input
placeholder='Load preset' {...register('title', { required: { value: true, message: 'Required field' } })}
/> fluid
</label> placeholder='Load preset'
<Panel.Error>{errors.title?.message}</Panel.Error> />
</div> </label>
</div> <Panel.Error>{errors.title?.message}</Panel.Error>
</div>
<div className={style.innerSection}> <div className={style.titleSection}>
<h3>Filters (optional)</h3> <label id='runs-on-label'>Runs on</label>
<div className={style.ruleSection}> <Panel.Description>
<label> Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
Trigger outputs if single event from the event editor.
<RadioGroup </Panel.Description>
orientation='horizontal' <Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
value={watch('filterRule')} {cycles.map(({ label, value }) => {
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} const isSelected = selectedCycles.includes(value);
items={[ return (
{ value: 'all', label: 'All filters pass' }, <Button
{ value: 'any', label: 'Any filter passes' }, key={value}
]} size='small'
/> variant={isSelected ? 'primary' : 'subtle'}
</label> aria-pressed={isSelected}
{fieldFilters.map((field, index) => { onClick={() => toggleCycle(value)}
const key = `filters.${index}.field.${field.id}`; >
return ( {label}
<div key={key} className={style.filterSection}> </Button>
<label> );
Runtime data source })}
<Select<string | null> </Panel.InlineElements>
// need to normalize '' to null for the Select to show the placeholder {hasContinuousCycle && (
value={watch(`filters.${index}.field`) || null} <Panel.Description tone='warning'>
onValueChange={(value) => { Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
if (value === null) return; mean to send on every tick.
setValue(`filters.${index}.field`, value, { shouldDirty: true }); </Panel.Description>
}} )}
options={fieldList.map(({ value, label }) => ({ {triggersToRemove.length > 0 && (
value, <Panel.Description tone='warning'>
label, {`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
disabled: value === null, .map((trigger) => trigger.title)
}))} .join(', ')}`}
aria-label='Event field' </Panel.Description>
/> )}
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> </div>
</label> </div>
<label>
Matching condition <div className={style.innerColumn}>
<Select <h3>Filters (optional)</h3>
value={watch(`filters.${index}.operator`)} <Panel.Description>
onValueChange={(value: string | null) => { Without filters the outputs are sent every time the automation is triggered.
if (value === null) return; </Panel.Description>
setValue( <div className={style.ruleSection}>
`filters.${index}.operator`, {fieldFilters.length > 1 && (
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains', <label>
{ shouldDirty: true }, Trigger outputs if
); <RadioGroup
}} orientation='horizontal'
options={[ value={watch('filterRule')}
{ value: 'equals', label: 'equals' }, onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
{ value: 'not_equals', label: 'not equals' }, items={[
{ value: 'contains', label: 'contains' }, { value: 'all', label: 'All filters pass' },
]} { value: 'any', label: 'Any filter passes' },
aria-label='Operator' ]}
/> />
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> </label>
</label> )}
<label> {fieldFilters.map((field, index) => {
Value to match const description = describeFilter(index);
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' /> return (
</label> <div key={field.id} className={style.card}>
<div className={style.cardHeader}>
<Tag>Filter</Tag>
<span className={style.cardSummary}>{description}</span>
<IconButton
aria-label='Delete filter'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
<div className={style.cardBody}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
shouldDirty: true,
});
}}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
</div>
</div>
);
})}
<div> <div>
<span>&nbsp;</span> <Button onClick={handleAddNewFilter}>
<div> Add filter <IoAdd />
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}> </Button>
<IoTrash />
</IconButton>
</div>
</div> </div>
</div> </div>
); </div>
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
</div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
or to change properties of Ontime itself. <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> </Info>
</Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.length === 0 && (
if (isOSCOutput(output)) { <Panel.EmptyState
const rowErrors = errors.outputs?.[index] as title='This automation does nothing yet'
| { description='An automation without outputs will be triggered, but it has nothing to send.'
targetIP?: { message?: string }; />
targetPort?: { message?: string }; )}
address?: { message?: string };
args?: { message?: string }; {fieldOutputs.map((output, index) => {
const rowErrors = getOutputErrors(index);
const cardProps = {
testState: testResults[output.id],
onTest: () => handleTest(index, output.id),
onDelete: () => removeOutput(index),
};
if (isOSCOutput(output)) {
return (
<OutputCard
key={output.id}
label='OSC'
kindClass={style.tagOsc}
summary={watch(`outputs.${index}.address`)}
{...cardProps}
>
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
} }
| undefined;
return ( if (isHTTPOutput(output)) {
<div key={output.id} className={style.outputCard}> return (
<Tag>OSC</Tag> <OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<div className={style.oscSection}> <HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
<label> </OutputCard>
Target IP );
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
} }
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<Input
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isOntimeAction(output)) { if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as return (
| { <OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
action?: { message?: string }; <OntimeActionForm
time?: { message?: string }; value={output.action}
text?: { message?: string }; index={index}
visible?: { message?: string }; register={register}
secondarySource?: { message?: string }; rowErrors={rowErrors}
setValue={setValue}
watch={watch}
/>
</OutputCard>
);
} }
| undefined;
return ( return null;
<div key={output.id} className={style.outputCard}> })}
<Tag>Ontime action</Tag> <div>
<OntimeActionForm <DropdownMenu
value={output.action} render={<Button />}
index={index} items={[
register={register} {
rowErrors={rowErrors} type: 'item',
setValue={setValue} label: 'OSC',
watch={watch} description: 'Send an OSC message to a device on the network',
onClick: handleAddNewOSCOutput,
},
{
type: 'item',
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item',
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddNewOntimeAction,
},
]}
> >
<span>&nbsp;</span> Add output <IoAdd />
<Panel.InlineElements relation='inner'> </DropdownMenu>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div> </div>
); </div>
} </ScrollArea>
</form>
// there should be no other output types }
return null; footerElements={
})} <>
<Panel.InlineElements relation='inner'> {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={handleAddNewOSCOutput}> <Button onClick={onClose}>Cancel</Button>
OSC <IoAdd /> <Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
</Button> </Button>
<Button onClick={handleAddNewHTTPOutput}> </>
HTTP <IoAdd /> }
</Button> />
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
); );
} }
@@ -19,24 +19,27 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
return ( return (
<> <>
<Panel.Header>Automation</Panel.Header> <Panel.Header>Automation</Panel.Header>
<Panel.Section> <div ref={settingsRef}>
<Panel.Loader isLoading={isLoading} /> <AutomationSettingsForm
<div ref={settingsRef}> enabledAutomations={data.enabledAutomations}
<AutomationSettingsForm enabledOscIn={data.enabledOscIn}
enabledAutomations={data.enabledAutomations} oscPortIn={data.oscPortIn}
enabledOscIn={data.enabledOscIn} automationState={automationState}
oscPortIn={data.oscPortIn} oscInputState={oscInputState}
automationState={automationState} isLoading={isLoading}
oscInputState={oscInputState} />
/> </div>
</div> <div ref={automationsRef}>
<div ref={automationsRef}> <AutomationsList
<AutomationsList automations={data.automations} enabledAutomations={automationState} /> automations={data.automations}
</div> triggers={data.triggers}
<div ref={triggersRef}> enabledAutomations={automationState}
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} /> isLoading={isLoading}
</div> />
</Panel.Section> </div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
</div>
</> </>
); );
} }
@@ -21,6 +21,7 @@ interface AutomationSettingsProps {
oscPortIn: number; oscPortIn: number;
automationState?: boolean; automationState?: boolean;
oscInputState?: boolean; oscInputState?: boolean;
isLoading?: boolean;
} }
export default function AutomationSettingsForm({ export default function AutomationSettingsForm({
@@ -29,6 +30,7 @@ export default function AutomationSettingsForm({
oscPortIn, oscPortIn,
automationState, automationState,
oscInputState, oscInputState,
isLoading,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const { const {
handleSubmit, handleSubmit,
@@ -65,126 +67,130 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn'); const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Automation settings <Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.InlineElements> <Panel.SubHeader>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> Automation settings
Revert to saved <Panel.InlineElements>
</Button> <Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
<Button Revert to saved
variant='primary' </Button>
type='submit' <Button
form='automation-settings-form' variant='primary'
disabled={!canSubmit} type='submit'
loading={isSubmitting} form='automation-settings-form'
> disabled={!canSubmit}
Save loading={isSubmitting}
</Button> >
</Panel.InlineElements> Save
</Panel.SubHeader> </Button>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} </Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span> <span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
</Info> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Panel.Section> </Info>
</Panel.Section>
<Panel.Section <Panel.Section
as='form' as='form'
id='automation-settings-form' id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)} onKeyDown={(event) => preventEscape(event, onReset)}
> >
<Panel.Loader isLoading={false} /> <Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.Title>Automation</Panel.Title> <Panel.ListGroup>
<Panel.ListGroup> {isOntimeCloud && (
<Panel.ListItem> <Info>For security reasons OSC integrations are not available in the cloud service.</Info>
<Panel.Field )}
title={ <Panel.ListItem>
<> <Panel.Field
<span>Enable automations</span> title={
{automationState === false && <Tag variant='warning'>OFF</Tag>} <>
</> <span>OSC input</span>
} {oscInputState === false && <Tag variant='warning'>OFF</Tag>}
description={ </>
automationState === false }
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.' description={
: 'Allow Ontime to send messages on lifecycle triggers' oscInputState === false
} ? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
descriptionTone={automationState === false ? 'warning' : 'default'} : 'Allow control of Ontime through OSC'
error={errors.enabledAutomations?.message} }
/> descriptionTone={oscInputState === false ? 'warning' : 'default'}
<Switch error={errors.enabledOscIn?.message}
size='large' />
checked={automationsEnabled} <Switch
onCheckedChange={(value: boolean) => size='large'
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true }) checked={oscInputEnabled}
} onCheckedChange={(value: boolean) =>
/> setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
</Panel.ListItem> }
</Panel.ListGroup> />
<Panel.Title>OSC Input</Panel.Title> </Panel.ListItem>
<Panel.ListItem>
<Panel.ListGroup> <Panel.Field
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>} title='Listen on port'
<Panel.ListItem> description='Port for incoming OSC. Default: 8888'
<Panel.Field error={errors.oscPortIn?.message}
title={ />
<> <Input
<span>OSC input</span> id='oscPortIn'
{oscInputState === false && <Tag variant='warning'>OFF</Tag>} placeholder='8888'
</> maxLength={5}
} style={{ textAlign: 'right', width: '5rem' }}
description={ type='number'
oscInputState === false fluid
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.' {...register('oscPortIn', {
: 'Allow control of Ontime through OSC' required: { value: true, message: 'Required field' },
} max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
descriptionTone={oscInputState === false ? 'warning' : 'default'} min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
error={errors.enabledOscIn?.message} pattern: {
/> value: isOnlyNumbers,
<Switch message: 'Value should be numeric',
size='large' },
checked={oscInputEnabled} })}
onCheckedChange={(value: boolean) => />
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) </Panel.ListItem>
} </Panel.ListGroup>
/> </Panel.Section>
</Panel.ListItem> </Panel.Card>
<Panel.ListItem> </Panel.Section>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
maxLength={5}
style={{ textAlign: 'right', width: '5rem' }}
type='number'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
); );
} }
@@ -0,0 +1,20 @@
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
.table td {
vertical-align: middle;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.actions {
justify-content: flex-end;
flex-wrap: nowrap;
}
.muted {
color: $muted-gray;
}
@@ -1,18 +1,25 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog';
const automationPlaceholder: AutomationDTO = { import style from './AutomationsList.module.scss';
const emptyAutomation: AutomationDTO = {
title: '', title: '',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -21,112 +28,180 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
triggers: Trigger[];
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function AutomationsList(props: AutomationsListProps) { export default function AutomationsList({
const { automations, enabledAutomations } = props; automations,
triggers,
enabledAutomations,
isLoading,
}: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
const handleDelete = async (id: string) => { const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
try { const automationList = Object.values(automations);
setDeleteError(null);
await deleteAutomation(id); /** a recipe creates the automation itself, so it lands in the list rather than in a form */
} catch (error) { const handleCreated = async () => {
setDeleteError(maybeAxiosError(error)); setIsPickingStart(false);
} finally { await refetch();
refetch();
}
}; };
const arrayAutomations = Object.keys(automations); const handleStartEmpty = () => {
setIsPickingStart(false);
setEditing(emptyAutomation);
};
const handleDeleted = async () => {
setDeleteTarget(null);
await refetch();
};
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Manage automations {editing !== null && (
<Button <AutomationForm
type='submit' // the form snapshots the automation's lifecycles on mount, so it must never be
disabled={Boolean(automationFormData)} // reused across two different automations
onClick={() => setAutomationFormData(automationPlaceholder)} key={isAutomation(editing) ? editing.id : 'new'}
> automation={editing}
New <IoAdd /> triggers={triggers}
</Button> onClose={() => setEditing(null)}
</Panel.SubHeader> />
)}
{isPickingStart && (
<NewAutomationDialog
onClose={() => setIsPickingStart(false)}
onStartEmpty={handleStartEmpty}
onCreated={handleCreated}
/>
)}
{deleteTarget !== null && (
<DeleteAutomationDialog
automation={deleteTarget}
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
)}
<Panel.SubHeader>
Manage automations
<Button onClick={() => setIsPickingStart(true)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
{enabledAutomations === false && ( <Panel.Section>
<Info> {enabledAutomations === false && (
Automations are disabled. You can still manage automation definitions here, but they will not run until <Info type='warning'>
enabled. <Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
</Info> <Info.Footer>
)} {/* the master switch is at the top of the panel, out of sight once the list has rows */}
<Button size='small' onClick={() => setLocation('automation__settings')}>
{automationFormData !== null && ( Go to automation settings
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} /> </Button>
)} </Info.Footer>
</Info>
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)} )}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) { <Panel.Table className={style.table}>
return null; <thead>
} <tr>
return ( <th style={{ width: '35%' }}>Title</th>
<Fragment key={automationId}> <th style={{ width: '25%' }}>Runs on</th>
<tr> <th style={{ width: '15%' }}>Filter rule</th>
<td>{automations[automationId].title}</td> <th style={{ width: '15%' }}>Sends</th>
<td> <th />
<Tag>{automations[automationId].filterRule}</Tag> </tr>
</td> </thead>
<td>{automations[automationId].filters.length}</td> <tbody>
<td>{automations[automationId].outputs.length}</td> {!isLoading && automationList.length === 0 && (
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.TableEmpty
<IconButton title='No automations yet'
variant='ghosted-white' description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
aria-label='Edit entry' action={
onClick={() => setAutomationFormData(automations[automationId])} <Button variant='primary' onClick={() => setIsPickingStart(true)}>
> New automation <IoAdd />
<IoPencil /> </Button>
</IconButton> }
<IconButton />
variant='ghosted-destructive' )}
aria-label='Delete entry' {automationList.map((automation) => {
onClick={() => handleDelete(automationId)} const lifecycles = lifecyclesByAutomation[automation.id] ?? [];
> const outputs = summariseOutputs(automation.outputs);
<IoTrash />
</IconButton> return (
</Panel.InlineElements> <tr key={automation.id}>
</tr> <td>{automation.title}</td>
</Fragment> <td>
); {/*
})} * Only global triggers are visible here: an automation can also be attached to
{deleteError && ( * single events, which live in the rundown. An empty cell is therefore not the
<tr> * same as never running, so it says nothing rather than claiming that.
<td colSpan={5}> */}
<Panel.Error>{deleteError}</Panel.Error> {lifecycles.length === 0 ? (
</td> <span className={style.muted}></span>
</tr> ) : (
)} <div className={style.tags}>
</tbody> {lifecycles.map((cycle) => (
</Panel.Table> <Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
</Panel.Card> ))}
</div>
)}
</td>
<td>
{automation.filters.length === 0 ? (
<span className={style.muted}></span>
) : (
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
)}
</td>
<td>
<div className={style.tags}>
{outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
outputs.map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</div>
</td>
<td>
<div className={cx([style.tags, style.actions])}>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setEditing(automation)}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => setDeleteTarget(automation)}
>
<IoTrash />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
); );
} }
@@ -0,0 +1,96 @@
import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import * as Panel from '../../panel-utils/PanelUtils';
interface DeleteAutomationDialogProps {
automation: Automation;
/** global triggers pointing at this automation, they are deleted along with it */
attachedTriggers: Trigger[];
onCancel: () => void;
onDeleted: () => void;
}
/**
* Deleting takes the automation's global triggers with it, so say so before it happens rather
* than leaving the user to discover it in the triggers list.
*
* An automation attached to an event is still refused by the server: that reference lives in
* the rundown and removing it is an edit to the show, not to this panel.
*/
export default function DeleteAutomationDialog({
automation,
attachedTriggers,
onCancel,
onDeleted,
}: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setError(null);
setIsDeleting(true);
try {
await deleteAutomation(automation.id);
onDeleted();
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setIsDeleting(false);
}
};
return (
<Dialog
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title='Delete automation'
bodyElements={
<Panel.Section>
<Panel.Paragraph>
Delete <strong>{automation.title}</strong>? This cannot be undone.
</Panel.Paragraph>
{attachedTriggers.length > 0 && (
<Info type='warning'>
<Info.Title>
{attachedTriggers.length === 1
? 'Its trigger is deleted with it'
: `Its ${attachedTriggers.length} triggers are deleted with it`}
</Info.Title>
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
</Info>
)}
{error && (
<Info type='error'>
<Info.Title>Could not delete this automation</Info.Title>
<Info.Body>{error}</Info.Body>
<Info.Footer>
An automation attached to a single event has to be removed from that event first, in the event editor.
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
Delete
</Button>
</>
}
/>
);
}

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