Compare commits

..

70 Commits

Author SHA1 Message Date
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
Carlos Valente daa032e92c ux(finder): add navigation shortcuts 2026-07-14 12:04:44 +02:00
Carlos Valente ae65ec97d6 fix(shortcut): correct shortcut to delete 2026-07-14 12:04:44 +02:00
Carlos Valente e7e4302100 refactor(shortcuts): improve readability of info element 2026-07-14 12:04:44 +02:00
Carlos Valente 7e7d35980a refactor(op): improve status recognition 2026-07-14 11:58:59 +02:00
Carlos Valente 5001dda284 feat(op): add group visual relation 2026-07-14 11:58:59 +02:00
Carlos Valente a75d35bdc0 refactor(op): improve delay indicator styles 2026-07-14 11:58:59 +02:00
Claude 2e631194e1 fix(ui): improve progress bar with local interpolation
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-14 11:25:22 +02:00
Carlos Valente f39892dc6e feat(navigation): persist view params customisation 2026-07-13 18:44:38 +02:00
Carlos Valente 6bcfd87851 refactor(rundown): show locked duration group indicator 2026-07-13 17:34:11 +02:00
Claude 4addb50ca4 feat(countdown): allow counting down to a group 2026-07-13 17:29:56 +02:00
Carlos Valente 161ab3fe13 refactor(timer): elapsed is active time from start 2026-07-13 15:34:31 +02:00
Carlos Valente 4f4626a163 refactor: allow larger settings panels 2026-07-13 15:33:23 +02:00
Claude 82d5f02ea0 refactor(mcp): guide agents through creating clean rundowns 2026-07-13 15:33:23 +02:00
Claude 05aaeddf22 resolver: drop dead ignoreDeprecations from tsconfig.dts.json
Confirmed empirically that it now suppresses nothing under TS 7.0.2:
compiling tsconfig.dts.json directly with this flag set to "6.0",
"7.0", or removed entirely all produce identical (clean) output. It
was a leftover suppression the previous tsdown mode never actually
needed once nothing in the config triggers a deprecation warning.
2026-07-13 14:27:31 +02:00
Claude 949ab2b7dc Upgrade TypeScript to 7.0.2 (native Go compiler)
TypeScript 7 ships as a native Go binary and drastically trims the
package's public JS API surface (no more `main`, and `exports['.']`
now only resolves to lib/version.cjs) since the programmatic compiler
API isn't stable again until 7.1. This broke apps/resolver's tsdown
build, which used rolldown-plugin-dts' tsc-program mode (import ts
from 'typescript'; ts.createProgram(...)) for declaration generation.

Fix by switching that build to isolatedDeclarations, which lets
rolldown-plugin-dts generate .d.ts via its oxc-based path instead of
the TS compiler API entirely. That surfaced two isolatedDeclarations
violations in ontime-types (array/Object.keys initializers lacking
explicit annotations), fixed with explicit `string[]` types matching
their existing inferred type. Also bumped tsdown to pick up its latest
rolldown-plugin-dts, and disabled allowJs in tsconfig.dts.json since
it's incompatible with isolatedDeclarations.

Full workspace typecheck, lint, build, and test suite (1186 tests)
pass under TypeScript 7.0.2.
2026-07-13 14:27:31 +02:00
Alex Christoffer Rasmussen f12d3ca59c Small cleanup (#2114)
* refactor: propper memo for `ScheduleContext` value

* chore: removed unused file `cuesheet.utils.ts`

* fix: memoize event in ScheduleProvider
2026-07-11 22:20:32 +02:00
Carlos Valente 42147d4d5b allow editing cuesheet link permissions 2026-07-01 13:24:47 +02:00
Carlos Valente 7850358039 bump ontime to version 4.10.0 2026-07-01 13:20:04 +02:00
Claude 52cae1e22f feat(timeline): add group reference
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2026-07-01 12:48:58 +02:00
Carlos Valente 48f9fb2609 expose mcp server in ontime 2026-07-01 12:48:40 +02:00
Carlos Valente ce8a4d230b refactor(translations): allow resetting to default values 2026-07-01 09:02:13 +02:00
Carlos Valente 1553573b94 feat(url-presets): allow presets to appear as nav menu items (#2110) 2026-06-28 18:07:02 +02:00
Alex Christoffer Rasmussen 41a09bf5c3 fix: send refetch on all rundown edits (#2098)
* fix(server): send refetch on all rundown edits

* fix(test): cuesheet tabing

* chore: remove unneeded async

* refactor: small cleanups

* chore: spelling

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

* chore: remove unneded async/await

---------

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

* chore: cleanup css

* test: cuesheet beckground edit from empty state

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

* ui: move dropdown

* feat: follow loaded

* ui: background edit warning

ui: fix disable radio button

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

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

* fix: better fallback

* fix: default to is isCurrentRundown for nav bar colour

* chore: rename navigate to cuesheet

---------

Co-authored-by: alex-arc <ac@omnivox.dk>
2026-06-07 13:33:38 +02:00
Carlos Valente 199dea5df6 fix(cuesheet): prevent timer cells from submitting on tab-out or escape (#2083) 2026-06-07 12:59:18 +02:00
Alex Christoffer Rasmussen 5a3ea9f7d0 fix: playwright ci test (#2094) 2026-05-28 09:50:20 +02:00
304 changed files with 12902 additions and 4079 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
+10 -2
View File
@@ -15,6 +15,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -54,6 +58,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -81,11 +89,11 @@ jobs:
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: | restore-keys: |
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
- run: npx playwright install --with-deps - run: pnpm playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install --with-deps run: pnpm playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm e2e run: pnpm e2e
+3
View File
@@ -54,3 +54,6 @@ ontime-data/
# temporary write files # temporary write files
**.tmp **.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.22.2 v22.22.3
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.8.0", "version": "4.11.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",
+16 -16
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.8.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.3.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -12,27 +12,27 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.9", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.18.0",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.3", "react": "^19.2.7",
"react-colorful": "^5.6.1", "react-colorful": "^5.7.0",
"react-dom": "^19.2.3", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.72.0", "react-hook-form": "^7.80.0",
"react-icons": "5.5.0", "react-icons": "5.6.0",
"react-router": "^7.11.0", "react-router": "^8.0.1",
"react-virtuoso": "^4.17.0", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.9" "zustand": "^5.0.14"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+32 -11
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -34,19 +35,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
throw new Error('Invalid rundown payload'); throw new Error('Invalid rundown payload');
} }
return res.data; return res.data;
}
function isValidRundown(x: any): x is Rundown { /**
return ( * HTTP request to fetch all entries in the given rundown
x && */
typeof x === 'object' && export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
typeof x.id === 'string' && const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
Array.isArray(x.order) && if (!isValidRundown(res.data)) {
Array.isArray(x.flatOrder) && throw new Error('Invalid rundown payload');
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
} }
return res.data;
} }
/** /**
@@ -84,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 ==========================
@@ -197,3 +205,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
} }
// #endregion operations on rundown entries ======================= // #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
@@ -1,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;
} }
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
value: string; value: string;
onChange: (event: ChangeEvent) => void; onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: (event: ChangeEvent) => void; onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
} }
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean; allowKeyboardNavigation?: boolean;
}, },
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit * @param {string} valueToSubmit
*/ */
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value // force the text to be the initial value
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
event.stopPropagation(); event.stopPropagation();
} }
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event); hotKeyHandler(event);
}; };
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
return { return {
value: text, value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value), onChange: (event) => handleChange(event.target.value),
onBlur: (event: ChangeEvent) => { onBlur: (event) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) { if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value); handleSubmit(event.target.value);
} }
}, },
onKeyDown: keyHandler, onKeyDown: keyHandler,
@@ -0,0 +1,14 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -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 && (
@@ -38,8 +38,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,5 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -8,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;
@@ -23,6 +25,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -34,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - getProgress(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;
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -1,6 +1,8 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
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';
@@ -17,6 +19,8 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
useHotkeys([ useHotkeys([
[ [
@@ -46,6 +50,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
@@ -1,4 +1,29 @@
.linkIcon { .trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto; margin-left: auto;
}
.linkIcon {
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,9 +1,17 @@
import { PropsWithChildren } from 'react'; import { MouseEvent, PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router'; import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
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';
@@ -16,32 +24,104 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem <ElectronNavigationItem current={current} to={to} postAction={postAction}>
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> </ElectronNavigationItem>
</NavigationMenuItem>
); );
} }
return ( return (
<NavigationMenuItem <BrowserNavigationItem current={current} to={to} postAction={postAction}>
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,3 +22,19 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<IconButton <div className={style.buttonWithIndicator}>
variant='subtle-white' <IconButton
size='xlarge' variant='subtle-white'
onClick={toggleMenu} size='xlarge'
aria-label='toggle menu' onClick={toggleMenu}
data-testid='navigation__toggle-menu' aria-label='toggle menu'
> data-testid='navigation__toggle-menu'
<IoApps /> >
</IconButton> <IoApps />
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -14,6 +14,4 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,18 +1,18 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress'; import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import './ProgressBar.scss'; 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 = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,18 +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 {
width: 100%; display: block;
opacity: 0.8; width: min(100%, 14rem);
margin: 0 auto -1.5rem;
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: 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>
); );
} }
@@ -8,13 +8,11 @@
padding-top: 10vh; padding-top: 10vh;
} }
.empty { .inline {
width: 100%; display: flex;
opacity: 0.8; align-items: center;
} justify-content: center;
gap: 1rem;
.text { margin-top: 1em;
font-weight: 600;
font-size: 2em;
} }
} }
@@ -1,18 +1,32 @@
import EmptyImage from '../../../assets/images/empty.svg?react'; import { SupportedEntry } from 'ontime-types';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import style from './EmptyTableBody.module.scss'; import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps { interface EmptyTableBodyProps {
text: string; handleAddNew?: (type: SupportedEntry) => void;
} }
export default function EmptyTableBody({ text }: EmptyTableBodyProps) { export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
return ( return (
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{text && <span className={style.text}>{text}</span>} {handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -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>) {
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
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 +28,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 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -12,6 +12,7 @@ import {
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Normalised rundown data * Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export default function useRundown() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -34,7 +38,7 @@ export default function useRundown() {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap) // Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => { useEffect(() => {
if (!data || loadedRundownId) return; if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data); queryClient.setQueryData(getRundownQueryKey(data.id), data);
@@ -115,3 +119,21 @@ export function useRundownAuxData() {
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -0,0 +1,49 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate =
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
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// 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
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+39 -15
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,
@@ -16,6 +15,7 @@ import {
TimeStrategy, TimeStrategy,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { import {
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
@@ -59,13 +59,26 @@ 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 * Gather utilities for actions on entries in the loaded rundown.
*/ */
export const useEntryActions = () => { export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -75,15 +88,16 @@ export const useEntryActions = () => {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded; if (scopedRundownId !== undefined) {
if (loadedRundownId) { return getRundownQueryKey(scopedRundownId);
return getRundownQueryKey(loadedRundownId);
} }
return CURRENT_RUNDOWN_QUERY_KEY; const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
}, [queryClient]); return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/** /**
* Returns the currently loaded rundown * Returns the currently loaded rundown
@@ -111,7 +125,7 @@ export const useEntryActions = () => {
* @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 });
@@ -140,8 +154,9 @@ export const useEntryActions = () => {
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);
@@ -184,7 +199,7 @@ export const useEntryActions = () => {
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) {
@@ -196,8 +211,8 @@ export const useEntryActions = () => {
// ************* 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;
@@ -232,6 +247,14 @@ export const useEntryActions = () => {
} }
} }
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
const parentId = resolveInsertParent(rundownData, newEntry);
const maybeParent = parentId ? rundownData.entries[parentId] : null;
if (maybeParent && isOntimeGroup(maybeParent)) {
newEntry.colour = maybeParent.colour;
}
}
try { try {
await addEntryMutation([rundownId, newEntry]); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
@@ -247,6 +270,7 @@ export const useEntryActions = () => {
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
defaultTimeStrategy, defaultTimeStrategy,
inheritGroupColour,
addEntryMutation, addEntryMutation,
], ],
); );
@@ -266,7 +290,7 @@ export const useEntryActions = () => {
* 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) {
@@ -1004,7 +1028,7 @@ export const useEntryActions = () => {
updateTimer, updateTimer,
], ],
); );
}; }
/** /**
* Utility to optimistically delete entries from client cache * Utility to optimistically delete entries from client cache
@@ -1039,7 +1063,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;
+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,
})); }));
@@ -0,0 +1,87 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
defaultDangerTime: string; defaultDangerTime: string;
defaultTimerType: TimerType; defaultTimerType: TimerType;
defaultEndAction: EndAction; defaultEndAction: EndAction;
inheritGroupColour: boolean;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void; setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void; setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void; setDangerTime: (dangerTime: string) => void;
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
dangerTime: '00:01:00', // 60000 same as backend dangerTime: '00:01:00', // 60000 same as backend
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
endAction: EndAction.None, endAction: EndAction.None,
inheritGroupColour: false,
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
DefaultDangerTime = 'ontime-default-danger-time', DefaultDangerTime = 'ontime-default-danger-time',
DefaultTimerType = 'ontime-default-timer-type', DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action', DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.getItem(EditorSettingsKeys.DefaultEndAction), localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction, editorSettingsDefaults.endAction,
), ),
inheritGroupColour: booleanFromLocalStorage(
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction)); localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction }; return { defaultEndAction };
}), }),
setInheritGroupColour: (inheritGroupColour) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
}; };
}); });
@@ -0,0 +1,62 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { linkToOtherHost } from '../linkUtils'; import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -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('');
});
});
@@ -37,6 +37,16 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {},
},
];
const disabledPresets: URLPreset[] = [
{
enabled: false,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {}, options: {},
}, },
]; ];
@@ -47,6 +57,16 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage'); expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
}); });
it('checks if the current location matches an enabled preset target', () => {
const location = resolvePath('/timer');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('does not redirect disabled presets', () => {
const location = resolvePath('/demopage');
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
});
it('returns null when already on a preset path', () => { it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage'); const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull(); expect(getRouteFromPreset(location, presets)).toBeNull();
@@ -86,6 +106,11 @@ describe('getRouteFromPreset()', () => {
const location = resolvePath('/demopage?n=1&token=123'); const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
}); });
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
}); });
describe('cuesheet presets', () => { describe('cuesheet presets', () => {
@@ -95,6 +120,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -107,6 +133,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -115,6 +142,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -196,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -207,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -218,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -229,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
+20 -1
View File
@@ -37,7 +37,12 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location; // split off any query string so it is not encoded into the pathname
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -54,3 +59,17 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
+7 -2
View File
@@ -14,8 +14,8 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CUSTOM_FIELDS,
CURRENT_RUNDOWN_QUERY_KEY, CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
RUNDOWN, RUNDOWN,
@@ -213,6 +213,9 @@ export const connectSocket = () => {
case RefetchKey.Settings: case RefetchKey.Settings:
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
break; break;
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -242,7 +245,9 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
// skip if we dont recognise the ID the revision is lower // skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision !== null && revision === cachedRundown?.revision) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return; return;
} }
+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');
} }
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -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: rgba($blue-500, 0.16);
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: rgba($blue-500, 0.16);
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: 850px;
} }
.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: rgba($blue-500, 0.16);
> 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,7 +194,7 @@ $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 {
@@ -188,19 +219,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 +286,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>
</> </>
); );
@@ -1,6 +1,6 @@
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';
export default function AppVersion() { export default function AppVersion() {
@@ -8,28 +8,33 @@ export default function AppVersion() {
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={`Ontime ${appVersion}`}
<br /> description={
{`A new version ${data.version} is available.`} <br /> isOntimeCloud
{isOntimeCloud ? ( ? `Version ${data.version} is available. Restart your stage to update.`
'You can restart your stage to get the latest available version.' : `Version ${data.version} is available.`
) : ( }
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink> />
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</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,13 @@
.outerColumn { .outerColumn {
display: flex;
flex-direction: column;
gap: 2rem; gap: 2rem;
margin-bottom: 2rem; font-size: calc(1rem - 1px);
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
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 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 { 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 { isAutomation, makeFieldList } from './automationUtils';
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
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';
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -185,284 +186,320 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid; const canSubmit = !isSubmitting && isDirty && isValid;
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)} title={isEdit ? 'Edit automation' : 'Create automation'}
> bodyElements={
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerSection}> <div className={style.innerColumn}>
<h3>Automation options</h3> <h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
<label> <label>
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
placeholder='Load preset' placeholder='Load preset'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
</div>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<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 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
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>
<span>&nbsp;</span>
<div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
</div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Filters (optional)</h3>
<Info> <div className={style.ruleSection}>
Automation outputs can be used to send data from Ontime to external software <br /> <label>
or to change properties of Ontime itself. Trigger outputs if
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> <RadioGroup
</Info> orientation='horizontal'
value={watch('filterRule')}
{fieldOutputs.map((output, index) => { onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
if (isOSCOutput(output)) { items={[
const rowErrors = errors.outputs?.[index] as { value: 'all', label: 'All filters pass' },
| { { value: 'any', label: 'Any filter passes' },
targetIP?: { message?: string }; ]}
targetPort?: { message?: string }; />
address?: { message?: string }; </label>
args?: { message?: string }; {fieldFilters.map((field, index) => {
} const key = `filters.${index}.field.${field.id}`;
| undefined; return (
<div key={key} className={style.filterSection}>
return ( <label>
<div key={output.id} className={style.outputCard}> Runtime data source
<Tag>OSC</Tag> <Select<string | null>
<div className={style.oscSection}> // need to normalize '' to null for the Select to show the placeholder
<label> value={watch(`filters.${index}.field`) || null}
Target IP onValueChange={(value) => {
<Input if (value === null) return;
{...register(`outputs.${index}.targetIP`, { setValue(`filters.${index}.field`, value, { shouldDirty: true });
required: { value: true, message: 'Required field' }, }}
})} options={fieldList.map(({ value, label }) => ({
fluid value,
placeholder='127.0.0.1' label,
/> disabled: value === null,
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error> }))}
</label> aria-label='Event field'
<label> />
Target Port <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
<Input </label>
{...register(`outputs.${index}.targetPort`, { <label>
required: { value: true, message: 'Required field' }, Matching condition
setValueAs: (value) => (value === '' ? 0 : Number(value)), <Select
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, value={watch(`filters.${index}.operator`)}
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, onValueChange={(value: string | null) => {
})} if (value === null) return;
fluid setValue(
type='number' `filters.${index}.operator`,
maxLength={5} value as
placeholder='8000' | 'equals'
/> | 'not_equals'
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error> | 'greater_than'
</label> | 'less_than'
<label> | 'contains'
Address | 'not_contains',
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' /> { shouldDirty: true },
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> );
</label> }}
<label> options={[
Arguments { value: 'equals', label: 'equals' },
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' /> { value: 'not_equals', label: 'not equals' },
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> { value: 'contains', label: 'contains' },
</label> ]}
<div> aria-label='Operator'
<span>&nbsp;</span> />
<Panel.InlineElements relation='inner'> <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}> </label>
Test <label>
</Button> Value to match
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
<IoTrash /> </label>
</IconButton> <div>
</Panel.InlineElements> <span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
</div>
</div> </div>
</div> );
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
); </div>
} </div>
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as <div className={style.innerColumn}>
| { <h3>Outputs</h3>
url?: { message?: string }; <Info>
} Automation outputs can be used to send data from Ontime to external software <br />
| undefined; or to change properties of Ontime itself. <br /> <br />
return ( Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<div key={output.id} className={style.outputCard}> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
<Tag>HTTP</Tag> </Info>
<div className={style.httpSection}>
<label> {fieldOutputs.map((output, index) => {
Target URL if (isOSCOutput(output)) {
<Input const rowErrors = errors.outputs?.[index] as
{...register(`outputs.${index}.url`, { | {
required: { value: true, message: 'Required field' }, targetIP?: { message?: string };
pattern: { targetPort?: { message?: string };
value: startsWithHttp, address?: { message?: string };
message: 'HTTP messages should target http:// or https://', args?: { message?: string };
}, }
})} | undefined;
fluid
placeholder='http://127.0.0.1/start/1' return (
/> <div key={output.id} className={style.outputCard}>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error> <Tag>OSC</Tag>
</label> <div className={style.oscSection}>
<div> <label>
<span>&nbsp;</span> Target IP
<Panel.InlineElements relation='inner'> <Input
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> {...register(`outputs.${index}.targetIP`, {
Test required: { value: true, message: 'Required field' },
</Button> })}
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> fluid
<IoTrash /> placeholder='127.0.0.1'
</IconButton> />
</Panel.InlineElements> <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
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
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> </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
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
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 const rowErrors = errors.outputs?.[index] as
| { | {
action?: { message?: string }; action?: { message?: string };
time?: { message?: string }; time?: { message?: string };
text?: { message?: string }; text?: { message?: string };
visible?: { message?: string }; visible?: { message?: string };
secondarySource?: { message?: string }; secondarySource?: { message?: string };
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag> <Tag>Ontime action</Tag>
<OntimeActionForm <OntimeActionForm
value={output.action} value={output.action}
index={index} index={index}
register={register} register={register}
rowErrors={rowErrors} rowErrors={rowErrors}
setValue={setValue} setValue={setValue}
watch={watch} watch={watch}
> >
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> <Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> variant='ghosted-destructive'
</Panel.InlineElements> onClick={() => removeOutput(index)}
</OntimeActionForm> >
</div> <IoTrash />
); </IconButton>
} </Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types return null;
return null; })}
})} <Panel.InlineElements relation='inner'>
<Panel.InlineElements relation='inner'> <Button onClick={handleAddNewOSCOutput}>
<Button onClick={handleAddNewOSCOutput}> OSC <IoAdd />
OSC <IoAdd /> </Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
</form>
}
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<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 automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
<AutomationsList automations={data.automations} enabledAutomations={automationState} /> </div>
</div> <div ref={triggersRef}>
<div ref={triggersRef}> <TriggersList
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} /> triggers={data.triggers}
</div> automations={data.automations}
</Panel.Section> enabledAutomations={automationState}
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,129 @@ 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>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
</Panel.Section> </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>
); );
} }
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function AutomationsList(props: AutomationsListProps) { export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,89 +44,94 @@ export default function AutomationsList(props: AutomationsListProps) {
const arrayAutomations = Object.keys(automations); const arrayAutomations = Object.keys(automations);
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Manage automations {automationFormData !== null && (
<Button <AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
type='submit' )}
disabled={Boolean(automationFormData)} <Panel.SubHeader>
onClick={() => setAutomationFormData(automationPlaceholder)} Manage automations
> <Button onClick={() => setAutomationFormData(automationPlaceholder)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </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>
enabled. Automations are disabled. You can still manage automation definitions here, but they will not run until
</Info> enabled.
)} </Info>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<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>
return null; <thead>
} <tr>
return ( <th style={{ width: '45%' }}>Title</th>
<Fragment key={automationId}> <th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
</Button>
}
/>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
</Fragment>
);
})}
{deleteError && (
<tr> <tr>
<td>{automations[automationId].title}</td> <td colSpan={5}>
<td> <Panel.Error>{deleteError}</Panel.Error>
<Tag>{automations[automationId].filterRule}</Tag>
</td> </td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr> </tr>
</Fragment> )}
); </tbody>
})} </Panel.Table>
{deleteError && ( </Panel.Section>
<tr> </Panel.Card>
<td colSpan={5}> </Panel.Section>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
); );
} }
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' }, { value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' }, { value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' }, { value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message: source' }, { value: 'message-secondary', label: 'Secondary Message' },
]} ]}
/> />
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' /> <TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <>
Timer secondary source <label>
<Select<SecondarySource | 'null' | null> Text (leave empty for no change)
onValueChange={(value) => { <TemplateInput
// null -> no selection {...register(`outputs.${index}.text`)}
if (value === null) return; value={watch(`outputs.${index}.text`) ?? ''}
// 'null' -> clear the secondary source fluid
if (value === 'null') { placeholder='eg: Next up: keynote'
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); />
return; <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
} </label>
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true }); <label>
}} Timer secondary source
value={watch(`outputs.${index}.secondarySource`)} <Select<SecondarySource | 'no-change' | 'null' | null>
options={[ onValueChange={(value) => {
{ value: null, label: 'Select secondary source' }, // null -> no selection
{ value: 'aux1', label: 'Auxiliary timer 1' }, if (value === null) return;
{ value: 'aux2', label: 'Auxiliary timer 2' }, // no-change -> leave the current secondary source untouched
{ value: 'aux3', label: 'Auxiliary timer 3' }, if (value === 'no-change') {
{ value: 'secondary', label: 'Secondary' }, setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source return;
]} }
/> // 'null' -> clear the secondary source
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> if (value === 'null') {
</label> setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } 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 Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
initialId?: string; trigger: Trigger | null;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void; onCancel: () => void;
postSubmit: () => void; postSubmit: () => void;
} }
export default function TriggerForm({ export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -40,9 +31,9 @@ export default function TriggerForm({
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({ } = useForm<TriggerDTO>({
defaultValues: { defaultValues: {
title: initialTitle, title: trigger?.title,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined), trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id, automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -55,10 +46,9 @@ export default function TriggerForm({
}, [setFocus]); }, [setFocus]);
const onSubmit = async (values: TriggerDTO) => { const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger if (trigger) {
if (initialId) {
try { try {
await editTrigger(initialId, { id: initialId, ...values }); await editTrigger(trigger.id, { id: trigger.id, ...values });
postSubmit(); postSubmit();
} catch (error) { } catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` }); setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -85,56 +75,63 @@ export default function TriggerForm({
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
return ( return (
<Panel.Indent <Modal
as='form' isOpen
name='trigger-form' onClose={onCancel}
onSubmit={handleSubmit(onSubmit)} showBackdrop
onKeyDown={(event) => preventEscape(event, onCancel)} showCloseButton
> size='compact'
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader> title={trigger ? 'Edit trigger' : 'Create trigger'}
<label> bodyElements={
Title <form id={formId} onSubmit={handleSubmit(onSubmit)}>
<Input <label>
{...register('title', { required: { value: true, message: 'Required field' } })} Title
fluid <Input
defaultValue={initialTitle} {...register('title', { required: { value: true, message: 'Required field' } })}
/> fluid
<Panel.Error>{errors.title?.message}</Panel.Error> defaultValue={trigger?.title}
</label> />
<label> <Panel.Error>{errors.title?.message}</Panel.Error>
Lifecycle trigger </label>
<Select <label>
value={watch('trigger')} Lifecycle trigger
onValueChange={(value) => { <Select
if (value === null) return; value={watch('trigger')}
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true }); onValueChange={(value) => {
}} if (value === null) return;
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
aria-label='Lifecycle trigger' }}
/> options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
<Panel.Error>{errors.trigger?.message}</Panel.Error> aria-label='Lifecycle trigger'
</label> />
<label> <Panel.Error>{errors.trigger?.message}</Panel.Error>
Automation title </label>
<Select <label>
value={watch('automationId')} Automation title
onValueChange={(value: string | null) => { <Select
if (value === null) return; value={watch('automationId')}
setValue('automationId', value, { shouldDirty: true }); onValueChange={(value: string | null) => {
}} if (value === null) return;
options={automationSelect} setValue('automationId', value, { shouldDirty: true });
aria-label='Automation title' }}
/> options={automationSelect}
<Panel.Error>{errors.automationId?.message}</Panel.Error> aria-label='Automation title'
</label> />
<Panel.InlineElements align='end'> <Panel.Error>{errors.automationId?.message}</Panel.Error>
<Button disabled={isSubmitting} onClick={onCancel}> </label>
Cancel </form>
</Button> }
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> footerElements={
Save <>
</Button> {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.InlineElements> <Button disabled={isSubmitting} onClick={onCancel}>
</Panel.Indent> Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
); );
} }
@@ -9,23 +9,33 @@ import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm'; import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function TriggersList(props: TriggersListProps) { export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props; const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
setDeleteError(null);
await deleteTrigger(id); await deleteTrigger(id);
} catch (error) { } catch (error) {
setDeleteError(maybeAxiosError(error)); setDeleteError(maybeAxiosError(error));
@@ -35,7 +45,7 @@ export default function TriggersList(props: TriggersListProps) {
}; };
const postSubmit = () => { const postSubmit = () => {
setShowForm(false); closeForm();
refetch(); refetch();
}; };
@@ -45,70 +55,86 @@ export default function TriggersList(props: TriggersListProps) {
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Manage triggers {formState.isOpen && (
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}> <TriggerForm
New <IoAdd /> automations={automations}
</Button> trigger={formState.trigger ?? null}
</Panel.SubHeader> onCancel={closeForm}
<Panel.Divider /> postSubmit={postSubmit}
<Panel.Section> />
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)} )}
{duplicates && ( <Panel.SubHeader>
<Panel.Error> Manage triggers
You have created multiple links between the same trigger and automation which can performance issues. <Button disabled={!canAdd} onClick={openNewForm}>
</Panel.Error> New <IoAdd />
)} </Button>
{showForm && ( </Panel.SubHeader>
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} /> <Panel.Divider />
)} <Panel.Section>
<Panel.Table> {enabledAutomations === false && (
<thead> <Info>
<tr> Automations are disabled. You can still manage triggers here, but they will not run until enabled.
<th style={{ width: '35%' }}>Title</th> </Info>
<th style={{ width: '25%' }}>Lifecycle trigger</th> )}
<th style={{ width: '25%' }}>Automation</th> {duplicates && (
<th /> <Panel.Error>
</tr> You have created multiple links between the same trigger and automation which can cause performance
</thead> issues.
<tbody> </Panel.Error>
{!showForm && triggers.length === 0 && ( )}
<Panel.TableEmpty <Panel.Table>
label='Create an automation to attach triggers to' <thead>
handleClick={canAdd ? () => setShowForm(true) : undefined} <tr>
/> <th style={{ width: '35%' }}>Title</th>
)} <th style={{ width: '25%' }}>Lifecycle trigger</th>
{triggers.map((trigger, index) => { <th style={{ width: '25%' }}>Automation</th>
return ( <th />
<Fragment key={trigger.id}> </tr>
<TriggersListItem </thead>
automations={automations} <tbody>
id={trigger.id} {!isLoading && triggers.length === 0 && (
title={trigger.title} <Panel.TableEmpty
trigger={trigger.trigger} title='No triggers yet'
automationId={trigger.automationId} description={
duplicate={duplicates?.includes(index)} canAdd
handleDelete={() => handleDelete(trigger.id)} ? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
postSubmit={postSubmit} : 'Create an automation first, then add a trigger to decide when it should run.'
/> }
{deleteError && ( action={
<tr> canAdd && (
<td colSpan={5}> <Button variant='primary' onClick={openNewForm}>
<Panel.Error>{deleteError}</Panel.Error> Create trigger <IoAdd />
</td> </Button>
</tr> )
)} }
</Fragment> />
); )}
})} {triggers.map((trigger, index) => {
</tbody> return (
</Panel.Table> <Fragment key={trigger.id}>
</Panel.Section> <TriggersListItem
</Panel.Card> automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
); );
} }
@@ -1,48 +1,21 @@
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5'; import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps { interface TriggersListItemProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
id: string; trigger: Trigger;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean; duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void; handleDelete: () => void;
postSubmit: () => void;
} }
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props; const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500 color='#FFBC56' // $orange-500
/> />
)} )}
{title} {trigger.title}
</Panel.InlineElements> </Panel.InlineElements>
<td> <td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td> </td>
<td> <td>
<Tag>{automations?.[automationId]?.title}</Tag> <Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -1,29 +1,67 @@
.wrapper { .inputShell {
position: relative; position: relative;
} }
.suggestions { .fluid {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%; width: 100%;
margin: 0; }
z-index: $zindex-floating;
padding-block: 0.25rem;
max-height: 200px; .input {
overflow-y: auto; padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250;
color: $label-gray; color: $label-gray;
}
li { .list {
padding: 0.25rem; box-sizing: border-box;
} max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
outline: 0;
}
li:hover { .item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] {
color: $ui-white; color: $ui-white;
background: $blue-700; background: $blue-700;
} }
} }
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,69 +1,258 @@
import { mergeRefs, useClickOutside } from '@mantine/hooks'; import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { forwardRef, useMemo, useState } from 'react'; import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input'; import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils'; import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss'; import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {} interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) { interface TemplateEditorModalProps {
const { value, onChange, ...rest } = props; autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false)); const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
}, [data]); }, [data]);
const [inputValue, setInputValue] = useState(value || ''); const updateInputValue = useCallback(
const [suggestions, setSuggestions] = useState<string[]>([]); (nextValue: string) => {
const [showSuggestions, setShowSuggestions] = useState(false); setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const updateSuggestions = (value: string) => { const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const template = selectFromLastTemplate(value); const { setCursorForValue } = autocomplete;
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
}; };
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const closeExpandedEditor = () => {
setInputValue(event.target.value); setIsExpanded(false);
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
onChange?.(event);
}; };
const handleSuggestion = (value: string) => { const saveExpandedEditor = (nextValue: string) => {
setInputValue((prev) => { updateInputValue(nextValue);
const remaining = matchRemaining(prev as string, value); autocomplete.setCursorForValue(nextValue, nextValue.length);
return prev + remaining; setIsExpanded(false);
});
setShowSuggestions(false);
}; };
return ( return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}> <>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid /> <TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
{showSuggestions && suggestions.length > 0 && ( <div className={cx([style.inputShell, fluid && style.fluid])}>
<ul className={style.suggestions}> <BaseAutocomplete.Input
{suggestions.map((suggestion) => ( ref={setInputRef}
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}> className={cx([
{suggestion} inputStyle.input,
</li> inputStyle[variant],
))} inputStyle[height],
</ul> fluid && inputStyle.fluid,
)} style.input,
</div> className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
); );
}); }
export default TemplateInput; function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
};
const handleSave = () => {
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
);
}
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { matchRemaining } from '../templateInput.utils'; import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => { describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => { it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe(''); expect(matchRemaining('test', 'test')).toBe('');
}); });
}); });
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
]; ];
} }
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/** /**
* Returns the partial string b needed to autocomplete string a * Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st' * @example matchRemaining('te', 'test') -> 'st'
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
/** function getActiveTemplateRange(text: string, cursorIndex = text.length) {
* Selects the last starting template in a string const textBeforeCursor = text.slice(0, cursorIndex);
*/ const start = textBeforeCursor.lastIndexOf('{{');
export function selectFromLastTemplate(text: string) { if (start === -1) {
const lastBraceIndex = text.lastIndexOf('{{'); return null;
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
} }
return '';
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
} }
@@ -0,0 +1,72 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -66,7 +66,10 @@ export default function ReportSettings() {
</thead> </thead>
<tbody> <tbody>
{combinedReport.length === 0 && ( {combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' /> <Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)} )}
{combinedReport.map((entry) => { {combinedReport.map((entry) => {
@@ -1,7 +1,8 @@
import { URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
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';
@@ -22,16 +23,27 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { deletePreset, isMutating } = useUpdateUrlPreset(); const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.SubHeader> <Panel.SubHeader>
URL presets URL presets
<Button onClick={openNewForm}> <Button onClick={openNewForm}>
@@ -52,23 +64,47 @@ export default function URLPresets() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />} {data.length === 0 && (
<Panel.TableEmpty
title='No URL presets yet'
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
action={
<Button variant='primary' onClick={openNewForm}>
Create preset <IoAdd />
</Button>
}
/>
)}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} /> <Switch
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,17 +1,18 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea'; import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
@@ -27,11 +28,14 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: 'Project Info' },
]; ];
const formId = 'url-preset-form';
const defaultValues: URLPreset = { const defaultValues: URLPreset = {
alias: '', alias: '',
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -61,12 +65,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, data); await updatePreset(urlPreset.alias, payload);
} else { } else {
await addPreset(data); await addPreset(payload);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -74,6 +108,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -101,73 +136,101 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}; };
return ( return (
<Panel.Indent <Modal
as='form' isOpen
onSubmit={handleSubmit(setupSubmit)} onClose={onClose}
onKeyDown={(event) => preventEscape(event, onClose)} showBackdrop
className={style.column} showCloseButton
> size='compact'
<input hidden name='enabled' value='true' /> title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div> <div>1. Enter URL and let Ontime generate the preset options</div>
<Panel.InlineElements>
<div>
<Panel.Description>Alias</Panel.Description>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} /> <div>
<Button onClick={generateOptions}>Generate</Button> <Panel.Description>Alias</Panel.Description>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements> </Panel.InlineElements>
</div> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
</Panel.InlineElements> {!isEditingCuesheet && (
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} <>
<div> <div>
{enDash} or {enDash} {enDash} or {enDash}
</div> </div>
<div>2. Choose a view and its parameters</div> <div>2. Choose a view and its parameters</div>
<div> <div>
<Panel.Description>Target</Panel.Description> <Panel.Description>Target</Panel.Description>
<Select <Select
options={targetOptions} options={targetOptions}
{...register('target', { required: 'Target is required' })} {...register('target', { required: 'Target is required' })}
value={watch('target')} value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => { onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return; if (value === null) return;
setValue('target', value, { shouldDirty: true }); setValue('target', value, { shouldDirty: true });
}} }}
/> />
</div> </div>
<div> <div>
<Panel.Description>Parameters</Panel.Description> <Panel.Description>Parameters</Panel.Description>
<Textarea <Textarea
fluid fluid
rows={3} rows={3}
{...register('search', { {...register('search', {
validate: validateParams, validate: validateParams,
})} })}
/> />
<Panel.Error>{errors.search?.message}</Panel.Error> <Panel.Error>{errors.search?.message}</Panel.Error>
</div> </div>
<div> </>
<Panel.Error>{errors.root?.message}</Panel.Error> )}
<Panel.InlineElements align='end'>
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
</form>
}
footerElements={
<>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}> <Button
variant='primary'
type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </>
</div> }
</Panel.Indent> />
); );
} }
@@ -13,27 +13,30 @@ import * as Panel from '../../panel-utils/PanelUtils';
import CustomFieldEntry from './composite/CustomFieldEntry'; import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm'; import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
type FormState = {
isOpen: boolean;
field?: CustomFieldEntity;
};
export default function CustomFieldSettings() { export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false); const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
const handleInitiateCreate = () => { const openNewForm = () => setFormState({ isOpen: true });
setIsAdding(true); const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
}; const closeForm = () => setFormState({ isOpen: false, field: undefined });
const handleCancel = () => { const handleSubmit = async (customField: CustomField) => {
setIsAdding(false); const editing = formState.field;
}; if (editing) {
await editCustomField(editing.key, customField);
const handleCreate = async (customField: CustomField) => { } else {
await postCustomField(customField); await postCustomField(customField);
refetch(); }
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch(); refetch();
closeForm();
}; };
const handleDelete = async (key: CustomFieldKey) => { const handleDelete = async (key: CustomFieldKey) => {
@@ -45,12 +48,24 @@ export default function CustomFieldSettings() {
} }
}; };
const entries = Object.entries(data);
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && (
<CustomFieldForm
onSubmit={handleSubmit}
onCancel={closeForm}
initialColour={formState.field?.colour}
initialLabel={formState.field?.label}
initialKey={formState.field?.key}
initialType={formState.field?.type}
/>
)}
<Panel.SubHeader> <Panel.SubHeader>
Custom fields Custom fields
<Button onClick={handleInitiateCreate}> <Button onClick={openNewForm}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -66,7 +81,6 @@ export default function CustomFieldSettings() {
</Info> </Info>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -78,7 +92,18 @@ export default function CustomFieldSettings() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(data).map(([key, { colour, label, type }]) => { {entries.length === 0 && (
<Panel.TableEmpty
title='No custom fields yet'
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
action={
<Button variant='primary' onClick={openNewForm}>
Create custom field <IoAdd />
</Button>
}
/>
)}
{entries.map(([key, { colour, label, type }]) => {
return ( return (
<CustomFieldEntry <CustomFieldEntry
key={key} key={key}
@@ -86,7 +111,7 @@ export default function CustomFieldSettings() {
colour={colour} colour={colour}
label={label} label={label}
type={type} type={type}
onEdit={handleEditField} onEdit={() => openEditForm({ key, colour, label, type })}
onDelete={handleDelete} onDelete={handleDelete}
/> />
); );
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
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 Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils'; import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss'; import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps { interface CustomViewFormProps {
onComplete: () => void; onComplete: () => void;
onClose: () => void; onClose: () => void;
@@ -53,62 +56,72 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
}; };
return ( return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}> <Modal
<input isOpen
ref={fileInputRef} onClose={onClose}
style={{ display: 'none' }} showBackdrop
type='file' showCloseButton
onChange={handleSelectFile} size='compact'
accept='.html,text/html' title='Upload custom view'
/> bodyElements={
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
<div className={style.step}> <div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div> <div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description> <Panel.Description>Name</Panel.Description>
<Input <Input
value={slug} value={slug}
onChange={(event) => { onChange={(event) => {
setSlug(event.target.value); setSlug(event.target.value);
setSlugDirty(true); setSlugDirty(true);
}} }}
placeholder='my-view' placeholder='my-view'
aria-label='Custom view name' aria-label='Custom view name'
autoCapitalize='off' autoCapitalize='off'
autoComplete='off' autoComplete='off'
fluid fluid
/> />
<Panel.Description> <Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight> Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description> </Panel.Description>
<Panel.Description> <Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight> Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description> </Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>} {slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div> </div>
<div className={style.step}> <div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div> <div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description> <Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}> <Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}> <Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'} {selectedFile ? 'Replace index.html' : 'Choose index.html'}
</Button>
<span className={style.fileName}>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
</span>
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
</form>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button> </Button>
<span className={style.fileName}> </>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'} }
</span> />
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</Panel.InlineElements>
</Panel.Indent>
); );
} }
@@ -37,13 +37,14 @@ export default function CustomViews() {
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader> <Panel.SubHeader>
Custom views Custom views
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}> <Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo Restore demo
</Button> </Button>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}> <Button onClick={() => setIsUploadOpen(true)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -63,8 +64,6 @@ export default function CustomViews() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>} {actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList <CustomViewsList
@@ -1,5 +1,6 @@
import { CustomViewSummary } from 'ontime-types'; import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews'; import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />} {views.length === 0 && (
<Panel.TableEmpty
title='No custom views yet'
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
action={
<Button variant='primary' onClick={onOpenUpload}>
Upload view <IoCloudUploadOutline />
</Button>
}
/>
)}
{views.map((view, index) => ( {views.map((view, index) => (
<CustomViewsListItem <CustomViewsListItem
key={view.slug} key={view.slug}
@@ -12,6 +12,13 @@
gap: 1rem; gap: 1rem;
} }
.current { .formColumn {
background-color: $blue-1100 !important; // fighting zebra styles display: flex;
flex-direction: column;
gap: 1rem;
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
} }
@@ -3,9 +3,12 @@ import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = { type NewRundownFormState = {
title: string; title: string;
}; };
@@ -42,22 +45,32 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]); }, [setFocus]);
return ( return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}> <Modal
<Panel.Section> isOpen
<label> onClose={onClose}
<Panel.Description>Rundown title</Panel.Description> showBackdrop
<Input {...register('title')} fluid placeholder='Your rundown name' /> showCloseButton
</label> size='small'
</Panel.Section> title='Create rundown'
<Panel.InlineElements relation='inner' align='end'> bodyElements={
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}> <form id={formId} onSubmit={handleSubmit(createRundown)}>
Cancel <label>
</Button> <Panel.Description>Rundown title</Panel.Description>
<Button type='submit' variant='primary' disabled={isSubmitting}> <Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
Create rundown </label>
</Button> </form>
</Panel.InlineElements> }
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} footerElements={
</Panel.Indent> <>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</>
}
/>
); );
} }
@@ -9,6 +9,7 @@ import {
IoPencilOutline, IoPencilOutline,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbGhost3 } from 'react-icons/tb';
import { downloadAsExcel } from '../../../../common/api/excel'; import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
await downloadAsExcel(rundownId, title); await downloadAsExcel(rundownId, title);
}; };
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
return ( return (
<> <>
<Panel.Section> <Panel.Section>
@@ -149,11 +153,11 @@ export default function ManageRundowns() {
<tr key={id} className={cx([isLoaded && style.current])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
<td> <td>
{title} {isLoaded && <Tag>Loaded</Tag>} {title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
</td> </td>
<td> <td>
<DropdownMenu <DropdownMenu
render={<IconButton variant='ghosted-white' />} render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
items={[ items={[
{ {
type: 'item', type: 'item',
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
label: 'Duplicate', label: 'Duplicate',
onClick: () => submitRundownDuplicate(id), onClick: () => submitRundownDuplicate(id),
}, },
{
type: 'item',
icon: TbGhost3,
label: 'Edit in cuesheet',
onClick: () => navigateToCuesheet(id),
},
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'destructive', type: 'destructive',
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
setDefaultDuration, setDefaultDuration,
setLinkPrevious, setLinkPrevious,
setTimeStrategy, setTimeStrategy,
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
setDangerTime, setDangerTime,
setDefaultTimerType, setDefaultTimerType,
setDefaultEndAction, setDefaultEndAction,
setInheritGroupColour,
} = useEditorSettings((state) => state); } = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration); const durationInMs = parseUserTime(defaultDuration);
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
/> />
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} /> <Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Inherit group colour'
description='Whether new events and milestones inherit the colour of their parent group'
/>
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
</Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Timer strategy' title='Timer strategy'
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -7,7 +6,6 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
label: string; label: string;
fieldKey: string; fieldKey: string;
type: CustomField['type']; type: CustomField['type'];
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>; onEdit: () => void;
onDelete: (key: CustomFieldKey) => Promise<void>; onDelete: (key: CustomFieldKey) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props; const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(fieldKey, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
initialKey={fieldKey}
initialType={type}
/>
</td>
</tr>
);
}
return ( return (
<tr> <tr>
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,13 +8,15 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps { interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>; onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void; onCancel: () => void;
@@ -85,65 +87,77 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined; const isEditMode = initialKey !== undefined;
return ( return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}> <Modal
<Info> isOpen
Please note that images can quickly deteriorate your app&apos;s performance. onClose={onCancel}
<br /> showBackdrop
Prefer using small, and compressed images. showCloseButton
</Info> size='compact'
<div> title={isEditMode ? 'Edit custom field' : 'Create custom field'}
<Panel.Description>Type</Panel.Description> bodyElements={
<RadioGroup <form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
orientation='horizontal' <Info>
disabled={isEditMode} Please note that images can quickly deteriorate your app&apos;s performance.
onValueChange={(value) => setValue('type', value, { shouldDirty: true })} <br />
value={watch('type')} Prefer using small, and compressed images.
items={[ </Info>
{ value: 'text', label: 'Text' }, <div>
{ value: 'image', label: 'Image' }, <Panel.Description>Type</Panel.Description>
]} <RadioGroup
/> orientation='horizontal'
</div> disabled={isEditMode}
<div className={style.twoCols}> onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
<label> value={watch('type')}
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> items={[
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} { value: 'text', label: 'Text' },
<Input { value: 'image', label: 'Image' },
{...register('label', { ]}
required: { value: true, message: 'Required field' }, />
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), </div>
validate: (value) => { <div className={style.twoCols}>
if (value.trim().length === 0) return 'Required field'; <label>
if (!checkRegex.isAlphanumericWithSpace(value)) <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
return 'Only alphanumeric characters and space are allowed'; {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
if (!isEditMode) { <Input
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; {...register('label', {
} required: { value: true, message: 'Required field' },
return true; onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
}, validate: (value) => {
})} if (value.trim().length === 0) return 'Required field';
fluid if (!checkRegex.isAlphanumericWithSpace(value))
/> return 'Only alphanumeric characters and space are allowed';
</label> if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<label> <label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid /> <Input {...register('key')} variant='ghosted' readOnly fluid />
</label> </label>
</div> </div>
<label> <label>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label> </label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} </form>
<Panel.InlineElements relation='inner' align='end'> }
<Button variant='ghosted' onClick={onCancel}> footerElements={
Cancel <>
</Button> {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button variant='ghosted' onClick={onCancel}>
Save Cancel
</Button> </Button>
</Panel.InlineElements> <Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
</Panel.Indent> Save
</Button>
</>
}
/>
); );
} }
@@ -59,6 +59,7 @@ export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }:
return true; return true;
}, },
})} })}
maxLength={64}
fluid fluid
/> />
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>} {errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}

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