Compare commits

..

117 Commits

Author SHA1 Message Date
Carlos Valente 12d9e5922c Alpha revision (#811)
* style: less space for direction button

* chore: cleanup unused

* refactor: prevent CSS pollution

* refactor: force render on swatch

* refactor: force render on swatch

* refactor: improve import of blocks and skip import

* chore: remove unused

* chore: clarify effect

* chore: updata dataset

* fix: integrations not triggering

* chore: remove unused

* fix: onair derives from playback

* fix: gap day after
2024-03-12 21:30:16 +01:00
Carlos Valente 63983fc39a Alpha release (#810)
* fix: path to resources

* docs: rename aliases > url presets
2024-03-09 22:44:35 +01:00
Carlos Valente 41af3b7466 Sheets revision (#809)
* refactor: poll for authentication

* refactor: show success card
2024-03-09 21:39:11 +01:00
Carlos Valente 78b386742e Settings style (#808) 2024-03-09 10:56:14 +01:00
Carlos Valente ad71793cf3 Url presets (#807) 2024-03-08 23:10:43 +01:00
Carlos Valente 10d7273ec0 Skip fixes (#805)
* refactor: resolve current in time-to-end

* refactor: recalculate on skip

* Alpha tweaks (#806)

* style: small tweaks to interface

* refactor: simplify quick add
2024-03-08 19:54:18 +01:00
Carlos Valente 6dc2bebeee Time to end (#804) 2024-03-07 22:10:21 +01:00
Carlos Valente d59885fc20 fix: allow renaming custom field (#803)
* fix: allow renaming custom field

* style: change time-to-end icon
2024-03-07 19:59:36 +01:00
Carlos Valente d22cc48565 refactor: small tweaks and fixes 2024-03-06 22:42:20 +01:00
Carlos Valente 8196ea584d UI tweaks (#800)
* style: placeholder for progress value

* style: rundown menu

* style: extracted rundown takes whole screen

* style: tweaks to rundown indicators

* feat: allow exporting info

* style: indicate action
2024-03-03 20:42:36 +01:00
Carlos Valente 0c31438e44 chore: migrate remaining settings (#799)
* chore: migrate remaining settings

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
2024-03-03 19:16:25 +01:00
Bianca Procopio 10d3986832 Move app settings (#765)
* refactor: migrate app settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-03-03 19:06:21 +01:00
Carlos Valente 088927bbbb refactor: organise API around resources (#798) 2024-03-02 23:08:08 +01:00
Carlos Valente d87ba12b2e chore: cleanup linter (#797) 2024-03-01 23:19:05 +01:00
Carlos Valente 5b01329c37 refactor: migrate project upload (#796)
* refactor: migrate project upload
2024-03-01 22:59:00 +01:00
Carlos Valente 8df419d835 refactor: deprecate presenter and subtitle (#795)
* refactor: deprecate presenter and subtitle

* style: reduce spacing between related sections

* refactor: optional secondary field

* refactor: remove secondary field

* refactor: dynamic data source
2024-02-29 18:19:58 +01:00
Carlos Valente d7392b93d2 refactor: remove userFields (#791)
* refactor: process custom fields on cache generate

* refactor: remove userFields
2024-02-27 13:23:12 +01:00
Carlos Valente 429df21557 Custom fields caching (#790)
* refactor: process custom fields on cache generate
2024-02-25 17:21:59 +01:00
Carlos Valente da7ef59216 Custom fields views (#789)
* style: show field colour in editor

* feat: custom fields in cuesheet

* feat: custom fields in operator

* refactor: update CSV export
2024-02-24 22:04:20 +01:00
Alex Christoffer Rasmussen 474f1e2177 Sheet use limited input device auth flow (#782)
* refactor: limited-input-device auth

* refactor: resolve sheet directory from setup

* refactor: extract sheet logic in backend

* refactor: simplify sheet integration

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-02-24 11:58:50 +01:00
Carlos Valente fc5338903b feat: generate crash report (#787) 2024-02-23 08:52:08 +01:00
cv 11d06de133 Merge branch 'v3' of https://github.com/cpvalente/ontime into v3 2024-02-19 21:43:00 +01:00
cv cbc2ae5116 chore: cleanup merge 2024-02-19 21:42:38 +01:00
cv a6ff874a2a Merge remote-tracking branch 'origin/master' into v3 2024-02-19 21:40:56 +01:00
Carlos Valente 7ff75835ff refactor: partial state updates (#780)
* refactor: partial state updates

* refactor: less calls to restore

* refactor: add error stack trace to log
2024-02-19 21:31:27 +01:00
Carlos Valente c890251dad refactor: delays account for gaps (#784) 2024-02-19 21:30:52 +01:00
Carlos Valente c1e2700c38 style: tweaks to lower thirds (#785) 2024-02-19 21:29:55 +01:00
Carlos Valente 5a397c6da7 Sheets settings (#774)
* wip: migrate sheet settings

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-02-17 22:05:38 +01:00
Alex Christoffer Rasmussen c1377544a0 custom fields (#744)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-02-17 21:55:46 +01:00
Carlos Valente 1a420a1ddd Over under (#771)
* feat: schedule offset
2024-02-16 21:04:58 +01:00
Fabian Posenau 436a34aaee upgraded pipeline actions (#777) 2024-02-15 14:54:05 +01:00
Fabian Posenau 4848fdbee3 Fix build local (#778)
* adjust node version

* fix build:local
2024-02-15 14:51:19 +01:00
Carlos Valente 71dace669b fix: recover cursor logic (#773) 2024-02-13 18:43:41 +01:00
Carlos Valente 67c448572c style: tweaks to settings (#772) 2024-02-12 21:24:49 +01:00
Carlos Valente 53963a9ad7 Integrations data (#770)
* feat: OSC settings

* feat: HTTP settings
2024-02-11 21:25:23 +01:00
Carlos Valente 5355e45b80 refactor: UI for linking events (#763) 2024-02-11 15:18:04 +01:00
Ary 12f81c63ce Project Manager: Quick Start (#762) 2024-02-10 14:30:33 +01:00
Carlos Valente 7f78fac162 wip: UI for integrations (#769) 2024-02-09 13:56:04 +01:00
Carlos Valente e8b5cb6750 wip: add UI to manage custom fields (#767) 2024-02-09 13:51:40 +01:00
Carlos Valente 212e5f9e68 refactor: small type improvements (#759) 2024-02-03 23:17:36 +01:00
Carlos Valente 1890fc49d7 refactor: normalise data (#756)
* chore: remove legal from bundle

* refactor: create normalised dataset

* refactor: cuesheet uses flat rundown

* refactor: multi-selection

* refactor: prevent stale data on server restart

* refactor: increase ID size

* chore: instrument operation

* chore: update csv tests

* fix: resolve directory to test-db (#758)
2024-02-03 21:43:17 +01:00
Ary 47a519bac1 Merge pull request #753 from biancahpp/feat/download-and-export-options
Project Manager: Export
2024-02-01 16:03:41 -07:00
Ary 083b376113 Merge pull request #754 from cpvalente/fix/project_manager_init
Fix: Project manager list is blank on the first app load
2024-02-01 16:03:13 -07:00
Ary 5b20a47ff6 fix: fixed issue where there were no projects on the project list on the first load 2024-01-31 20:28:39 -07:00
Bianca Procopio c3880cd5a7 update downloadCSV and downloadRundown to accept a file name and add two new options to ProjectListItem 2024-01-31 19:11:00 -07:00
Carlos Valente 91fd275564 refactor: rundown service fetches data from data provider (#752) 2024-01-31 19:30:44 +01:00
Carlos Valente cf08bfc796 refactor: type improvements (#751) 2024-01-31 14:42:33 +01:00
Carlos Valente 7eb9ee8d03 refactor: flatten state mutations (#750) 2024-01-31 14:02:27 +01:00
Carlos Valente ec8952be56 chore: organise delay and rundown utils (#749) 2024-01-30 21:26:37 +01:00
Carlos Valente 6a5020424b refactor: improve typing in backend (#748) 2024-01-30 21:12:19 +01:00
Carlos Valente 39711c1edd refactor: time-to-end always updates (#739) 2024-01-30 12:35:54 +01:00
Ary 3c83f4d15b Project manager (#697)
* Project manager: Load, Rename, Duplicate (#712)

* Project Manager: Create, Delete (#723)
2024-01-29 14:06:33 +01:00
Carlos Valente 4c77d26a71 style: tweaks to editor (#738) 2024-01-28 22:42:19 +01:00
Carlos Valente 5de40e8679 fix: update timer on hot reload (#741) 2024-01-27 22:54:03 +01:00
Carlos Valente 32d70b3cd5 chore: split vendor bundle (#740) 2024-01-27 22:30:23 +01:00
Carlos Valente 86cec56a3d refactor: allow import of blocks and skip import (#735) 2024-01-27 15:08:01 +01:00
Carlos Valente f6557a9814 style: editor tweaks (#736)
* style: menu tweaks

* style: editor tweaks

* style: freeze
2024-01-26 14:47:27 +01:00
Carlos Valente c146954a2f refactor: excel cleanup (#734)
* refactor: remove import of project data from excel
2024-01-26 12:55:35 +01:00
Carlos Valente 2df3d376ad feat: many timers (#706)
* wip: create simple timer class

* refactor: service to manage extra timers

* wip:ui for controlling the extra timer

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-25 16:51:20 +01:00
Carlos Valente 74e1ae609c refactor: update timers (#729)
* refactor: remove duplication

* refactor: previous times

* refactor: event patching
2024-01-25 13:36:45 +01:00
Carlos Valente e748bff0dd chore: upload screenshots to artifacts (#730) 2024-01-24 21:56:01 +01:00
Carlos Valente b4ad533e6a refactor: cleanup store (#727)
* refactor: isolate clock

* refactor: isolate private

* refactor: remove duplicate

* chore: rename loaded > runtime

* fix: handle no events loaded
2024-01-24 00:20:10 +01:00
Alex Christoffer Rasmussen 0cd2ca4191 update API - part 1 (#709)
* api: `test-ontime` -> `version`

* api: `ontime-poll` -> `poll`

* api: `start-next` -> `startnext`

* api: `start-next` ->  `startnext`

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-23 08:43:00 +01:00
Carlos Valente cb8ba776e2 Event editor continue (#722)
* refactor: remove info panel

* feat: editor panel

* wip: event info in drawer

* chore: update tests

* chore: add scss to processor
2024-01-22 16:23:11 +01:00
Carlos Valente c7328a33f6 chore: fail tests that contain unhandled errors (#724) 2024-01-22 14:15:23 +01:00
Carlos Valente fc5800bc21 remove info panel (#721)
* refactor: remove info panel

* chore: update tests
2024-01-19 14:08:32 +01:00
Carlos Valente 3c1be1fc10 fix: issue with loosing cursor position on message (#719) 2024-01-17 23:16:22 +01:00
Carlos Valente a2950442d8 refactor: runtime service (#715)
* refactor: rename PlaybackService > RuntimeService

* refactor: stateful runtime service

* refactor: merge event loading

* refactor: roll is part of state mutation
2024-01-16 19:54:40 +01:00
Alex Christoffer Rasmussen ee121bcfcb change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase (#716)
2024-01-16 19:35:16 +01:00
Alex Christoffer Rasmussen 4234334fbb fix: test - go to Edit mode befor tying to click Event options button (#708) 2024-01-13 12:45:23 +01:00
Carlos Valente 7f63cde7a5 feat: multiple selection (#703)
feat: multiple event selection

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>
2024-01-12 13:07:56 +01:00
Alex Rohleder f965db148c refactor: setup global timer state (#629)
* refactor: setup global timer state

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-12 13:03:58 +01:00
cv e6bb52ad9c chore: cleanup merge 2024-01-12 10:17:12 +01:00
cv 6d067f3d3a chore: cleanup merge 2024-01-12 10:13:49 +01:00
cv 506f9a868b chore: cleanup merge 2024-01-12 10:13:40 +01:00
cv adb8981191 chore: cleanup merge 2024-01-12 10:12:01 +01:00
cv a0b3e56896 chore: cleanup merge 2024-01-12 10:05:51 +01:00
cv 229fff05a4 Merge remote-tracking branch 'origin/master' into v3 2024-01-12 10:05:10 +01:00
Alex Christoffer Rasmussen 5b7045e6a5 chore: remove unused deepmerge (#705) 2024-01-10 19:30:34 +01:00
Alex Christoffer Rasmussen 7cc4364a63 chore: upgrade nanoid to v5 (#704) 2024-01-10 19:28:34 +01:00
Carlos Valente b4e73ff6b5 Refactor/time formatting (#696)
* chore: upgrade related dependencies

* fix: skip sentry on dev

* refactor: unify time formatting

* refactor: display default format for page
2024-01-09 15:30:11 +01:00
Carlos Valente 9031051465 Revert "style: align delay to clock"
This reverts commit 3fb862628c.
2024-01-08 22:55:57 +01:00
Carlos Valente a15a9671d2 Revert "chore: cleanup utils"
This reverts commit ecd13fdc5a.
2024-01-08 22:55:47 +01:00
Carlos Valente ecd13fdc5a chore: cleanup utils 2024-01-08 22:51:38 +01:00
Carlos Valente 3fb862628c style: align delay to clock 2024-01-08 22:48:25 +01:00
Carlos Valente 1f3133d4f3 chore: upgrade deps (#698) 2024-01-08 17:31:04 +01:00
Alex Christoffer Rasmussen 64573aff8a refactor: update lower third (#665)
* init

* auto in out

* fix in out animation and other options

* move some constants to css override

* dont show block if text source is empty

* select trigger

* adjust the animation

* remove the old one and replace it in the router

* return null from useEffect when there is no timer to clear

* typo: manuel -> manual

* fix: delay is excluding transition in time

* rename to top and bottom

* change font size to px and increese the default

* sharper animation timning

* fix: dont need state for options

* fix: e2e test for lower third

* deafault to ffffff00 key color

* typo: scss naming

* style: all white default bg key

* style: add default values to src options

* style: `white-space: nowrap;`

* style: start out animation a bit early

* style: browser tab title

* fix: checking the correct default values in ViewParamsEditor

* fix: defaultValues save improved by @asharonbaltazar

* lint: capitalize enum names

* fix: memoize options

* fix: searchParams test

* fix: readability of `topText` and `bottomText` and memoize them

* fix: enum naming upper case

* refactor: remove cx import

* refactor: try to simplify is else flow
2024-01-07 23:23:25 +01:00
Carlos Valente 655e9e8fb5 refactor: migrate logs (#673) 2024-01-06 21:48:20 +01:00
Carlos Valente bac2dc6b87 feat: create project list (#671) 2024-01-06 21:38:16 +01:00
Ary 65a5aaa665 Keep several project files in user folder - Steps 5A-E (#684) 2024-01-06 21:24:48 +01:00
Carlos Valente c889d00e6b fix: focus cursor (#695)
* fix: prevent cursor from removing focus
2024-01-06 09:10:42 +01:00
Carlos Valente 3753d51d85 chore: cleanup 2024-01-05 21:57:47 +01:00
Carlos Valente cb23b83b9d Merge remote-tracking branch 'origin/master' into v3 2024-01-05 21:55:16 +01:00
Alex Christoffer Rasmussen a567fdc446 build: upgrade lowdb (#689) 2024-01-02 20:27:10 +01:00
Carlos Valente 3d9a9a2226 wip: overview (#688)
* wip: overview
2024-01-01 22:06:23 +01:00
Carlos Valente 9a4d659b04 improvement: hide seconds (#675)
* feat: optional timer seconds
2023-12-31 12:13:05 +01:00
Fabian Posenau a5d8b91f34 add fields to import modal (#685)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 22:11:06 +01:00
Carlos Valente 5409c0ac6e refactor: stabilise actionHandler (#683) 2023-12-30 21:13:51 +01:00
Fabian Posenau 3998b8927d Warning and danger per event (#677)
* feat: warning and danger per event

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 20:56:57 +01:00
Ary 7b9f64a630 Project files tweaks (#679)
* chore: fixed issue when loading first time; deleting excel file after upload; only json files from project list

* chore: added last loaded project to endpoint response

* chore: incremental file name

---------

Co-authored-by: Name <Email>
2023-12-30 19:32:17 +01:00
Alex Christoffer Rasmussen c5c0401ac4 refactor: switch out steno for lowdb (#682)
* refactor: switch out steno for lowdb
2023-12-30 10:03:02 +01:00
Alex Christoffer Rasmussen 96280e5932 Feat: reorder events with alt+ctrl + arrow up/down (#645)
* fix: key guard

* feat: reorder events

* refactor: return index from the getEvent functions
2023-12-28 13:41:43 +01:00
Alex Christoffer Rasmussen b2c01a141a Studio clock (#663)
* refactor: trim rundown shows only upcoming

* style: small tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-28 13:25:45 +01:00
Ary dc4ba4012c feat: Several project files user folder (Steps 3-5) (#664)
* feat: steps 3-5 for several project files user folder

* feat: common types, better listing function

* feat: error response type
2023-12-28 13:23:51 +01:00
Carlos Valente d1e201ebb5 refactor: remove deprecated event data (#674) 2023-12-27 20:00:09 +01:00
Carlos Valente a463cb491b feat: app settings (#658)
* feat: app settings

* refactor: cleanup routes

* style: smaller base font

* chore: migrate about modal

* fix: import links
2023-12-25 21:25:09 +01:00
Carlos Valente 6ffc314513 chore: automated screenshots (#667)
* chore: take screenshots on routes

* chore: update test data

* chore: update test

* chore: upgrade node in CI
2023-12-25 19:41:45 +01:00
Ary f2632412aa Several project files user folder (#617)
* feat: save loaded file across sessions
2023-12-21 13:52:09 +01:00
Carlos Valente 062dc0c914 refactor: pass HTTP to integration controller (#652)
* refactor: pass HTTP to integration controller

* refactor: deprecate onair control

* refactor: remove playback router
2023-12-19 21:44:43 +01:00
Carlos Valente fe9f12a14b chore: lock node version to electron 2023-12-19 10:23:27 +01:00
Carlos Valente 3144888eb1 chore: add italian to languages (#656) 2023-12-19 10:05:09 +01:00
Carlos Valente 820eacde91 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-19 10:05:09 +01:00
Matteo Gheza 6886a3236f Add italian client translation (#655)
* Add Italian client translation
2023-12-19 10:05:09 +01:00
Carlos Valente bb541c551f chore: upgrade build deps (#661)
* chore: upgrade build dependencies

* chore: add bundle analysis script

* chore: upgrade sentry deps

* chore: upgrade node types

* docs: declare new engine versions
2023-12-19 10:03:59 +01:00
Carlos Valente 3196bd7802 chore: version bump 2023-12-17 20:57:49 +01:00
Carlos Valente ce0657cc6d style: smaller base font 2023-12-17 20:56:58 +01:00
Carlos Valente 44c2b1b5e3 refactor: cleanup routes 2023-12-17 20:56:42 +01:00
265 changed files with 3058 additions and 4127 deletions
-1
View File
@@ -19,7 +19,6 @@
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
-3
View File
@@ -45,6 +45,3 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
# temporary write files
**.tmp
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18.18-alpine
FROM node:16-alpine
# Set environment variables
# Environment Variable to signal that we are running production
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.0-beta.1",
"version": "3.0.0-alpha",
"private": true,
"type": "module",
"dependencies": {
@@ -10,7 +10,6 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
@@ -86,7 +85,7 @@
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.4.3",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
+2 -1
View File
@@ -47,8 +47,9 @@ function App() {
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
+3 -3
View File
@@ -7,7 +7,7 @@ import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -49,12 +49,12 @@ export default function AppRouter() {
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
+41 -54
View File
@@ -1,54 +1,36 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import {
CustomFields,
DatabaseModel,
GetInfo,
MessageResponse,
OntimeRundown,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
import fileDownload from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
* HTTP request to download db in JSON format
*/
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
return axios.get(`${dbPath}/download`);
export async function downloadRundown(fileName?: string) {
return fileDownload(
dbPath,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
}
/**
* Request download of the current project file
* @param fileName
* HTTP request to download db in CSV format
*/
export async function downloadProject(fileName: string = 'ontime-project') {
try {
const { data, name } = await fileDownload(fileName);
const fileContent = JSON.stringify(data, null, 2);
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
downloadBlob(blob, `${name}.json`);
} catch (error) {
console.error(error);
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
export async function downloadCSV(fileName?: string) {
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
}
/**
@@ -146,23 +128,28 @@ export async function getInfo(): Promise<GetInfo> {
return res.data;
}
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* Utility function gets project from db
* @param fileName
* @returns
* Make patch changes to the objects in the db
*/
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb();
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const formData = new FormData();
formData.append('spreadsheet', file);
formData.append('options', JSON.stringify(options));
const headerLine = response.headers['Content-Disposition'];
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${dbPath}/spreadsheet/preview`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
// try and get the filename from the response
let name = fileName;
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
return { data: response.data, name };
return response.data;
}
-42
View File
@@ -1,42 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File) {
const formData = new FormData();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data;
}
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
return response.data;
}
+2 -10
View File
@@ -1,4 +1,4 @@
import axios, { AxiosResponse } from 'axios';
import axios from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
@@ -9,10 +9,7 @@ const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{
authenticated: AuthenticationStatus;
sheetId: string;
}> => {
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.get(`${sheetsPath}/connect`);
return response.data;
};
@@ -61,11 +58,6 @@ export const previewRundown = async (
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
+49 -14
View File
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
@@ -55,29 +56,63 @@ export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
/**
* Creates blob from content
* @param fileContent
* @param type
* @returns
*/
export function createBlob(fileContent: string, type: string): Blob {
return new Blob([fileContent], { type });
}
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
/**
* downloads a blob
* @param downloadUrl
* @param fileName
* Gets DB from backend and prepares a file to be downloaded
* @param url
* @param fileOptions
* @param blobOptions
* @returns
*/
export function downloadBlob(blob: Blob, fileName: string) {
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, customFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, customFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
@@ -19,7 +19,7 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
@@ -37,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
</div>
@@ -1,60 +0,0 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,11 +1,12 @@
@use '../../../theme/mixins' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
$button-size: 48px;
.buttonContainer {
display: flex;
@@ -20,7 +21,7 @@ $button-size: 3rem;
position: fixed;
left: 0;
top: 0;
z-index: 12;
z-index: 2;
&.hidden {
opacity: 0;
@@ -28,7 +29,7 @@ $button-size: 3rem;
}
.button {
font-size: 1.5rem;
font-size: 24px;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
@@ -43,10 +44,31 @@ $button-size: 3rem;
z-index: 3;
}
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link {
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
justify-content: space-between;
padding: 0.5rem 1rem;
&:hover {
background-color: $menu-hover-bg;
@@ -69,7 +91,7 @@ $button-size: 3rem;
}
.linkIcon {
margin-left: auto;
display: inline-block;
transform: rotate(45deg);
}
@@ -1,24 +1,17 @@
import { memo, useRef } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
@@ -26,54 +19,81 @@ import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
interface NavigationMenuProps {
isOpen: boolean;
onClose: () => void;
}
function NavigationMenu() {
const location = useLocation();
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={toggle}
onClick={handleFullscreen}
onKeyDown={(event) => {
isKeyEnter(event) && toggle();
isKeyEnter(event) && handleFullscreen();
}}
>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
{isFullScreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
isKeyEnter(event) && handleMirror();
}}
>
Flip Screen
@@ -83,35 +103,20 @@ function NavigationMenu(props: NavigationMenuProps) {
className={style.link}
tabIndex={0}
role='button'
onClick={onRenameOpen}
onClick={onOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onRenameOpen();
isKeyEnter(event) && onOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
<Link to='/cuesheet' className={style.link} tabIndex={0}>
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
@@ -127,10 +132,11 @@ function NavigationMenu(props: NavigationMenuProps) {
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</DrawerBody>
</DrawerContent>
</Drawer>
</div>
)}
</div>
</div>,
document.body,
);
}
@@ -1,16 +0,0 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -1,27 +0,0 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
export default memo(ViewNavigationMenu);
@@ -0,0 +1,5 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -13,6 +13,8 @@ import {
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
@@ -43,18 +45,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody>
<ModalBody className={style.modalBody}>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled'
variant='ontime-filled-on-light'
/>
<Button
isDisabled={newName === clientName || !newName}
@@ -1,14 +1,13 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
$progress-bar-br: 6px;
.progress-bar__bg {
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
@@ -19,5 +18,6 @@ $progress-bar-br: 3px;
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
border-radius: $progress-bar-br 0 0 $progress-bar-br;
transition-property: width;
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return (
<div className={style.container}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
@@ -57,15 +57,8 @@ export default function PinPage(props: PinPageProps) {
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
</div>
);
}
@@ -1,31 +1,27 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
background: $bg-container-l1;
color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200;
font-size: 3rem;
text-align: center;
font-size: 3vw;
}
.pin,
.pin__failed {
display: flex;
gap: 0.125em;
padding-block: 0.5em;
padding: 20px;
input {
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
border-radius: 50%;
}
button {
margin-left: 1em;
margin-left: 20px;
}
}
@@ -37,9 +33,9 @@
@keyframes colourFade {
from {
background: $red-500;
background: $action-blue;
}
to {
background: rgba($red-500, 0);
background: rgba($action-blue, 0);
}
}
}
@@ -1,9 +1,24 @@
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
justify-content: end;
justify-content: start;
gap: $section-spacing;
button {
button[type='reset'] {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
padding: 0 2em;
}
}
@@ -11,9 +26,6 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
}
.columnSection {
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocation, useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
@@ -12,26 +12,30 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
acc[id] = String(defaultValue);
return acc;
return { ...acc, [id]: String(defaultValue) };
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
if (typeof value === 'string' && value.length) {
if (defaultValues[id] === value) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
@@ -43,6 +47,8 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => {
const isEditing = searchParams.get('edit');
@@ -52,16 +58,37 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
const handleClose = () => {
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
if (viewParamsObjFromLocalStorage !== undefined) {
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
*/
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
onClose();
};
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -69,21 +96,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setSearchParams(newSearchParams);
onClose();
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
Customise
</DrawerHeader>
<DrawerBody>
<DrawerBody className={style.drawerContent}>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
@@ -99,7 +126,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset to default
Reset
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
@@ -26,6 +26,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) {
@@ -52,6 +53,10 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
return savedPin == null || savedPin === '' || pin === savedPin;
}
if (!data) {
return false;
}
if (permission === 'editor') {
const correct = isValid(pin, data.editorKey);
if (correct) {
@@ -11,7 +11,7 @@ export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -11,13 +11,14 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
@@ -10,7 +10,7 @@ export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: ontimePlaceholderInfo,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,7 +9,7 @@ export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -14,7 +14,7 @@ export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: placeholderProjectList,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -13,7 +13,7 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/constants';
@@ -10,22 +9,12 @@ export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: ontimePlaceholderSettings,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
select: (data) => {
const unobfuscated = { ...data };
if (data.editorKey) {
unobfuscated.editorKey = unobfuscate(data.editorKey);
}
if (data.operatorKey) {
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
}
return unobfuscated;
},
});
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
return { data, status, isFetching, isError, refetch };
}
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,12 +9,12 @@ export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: viewsSettingsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
return { data, status, isError, refetch, isFetching };
}
+17 -20
View File
@@ -25,7 +25,9 @@ import { forgivingStringToMillis } from '../utils/dateConfig';
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
/**
* Calls mutation to add new event
@@ -43,12 +45,11 @@ export const useEventAction = () => {
after?: string;
};
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
type EventOptions = BaseOptions & {
defaultPublic?: boolean;
lastEventId?: string;
startTimeIsLastEnd?: boolean;
};
/**
* Adds an event to rundown
@@ -60,31 +61,27 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
after: options?.after,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
}
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
}
// handle adding options that concern all event type
@@ -98,7 +95,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
);
/**
@@ -0,0 +1,68 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useCallback, useEffect, useState } from 'react';
interface WebkitDocument extends Document {
webkitFullscreenElement?: Element | null;
webkitIsFullScreen?: boolean;
webkitExitFullscreen?: () => Promise<void>;
webkitRequestFullscreen?: () => Promise<void>;
}
export default function useFullscreen() {
const [isFullScreen, setFullScreen] = useState(
document.fullscreenElement || (document as WebkitDocument).webkitFullscreenElement,
);
useEffect(() => {
const handleChange = () => {
if (typeof (document as WebkitDocument).webkitFullscreenElement !== 'undefined') {
setFullScreen((document as WebkitDocument).webkitFullscreenElement);
} else {
setFullScreen(document.fullscreenElement);
}
};
(document as WebkitDocument).addEventListener('webkitfullscreenchange', handleChange, { passive: true });
document.addEventListener('fullscreenchange', handleChange, { passive: true });
document.addEventListener('resize', handleChange, { passive: true });
return () => {
(document as WebkitDocument).removeEventListener('webkitfullscreenchange', handleChange);
document.removeEventListener('fullscreenchange', handleChange);
document.removeEventListener('resize', handleChange);
};
}, []);
const toggleFullScreen = useCallback(() => {
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
// Fullscreen mode is not active, so we can enter fullscreen mode
const element = document.documentElement;
if (element.requestFullscreen) {
// Standard fullscreen API is supported
element.requestFullscreen().catch(() => {
/* nothing to do */
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */
});
}
} else {
// Fullscreen mode is active, so we can exit fullscreen mode
if (document.exitFullscreen) {
// Standard fullscreen API is supported
document.exitFullscreen().catch((error) => {
console.error('Error while trying to exit fullscreen:', error);
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */
});
}
}
}, []);
return { isFullScreen, toggleFullScreen };
}
@@ -1,15 +0,0 @@
import { useEffect, useRef } from 'react';
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
const ref = useRef<T>(null);
useEffect(() => {
if (location && ref.current) {
if (location === name) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [location, name]);
return ref;
}
+11 -11
View File
@@ -59,12 +59,12 @@ export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start', 'next'),
startNext: () => socketSendJson('start', { next: true }),
previous: () => {
socketSendJson('load', 'previous');
socketSendJson('load', { previous: true });
},
next: () => {
socketSendJson('load', 'next');
socketSendJson('load', { next: true });
},
stop: () => {
socketSendJson('stop');
@@ -90,26 +90,26 @@ export const useInfoPanel = () => {
};
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
const featureSelector = (state: RuntimeStore) => state.timer1.current;
return useRuntimeStore(featureSelector);
};
export const useExtraTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
playback: state.timer1.playback,
direction: state.timer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setExtraTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
};
export const useCuesheet = () => {
@@ -1,11 +0,0 @@
import { useEffect } from 'react';
/**
* Sets tab title
* @param title
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
}, []);
}
@@ -1,10 +1,9 @@
import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
};
+20 -19
View File
@@ -3,43 +3,50 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
linkPrevious: boolean;
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
defaultDuration: string;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultDuration: (defaultDuration: string) => void;
};
enum EditorSettingsKeys {
LinkPrevious = 'ontime-link-previous',
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
DefaultDuration = 'ontime-default-duration',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setLinkPrevious: (linkPrevious) =>
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { eventSettings: { ...state.eventSettings, linkPrevious } };
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
@@ -47,10 +54,4 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setDefaultDuration: (defaultDuration) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { eventSettings: { ...state.eventSettings, defaultDuration } };
}),
}));
+1 -1
View File
@@ -49,7 +49,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
eventNext: null,
publicEventNow: null,
publicEventNext: null,
auxtimer1: {
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
-1
View File
@@ -8,5 +8,4 @@ export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/;
+3 -3
View File
@@ -114,9 +114,9 @@ export const connectSocket = (preferredClientName?: string) => {
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
case 'ontime-timer1': {
patchRuntime('timer1', payload);
updateDevTools({ timer1: payload });
break;
}
}
@@ -33,4 +33,3 @@ export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
export const enDash = '';
export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––';
+2 -2
View File
@@ -2,7 +2,7 @@
* Collection of rules for pre-validating a spreadsheet
* @param file
*/
export function validateExcelImport(file: File) {
export function validateSpreadsheetImport(file: File) {
if (!isExcelFile(file)) {
throw new Error('Unknown file type');
}
@@ -12,7 +12,7 @@ export function validateExcelImport(file: File) {
throw new Error('File is empty');
}
// Limit file size of an Excel file to around 10MB
// Limit file size of an excel file to around 10MB
if (file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
@@ -3,36 +3,41 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import InterfacePanel from './panel/interface-panel/InterfacePanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import useAppSettingsNavigation from './useAppSettingsNavigation';
import { useSettingsStore } from './settingsStore';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape');
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const selectedPanel = useSettingsStore((state) => state.showSettings);
const closeSettings = () => {
setShowSettings(null);
};
useKeyDown(closeSettings, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
<PanelList />
<PanelContent onClose={closeSettings}>
{selectedPanel === 'project' && <ProjectPanel />}
{selectedPanel === 'general' && <GeneralPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'sources' && <SourcesPanel />}
{selectedPanel === 'interface' && <InterfacePanel />}
{selectedPanel === 'integrations' && <IntegrationsPanel />}
{selectedPanel === 'about' && <AboutPanel />}
{selectedPanel === 'log' && <LogPanel />}
</PanelContent>
</ErrorBoundary>
</div>
@@ -1,7 +1,7 @@
.corner {
position: absolute;
top: 1rem;
right: 2rem;
right: 1rem;
z-index: 100;
}
@@ -58,8 +58,4 @@ ul {
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
&.active {
color: $blue-400;
}
}
@@ -2,18 +2,16 @@ import { Fragment } from 'react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
import style from './PanelList.module.scss';
interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
export default function PanelList() {
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
const handleSelect = (panel: SettingsOption) => {
setShowSettings(panel.id);
};
return (
<ul className={style.tabs}>
@@ -22,7 +20,7 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
const classes = cx([
style.primary,
selectedPanel === panel.id ? style.active : null,
showSettings === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
@@ -31,9 +29,9 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onClick={() => handleSelect(panel)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
isKeyEnter(event) && handleSelect(panel);
}}
className={classes}
tabIndex={0}
@@ -42,18 +40,8 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
{secondary.label}
</li>
);
@@ -75,6 +75,10 @@ $inner-padding: 1rem;
box-shadow: 0 1px $white-10;
}
tr {
padding: 1rem 0;
}
th {
font-weight: 400;
color: $gray-400;
@@ -1,24 +0,0 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
return (
<>
<Panel.Header>Feature Settings</Panel.Header>
<div ref={customFieldsRef}>
<CustomFields />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
</>
);
}
@@ -1,98 +0,0 @@
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
<br />
This data is not used by Ontime.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
field={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -2,3 +2,23 @@
display: flex;
gap: 1em;
}
.pad {
padding: 0 2rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.fullWidth {
width: 100%;
}
.flex {
display: flex;
}
@@ -1,28 +1,16 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm';
import UrlPresetsForm from './UrlPresetsForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel({ location }: PanelBaseProps) {
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
export default function GeneralPanel() {
return (
<>
<Panel.Header>App Settings</Panel.Header>
<div ref={generalRef}>
<GeneralPanelForm />
</div>
<div ref={editorRef}>
<EditorSettingsForm />
</div>
<div ref={viewRef}>
<ViewSettingsForm />
</div>
<Panel.Header>Settings</Panel.Header>
<GeneralPanelForm />
<ViewSettingsForm />
<UrlPresetsForm />
</>
);
}
@@ -15,7 +15,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../PanelUtils';
import style from './FeatureSettings.module.scss';
import style from './GeneralPanel.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
@@ -91,7 +91,7 @@ export default function UrlPresetsForm() {
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
<Panel.Card>
<Panel.SubHeader>
URL presets
URL Presets
<div className={style.actionButtons}>
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
@@ -99,13 +99,7 @@ export default function ViewSettingsForm() {
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
/>
<Controller
control={control}
name='overrideStyles'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
@@ -124,23 +118,7 @@ export default function ViewSettingsForm() {
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Freeze timer on end'
description='Timer in views will stop from going negative after reaching'
/>
<Controller
control={control}
name='freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='End message'
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
/>
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
<Input
size='sm'
autoComplete='off'
@@ -1,4 +1,4 @@
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
@@ -75,7 +75,7 @@ export default function HttpIntegrations() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP settings
HTTP
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
@@ -99,13 +99,7 @@ export default function HttpIntegrations() {
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
</Panel.ListGroup>
@@ -2,10 +2,6 @@
width: 100%;
}
.halfWidth {
width: 50%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
}
@@ -1,8 +1,6 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
@@ -10,10 +8,7 @@ import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel({ location }: PanelBaseProps) {
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
export default function IntegrationsPanel() {
return (
<>
<Panel.Header>Integration settings</Panel.Header>
@@ -30,12 +25,8 @@ export default function IntegrationsPanel({ location }: PanelBaseProps) {
</Alert>
</Panel.Section>
<Panel.Section>
<div ref={oscRef}>
<OscIntegrations />
</div>
<div ref={httpRef}>
<HttpIntegrations />
</div>
<OscIntegrations />
<HttpIntegrations />
</Panel.Section>
</>
);
@@ -1,4 +1,4 @@
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
@@ -8,7 +8,7 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
@@ -65,8 +65,7 @@ export default function OscIntegrations() {
prepend({
id: generateId(),
cycle: 'onLoad',
address: '',
payload: '',
message: '',
enabled: false,
});
};
@@ -81,7 +80,7 @@ export default function OscIntegrations() {
return (
<Panel.Card>
<Panel.SubHeader>
OSC settings
Open Sound Control
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
@@ -103,18 +102,12 @@ export default function OscIntegrations() {
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>General OSC settings</Panel.Title>
<Panel.Title>OSC Settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Controller
control={control}
name='enabledIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -147,13 +140,7 @@ export default function OscIntegrations() {
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -208,7 +195,7 @@ export default function OscIntegrations() {
<Panel.Divider />
<Panel.Title>
OSC integrations
OSC Integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
@@ -220,15 +207,14 @@ export default function OscIntegrations() {
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={field.id}>
<td>
@@ -248,40 +234,22 @@ export default function OscIntegrations() {
))}
</Select>
</td>
<td className={style.halfWidth}>
<td className={style.fullWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/'
{...register(`subscriptions.${index}.address`, {
placeholder='/from-ontime/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
validate: {
oscStartsWithSlash: (value) =>
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC address only allow ASCII characters',
pattern: {
value: startsWithSlash,
message: 'OSC messages should start with a forward slash',
},
})}
/>
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='{{timer.current}}'
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
},
})}
/>
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
@@ -1,99 +1,54 @@
import { Switch } from '@chakra-ui/react';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings);
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Rundown options</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Default duration'
description='When creating a new event, what is the default duration'
/>
<TimeInput<'defaultDuration'>
name='defaultDuration'
submitHandler={(_field, value) => setDefaultDuration(value)}
time={durationInMs}
placeholder='00:10:00'
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='New events start time will be linked to the previous event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show under selected event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowQuickEntry(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Start time is last end'
description='New events start time will be the previous event end'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.startTimeIsLastEnd}
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
);
@@ -6,7 +6,7 @@ import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Log</Panel.Header>
<Panel.Header>Application log</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
@@ -56,7 +56,7 @@ export default function ProjectData() {
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
<Panel.Card>
<Panel.SubHeader>
Project data
Project Data
<div className={style.headerButtons}>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
Revert to saved
@@ -29,21 +29,22 @@ export default function ProjectList() {
};
const reorderedProjectFiles = useMemo(() => {
if (!data.files?.length) return [];
if (!data?.files?.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
return [current, ...projectFiles];
}, [data.files?.length, files, lastLoadedProject]);
}, [data.files.length, files, lastLoadedProject]);
return (
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>Project Name</th>
<th>Last Used</th>
<th>Date Created</th>
<th>Date Modified</th>
<th />
</tr>
</thead>
@@ -52,6 +53,7 @@ export default function ProjectList() {
<ProjectListItem
key={project.filename}
filename={project.filename}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
@@ -5,7 +5,7 @@ import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHoriz
import {
deleteProject,
downloadCSV,
downloadProject,
downloadRundown,
duplicateProject,
loadProject,
renameProject,
@@ -21,6 +21,7 @@ export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps {
current?: boolean;
filename: string;
createdAt: string;
updatedAt: string;
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
onSubmit: () => void;
@@ -31,13 +32,14 @@ interface ProjectListItemProps {
export default function ProjectListItem({
current,
updatedAt,
createdAt,
editingFilename,
editingMode,
filename,
onRefetch,
onSubmit,
onToggleEditMode,
updatedAt,
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -100,6 +102,7 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
@@ -145,7 +148,7 @@ function ActionMenu({
};
const handleDownload = async () => {
await downloadProject(filename);
await downloadRundown(filename);
};
const handleExportCSV = async () => {
@@ -1,5 +1,5 @@
.current {
background-color: $blue-1100;
background-color: $blue-900;
}
.actionButton {
@@ -1,23 +1,14 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
export default function ProjectPanel({ location }: PanelBaseProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
export default function ProjectPanel() {
return (
<>
<Panel.Header>Project</Panel.Header>
<div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRef}>
<ManageProjects />
</div>
<ProjectData />
<ManageProjects />
</>
);
}
@@ -4,14 +4,13 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Swatch from '../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm';
import style from '../FeatureSettings.module.scss';
import style from './ProjectSettingsPanel.module.scss';
interface CustomFieldEntryProps {
field: string;
colour: string;
label: string;
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
@@ -19,11 +18,13 @@ interface CustomFieldEntryProps {
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete, field } = props;
const { colour, label, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(field, patch);
const oldLabel = label;
await onEdit(oldLabel, patch);
setIsEditing(false);
};
@@ -63,7 +64,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(field)}
onClick={() => onDelete(label)}
/>
</td>
</tr>
@@ -4,11 +4,11 @@ import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../../PanelUtils';
import { maybeAxiosError } from '../../../../common/api/utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../PanelUtils';
import style from '../FeatureSettings.module.scss';
import style from './ProjectSettingsPanel.module.scss';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
@@ -4,12 +4,7 @@
.actions {
display: flex;
gap: 0.5px;
}
.actionButtons {
display: flex;
gap: 1em;
gap: 0.5rem;
}
.fieldForm {
@@ -25,15 +20,3 @@
justify-content: flex-end;
gap: 1rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.flex {
display: flex;
}
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import * as Panel from '../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function ProjectSettingsPanel() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<>
<Panel.Header>Project Settings</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
<br />
<br />
This data is not used by Ontime.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -1,27 +0,0 @@
import { Button } from '@chakra-ui/react';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent();
const sendShutdown = () => {
sendToElectron('shutdown', 'now');
};
return (
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
Shutdown ontime
</Button>
</Panel.Section>
</>
);
}
@@ -1,10 +1,8 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { Button, Input } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
@@ -29,8 +27,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
@@ -56,6 +53,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
};
const handleCancelFlow = async () => {
await handleRevoke();
onCancel();
};
@@ -91,15 +89,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
if (result?.authenticated) {
setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') {
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
}
setLoading('');
return;
}
@@ -190,7 +179,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
@@ -199,7 +187,8 @@ export default function GSheetSetup(props: GSheetSetupProps) {
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate}
isDisabled={!canAuthenticate || isLoading}
isLoading={loading === 'authenticate' || isAuthenticating}
>
Authenticate
</Button>
@@ -14,11 +14,10 @@ interface ImportReviewProps {
rundown: OntimeRundown;
customFields: CustomFields;
onFinished: () => void;
onCancel: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
const { rundown, customFields, onFinished } = props;
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
@@ -26,7 +25,7 @@ export default function ImportReview(props: ImportReviewProps) {
const handleCancel = () => {
resetPreview();
onCancel();
onFinished();
};
const applyImport = async () => {
@@ -26,7 +26,6 @@
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
@@ -2,16 +2,11 @@ import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ImportMap, unpackError } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { importSpreadsheetPreview } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm';
@@ -26,43 +21,36 @@ import style from './SourcesPanel.module.scss';
export default function SourcesPanel() {
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none');
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
const { exportRundown, importRundownPreview, revoke, verifyAuth } = useGoogleSheet();
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const spreadsheet = useSheetStore((state) => state.spreadsheet);
const setSpreadsheet = useSheetStore((state) => state.setSpreadsheet);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const fileToUpload = event.target.files?.[0];
if (!fileToUpload) {
setWorksheets(null);
setHasFile('none');
setSpreadsheet(null);
return;
}
try {
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names);
validateSpreadsheetImport(fileToUpload);
setSpreadsheet(fileToUpload);
setImportFlow('excel');
setHasFile('done');
} catch (error) {
const errorMessage = getErrorMessage(error);
const errorMessage = unpackError(error);
setError(`Error uploading file: ${errorMessage}`);
setWorksheets(null);
setHasFile('none');
setSpreadsheet(null);
}
};
@@ -70,16 +58,7 @@ export default function SourcesPanel() {
fileInputRef.current?.click();
};
const openGSheetFlow = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
}
}
const openGSheetFlow = () => {
setImportFlow('gsheet');
};
@@ -89,8 +68,9 @@ export default function SourcesPanel() {
const handleSubmitImportPreview = async (importMap: ImportMap) => {
if (importFlow === 'excel') {
if (!spreadsheet) return;
try {
const previewData = await importRundownPreviewExcel(importMap);
const previewData = await importSpreadsheetPreview(spreadsheet, importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
} catch (error) {
@@ -106,9 +86,12 @@ export default function SourcesPanel() {
const cancelImportMap = async () => {
setImportFlow('none');
setHasFile('none');
setWorksheets(null);
if (spreadsheet) {
setSpreadsheet(null);
}
if (authenticationStatus === 'authenticated') {
await revoke();
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
@@ -119,8 +102,7 @@ export default function SourcesPanel() {
const handleFinished = () => {
setImportFlow('finished');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setSpreadsheet(null);
setCustomFields(null);
};
@@ -131,11 +113,12 @@ export default function SourcesPanel() {
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const hasFile = Boolean(spreadsheet);
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile);
const showReview = rundown !== null && customFields !== null;
return (
@@ -158,25 +141,13 @@ export default function SourcesPanel() {
/>
<div className={style.uploadSection}>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoDownloadOutline />}
onClick={handleUpload}
isLoading={hasFile === 'loading'}
>
<Button variant='ontime-filled' size='sm' leftIcon={<IoDownloadOutline />} onClick={handleUpload}>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
<Button variant='ontime-filled' size='sm' leftIcon={<IoCloudOutline />} onClick={openGSheetFlow}>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
@@ -201,14 +172,7 @@ export default function SourcesPanel() {
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && (
<ImportReview
rundown={rundown}
customFields={customFields}
onFinished={handleFinished}
onCancel={cancelImportMap}
/>
)}
{showReview && <ImportReview rundown={rundown} customFields={customFields} onFinished={handleFinished} />}
</Panel.Card>
</Panel.Section>
</>
@@ -1,13 +1,12 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { Button, IconButton, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore';
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
@@ -24,7 +23,7 @@ interface ImportMapFormProps {
export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
control,
handleSubmit,
@@ -42,7 +41,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
});
const stepData = useSheetStore((state) => state.stepData);
const worksheetNames = useSheetStore((state) => state.worksheetNames);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
@@ -54,11 +52,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
setLoading('');
};
const handleRevoke = async () => {
await revoke();
onCancel();
};
const handleImportPreview = async (values: NamedImportMap) => {
setLoading('import');
const importMap = convertToImportMap(values);
@@ -85,13 +78,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<Panel.Title>
Import options
<div className={style.buttonRow}>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
Revoke
</Button>
</Tooltip>
)}
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
Cancel
</Button>
@@ -130,30 +116,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
if (label === 'custom') {
return null;
}
if (label === 'Worksheet') {
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Select
variant='ontime'
id={importName as string}
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
</td>
<td className={style.singleActionCell} />
</tr>
);
}
return (
<tr key={importName as string}>
<td>{label}</td>
@@ -23,7 +23,7 @@ export default function useGoogleSheet() {
const setCustomFields = useSheetStore((state) => state.setCustomFields);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
@@ -5,12 +5,13 @@ import { create } from 'zustand';
type SheetStore = {
stepData: typeof initialStepData;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
setWorksheets: (worksheetNames: string[] | null) => void;
worksheetNames: string[] | null;
//gSheet
spreadsheet: File | null;
setSpreadsheet: (spreadsheet: File | null) => void;
sheetId: string | null;
setSheetId: (sheetId: string | null) => void;
authenticationStatus: AuthenticationStatus;
setAuthenticationStatus: (status: AuthenticationStatus) => void;
@@ -38,7 +39,7 @@ const initialStepData = {
const initialState = {
stepData: initialStepData,
worksheetNames: null,
spreadsheet: null,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
@@ -54,7 +55,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
set({ stepData: { ...stepData, ...patch } });
},
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
setSpreadsheet: (spreadsheet: File | null) => set({ spreadsheet }),
setSheetId: (sheetId: string | null) => set({ sheetId }),
@@ -11,28 +11,23 @@ export const settingPanels: Readonly<SettingsOption[]> = [
{
id: 'project',
label: 'Project',
secondary: [
{ id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' },
],
secondary: [{ id: 'project__manage', label: 'Manage project files' }],
},
{
id: 'general',
label: 'App Settings',
label: 'General',
secondary: [
{ id: 'general__settings', label: 'General settings' },
{ id: 'general__editor', label: 'Editor settings' },
{ id: 'general__manage', label: 'Manage Ontime settings' },
{ id: 'general__view', label: 'View settings' },
{ id: 'general__urlPresets', label: 'URL presets' },
],
},
{
id: 'feature_settings',
label: 'Feature Settings',
secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
],
id: 'project_settings',
label: 'Project Settings',
secondary: [{ id: 'project_settings__custom', label: 'Custom fields' }],
},
{ id: 'interface', label: 'Interface', secondary: [{ id: 'general__editor', label: 'Editor settings' }] },
{
id: 'sources',
label: 'Data Sources',
@@ -56,20 +51,14 @@ export const settingPanels: Readonly<SettingsOption[]> = [
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
export interface PanelBaseProps {
location?: string;
}
const firstPanel = settingPanels[0].id;
type SettingsStore = {
showSettings: SettingsOptionId | null;
setShowSettings: (panelId?: SettingsOptionId | null) => void;
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
@@ -77,6 +66,16 @@ type SettingsStore = {
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
showSettings: null,
setShowSettings: (panelId?: SettingsOptionId | null) => {
const newPanel = panelId === undefined ? firstPanel : panelId;
set((state) => {
return {
...state,
showSettings: newPanel,
};
});
},
unsavedChanges: new Set(),
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
addUnsavedChanges: (panelId: SettingsOptionId) =>
@@ -1,32 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SettingsOptionId } from './settingsStore';
const settingsKey = 'settings';
export default function useAppSettingsNavigation() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedPanel = useMemo(
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
[searchParams],
);
const isOpen = useMemo(() => Boolean(searchParams.get(settingsKey)), [searchParams]);
const [panel, location] = selectedPanel.split('__');
const close = useCallback(() => {
searchParams.delete(settingsKey);
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const setLocation = useCallback(
(panelId: SettingsOptionId) => {
searchParams.set(settingsKey, panelId);
setSearchParams(searchParams);
},
[searchParams, setSearchParams],
);
return { isOpen, panel, location, setLocation, close };
}
@@ -11,8 +11,6 @@ import InputRow from './InputRow';
import style from './MessageControl.module.scss';
const noop = () => undefined;
export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
@@ -52,7 +50,7 @@ export default function MessageControl() {
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
Blink message
</Button>
<Button
size='sm'
@@ -66,13 +64,13 @@ export default function MessageControl() {
</Button>
</div>
<InputRow
label='External Message (read only)'
label='External Message'
placeholder={enDash}
readonly
text={message.external.text || ''}
visible={message.external.visible || false}
changeHandler={noop}
actionHandler={noop}
changeHandler={() => undefined}
actionHandler={() => undefined}
/>
</div>
);
@@ -32,8 +32,8 @@ export function ExtraTimer() {
<div className={style.extraRow}>
<ExtraTimeInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountDown && <IoArrowDown />}
{direction === SimpleDirection.CountUp && <IoArrowUp />}
</TapButton>
<TapButton
@@ -42,7 +42,7 @@ export function ExtraTimer() {
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
<IoPlay />
</TapButton>
<TapButton
onClick={pause}
@@ -50,10 +50,10 @@ export function ExtraTimer() {
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
<IoPause />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
<IoStop />
</TapButton>
</div>
);
@@ -61,14 +61,14 @@ export function ExtraTimer() {
function ExtraTimeInput() {
const time = useExtraTimerTime();
const { setDuration } = setExtraTimer;
const { setTime } = setExtraTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
setDuration(newTime / 1000); //frontend api is seconds based;
setTime(newTime);
};
return (
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
<TimeInput<'extraTimer'> submitHandler={handleTimeUpdate} name='extraTimer' time={time} placeholder='Timer 1' />
);
}
@@ -34,7 +34,6 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback);
@@ -74,7 +73,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableNext}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
@@ -86,17 +86,16 @@
grid-area: 2 / 2 / 2 / 4 ;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
@@ -1,6 +1,6 @@
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -17,10 +17,9 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
const { playback } = props;
const timer = useTimer();
// TODO: checkout typescript in utilities
const started = millisToString(timer.startedAt);
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
const finish = millisToString(expectedFinish);
const finish = millisToString(timer.expectedFinish);
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
@@ -73,11 +72,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.tag}>Started at </span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.tag}>Finish at </span>
<span className={style.time}>{finish}</span>
</div>
</>
@@ -1,14 +1,21 @@
.tableWrapper {
width: 100%;
height: 100vh;
padding: 1rem 0.5rem;
padding: 1rem;
display: grid;
grid-template-rows: 3rem auto 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
'overview'
'header'
'settings'
'table';
gap: 1rem;
color: $ui-white;
background-color: $gray-1300;
color: white;
& > * {
border: 1px solid $white-10;
border-radius: 3px;
}
}
@@ -1,22 +1,17 @@
import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import { useCallback, useEffect, useMemo } from 'react';
import { CustomFieldLabel, isOntimeEvent, ProjectData } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { useCuesheetSettings } from './store/CuesheetSettings';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import { makeCSV, makeTable } from './cuesheetUtils';
import styles from './CuesheetWrapper.module.scss';
@@ -24,14 +19,15 @@ export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
useWindowTitle('Cuesheet');
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
/**
* Handles updating a field
@@ -78,29 +74,38 @@ export default function CuesheetWrapper() {
[flatRundown, rundownStatus, updateCustomField],
);
const exportHandler = useCallback(
(headerData: ProjectData) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
const sheetData = makeTable(headerData, flatRundown, customFields);
const csvContent = makeCSV(sheetData);
const fileName = 'ontime rundown.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
return;
},
[flatRundown, rundownStatus, customFields],
);
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<CuesheetOverview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</CuesheetOverview>
<CuesheetTableHeader handleExport={exportHandler} featureData={featureData} />
<CuesheetProgress />
<Cuesheet
data={flatRundown}
@@ -1,4 +1,4 @@
import { memo, useEffect } from 'react';
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
@@ -31,12 +31,6 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -1,8 +1,12 @@
import { Tooltip } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -24,7 +28,10 @@ interface CuesheetTableHeaderProps {
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: project } = useProjectData();
const exportProject = () => {
@@ -59,6 +66,19 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
<span
onClick={() => toggleSettings()}
className={cx([style.actionIcon, showSettings ? style.enabled : null])}
>
<IoSettingsOutline />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionText} onClick={exportProject}>
Export CSV
@@ -23,14 +23,12 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
toggleFollow,
showPrevious,
togglePreviousVisibility,
toggleDelayVisibility,
showDelayBlock,
showDelayedTimes,
toggleDelayedTimes,
toggleDelayVisibility,
togglePreviousVisibility,
} = useCuesheetSettings();
return (
@@ -55,10 +53,6 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
@@ -1,5 +1,6 @@
@use './EditorMixin' as editor;
$menu-width: 2.75rem;
$min-playback-width: 27rem;
$max-playback-width: 35rem;
$panel-gap: 0.5rem;
@@ -16,11 +17,11 @@ $panel-gap: 0.5rem;
padding: 1rem 0.5rem;
display: grid;
grid-template-columns: auto;
grid-template-columns: $menu-width auto;
grid-template-rows: 3rem 1fr;
grid-template-areas:
'overview'
'main';
'menu overview'
'menu main';
gap: $panel-gap;
}
+19 -67
View File
@@ -1,14 +1,10 @@
import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { lazy, useEffect } from 'react';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
import MenuBar from '../menu/MenuBar';
import Overview from '../overview/Overview';
import styles from './Editor.module.scss';
@@ -17,71 +13,26 @@ const TimerControl = lazy(() => import('../control/playback/TimerControlExport')
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const showSettings = useSettingsStore((state) => state.showSettings);
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
const handleSettings = (newTab?: SettingsOptionId) => {
setShowSettings(newTab);
};
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
toggleSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[toggleSettings],
);
// register ctrl + , to open settings
// Set window title
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
document.title = 'ontime - Editor';
}, []);
useWindowTitle('Editor');
const isSettingsOpen = Boolean(showSettings);
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={toggleSettings}
/>
</EditorOverview>
{isSettingsOpen ? (
<ErrorBoundary>
<MenuBar openSettings={handleSettings} isSettingsOpen={isSettingsOpen} />
</ErrorBoundary>
{showSettings ? (
<AppSettings />
) : (
<div id='panels' className={styles.panelContainer}>
@@ -92,6 +43,7 @@ export default function Editor() {
<Rundown />
</div>
)}
<Overview />
</div>
);
}
@@ -0,0 +1,20 @@
.menu {
grid-area: menu;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
padding-bottom: 1rem
}
.gap {
height: 1em;
}
.open {
background: $blue-700;
}
.bottom {
margin-top: auto;
}
+83
View File
@@ -0,0 +1,83 @@
import { memo, useCallback, useEffect } from 'react';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { cx } from '../../common/utils/styleUtils';
import style from './MenuBar.module.scss';
interface MenuBarProps {
openSettings: (newTab?: string) => void;
isSettingsOpen: boolean;
}
const buttonStyle = {
fontSize: '1.25em',
size: 'md',
colorScheme: 'white',
_hover: {
background: 'rgba(255, 255, 255, 0.10)', // $white-10
},
_active: {
background: 'rgba(255, 255, 255, 0.13)', // $white-13
},
};
const MenuBar = (props: MenuBarProps) => {
const { openSettings, isSettingsOpen } = props;
const { isElectron, sendToElectron } = useElectronEvent();
const sendShutdown = () => {
if (isElectron) {
sendToElectron('shutdown', 'now');
}
};
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
openSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[openSettings],
);
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
return (
<div className={style.menu}>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<TooltipActionBtn
{...buttonStyle}
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
icon={<IoSettingsOutline />}
clickHandler={() => openSettings()}
tooltip='Application settings'
aria-label='Application settings'
/>
</div>
);
};
export default memo(MenuBar);
@@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
@@ -57,7 +57,10 @@ export default function Operator() {
topOffset: selectedOffset,
});
useWindowTitle('Operator');
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
// reset scroll if nothing is selected
useEffect(() => {
@@ -138,6 +141,7 @@ export default function Operator() {
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
@@ -1,29 +0,0 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Operator from './Operator';
export default function OperatorExport() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = isOpen ? onClose : onOpen;
return (
<ProtectRoute permission='operator'>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
<Operator />
</ProtectRoute>
);
}
@@ -1,20 +1,10 @@
.overview {
grid-area: overview;
font-size: $inner-section-text-size;
display: flex;
}
.nav {
display: flex;
gap: 0.5rem;
}
.info {
flex: 1;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: $inner-section-text-size;
padding: 0 1rem;
}
.title {

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