Compare commits

..

50 Commits

Author SHA1 Message Date
arc-alex d763f3f6b9 open using external browser 2023-12-12 15:46:29 +01:00
arc-alex fdee764c28 fix: deepsource recommendations 2023-12-12 15:06:16 +01:00
arc-alex a750c04bad try to forward err to the user 2023-12-12 14:37:32 +01:00
arc-alex a7a7a7d886 fix key check and ensureDirectory on startup 2023-12-12 14:25:22 +01:00
arc-alex a98bd0f4a4 ensure sheets folder 2023-12-12 11:52:04 +01:00
arc-alex c5b8a133b5 Merge commit master into google-sheets-lite 2023-12-12 11:43:10 +01:00
arc-alex b37494109a ifx: use template string not concatination 2023-11-27 23:27:10 +01:00
arc-alex aaa1fb9368 fix: saveClientSecrets type object 2023-11-27 23:24:36 +01:00
arc-alex cb198bc3fe comments 2023-11-26 17:29:12 +01:00
arc-alex 6880f459b6 check keys of client secrets 2023-11-26 17:11:55 +01:00
arc-alex 7b34c8f3b1 push project data 2023-11-26 16:48:34 +01:00
arc-alex c714727801 useEffect 2023-11-26 14:26:05 +01:00
arc-alex 8fc70fa162 cellRequenstFromEvent change 2023-11-26 14:23:12 +01:00
arc-alex 54c4a7f68c add test 2023-11-26 14:16:50 +01:00
arc-alex 9820c71a2d move util functions to separat file for testing 2023-11-25 20:03:30 +01:00
arc-alex 2ff7d0f546 pass lint 2023-11-25 19:56:19 +01:00
arc-alex f29ed1b05a types for getA1Notation function 2023-11-25 19:54:33 +01:00
arc-alex b1de7ffe5b Merge branch 'master' into google-sheets-lite 2023-11-25 19:39:53 +01:00
arc-alex 46d17e0570 extend authServer timeout if opened again 2023-11-24 21:17:30 +01:00
arc-alex 38e3ef7586 dont save login token Between Sessions 2023-11-24 20:07:50 +01:00
arc-alex e8bcbb4291 clean up sheet flow 2023-11-24 17:47:21 +01:00
arc-alex 111df94d6d parseGoogleSheet from db 2023-11-24 16:49:37 +01:00
arc-alex d32d8a547d lint 2023-11-23 18:38:01 +01:00
arc-alex 5bc2c2f241 remove unused 2023-11-23 17:55:22 +01:00
arc-alex 28533464d4 add db test for google sheet 2023-11-23 17:49:08 +01:00
arc-alex 4f1fa2053f sheet settings with feedback 2023-11-23 17:17:13 +01:00
arc-alex b15b4b48a7 add googleSheet to db model 2023-11-23 14:53:22 +01:00
arc-alex ef2fa99673 finde correct range 2023-11-19 20:15:07 +01:00
arc-alex 85ee9de528 lint 2023-11-19 19:39:13 +01:00
arc-alex e52a75db7d test sheet exist and get worksheet id 2023-11-19 19:24:56 +01:00
arc-alex 07ebb5a321 fix merge 2023-11-19 19:24:31 +01:00
arc-alex 071cae4cc6 Merge remote-tracking branch 'org/master' into google-sheets-lite 2023-11-19 18:28:57 +01:00
arc-alex 543e365f82 push to sheet 2023-11-19 17:04:14 +01:00
arc-alex a7c9ce876c get cell location when from parseExcel 2023-11-19 17:03:50 +01:00
arc-alex ae4e2ceafe push function 2023-11-18 21:41:40 +01:00
arc-alex b77db4cbf6 rename to pull 2023-11-18 21:17:44 +01:00
arc-alex fe67e65a95 return auth url on first clik 2023-11-18 21:15:39 +01:00
arc-alex 785695c01c clear rundownState on modal close 2023-11-18 21:01:32 +01:00
arc-alex 65efebe180 Preview Sheet 2023-11-18 20:58:57 +01:00
arc-alex 52a9c85dbc remove duplicate routs 2023-11-18 17:57:13 +01:00
arc-alex 6b2b6dc6dd auth flow 2023-11-18 17:53:08 +01:00
arc-alex 31b46bbb34 handle client secret upload 2023-11-18 17:24:29 +01:00
arc-alex 38cf3a7b63 ensure sheets directory 2023-11-18 17:23:19 +01:00
arc-alex 81cf30daa0 use only sheets portion of google api 2023-11-17 16:15:28 +01:00
Carlos Valente e9683c6cab wip: placeholder sheets modal 2023-11-17 15:21:19 +01:00
arc-alex 370777326d add jsdoc comments 2023-11-15 10:26:04 +01:00
arc-alex 309f622077 update lock file 2023-11-15 10:09:50 +01:00
arc-alex 285b05bd65 comment 2023-11-15 09:06:03 +01:00
arc-alex 05207189cf add google sheet functions 2023-11-15 09:06:03 +01:00
arc-alex d70bb2174a install google apis 2023-11-15 09:06:03 +01:00
1398 changed files with 42467 additions and 98411 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+2 -11
View File
@@ -1,5 +1,5 @@
# Ignore formatter config files
.oxfmt*
# Ignore config files
.prettier*
# Ignore install stuff
**/yarn.lock
@@ -11,13 +11,4 @@
# Ignore git and cache folders
.git
.gitignore
.cache
# Ignore build folders
node_modules
**/node_modules
# Ignore default volumes created by running docker compose up
ontime-db
ontime-styles
+1
View File
@@ -0,0 +1 @@
"ONTIME_VERSION.js"
+62
View File
@@ -0,0 +1,62 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020
},
"env": {
"es6": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"overrides": [
{
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
}
],
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prettier/prettier": [
"warn",
{
"endOfLine": "auto"
}
]
}
}
-2
View File
@@ -1,2 +0,0 @@
github: cpvalente
buy_me_a_coffee: cpvalente
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

-61
View File
@@ -1,61 +0,0 @@
name: Ontime CLI build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_cli:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
-67
View File
@@ -1,67 +0,0 @@
name: Docker Image CI Ontime
on:
release:
types: [published]
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+56
View File
@@ -0,0 +1,56 @@
name: Docker Image CI Ontime V2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
with:
version: 16.16.0
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push Docker images
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
-142
View File
@@ -1,142 +0,0 @@
name: Ontime build
on:
push:
tags: ['*']
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-26
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60
- name: Upload macOS build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-macos-build
path: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Electron - Build app
run: pnpm dist-win
- name: Upload Windows build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-windows-build
path: |
./apps/electron/dist/ontime-win64.exe
./apps/electron/dist/win-unpacked/
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Electron - Build app
run: pnpm dist-linux
- name: Upload Linux build artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: ontime-linux-build
path: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
retention-days: 1
- name: Release
uses: softprops/action-gh-release@v2
if: github.ref_type == 'tag'
with:
files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-48
View File
@@ -1,48 +0,0 @@
name: Ontime Resolver build
on:
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm build:resolver
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+108
View File
@@ -0,0 +1,108 @@
name: Ontime build v2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-112
View File
@@ -1,112 +0,0 @@
name: Ontime test
on:
pull_request:
branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality
- name: Run formatter
run: pnpm format:check
- name: Run linter
run: pnpm lint
- name: Run TypeScript checks
run: pnpm typecheck
# We choose to run tests separately
- name: Run unit tests
if: always()
run: pnpm test
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v5
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: pnpm playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: pnpm playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v6
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+100
View File
@@ -0,0 +1,100 @@
name: Ontime test v2
on:
pull_request:
branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+3 -16
View File
@@ -27,33 +27,20 @@ ontime.code-workspace
test-results
playwright-report
/playwright/.cache/
automated-screenshots
e2e/tests/fixtures/tmp/*
# production
build/
dist/
*.tsbuildinfo
# bundled assets
translations.json
override.css
**/external/demo/index.html
# working stuff
**/TODO.md
**.local.**
# docker utils
ontime-db
ontime-external/
ontime-data/
# working database
apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
# temporary write files
**.tmp
# Claude Code metadata
.claude/
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+51
View File
@@ -0,0 +1,51 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1 +1 @@
v22.22.3
v16.16.0
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120,
"sortPackageJson": false,
"sortImports": {},
"ignorePatterns": [
"build",
"coverage",
"dist",
"node_modules",
"playwright-report",
"pnpm-lock.yaml",
"**/*.toml",
"**/*.json",
"!tsconfig.common.json",
"!turbo.json",
"!package.json"
]
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn",
"suspicious": "off",
"pedantic": "off",
"perf": "warn",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"caughtErrors": "none"
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
]
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"apps/client/build/**",
"apps/electron/assets/**",
"**/ONTIME_VERSION.js"
]
}
+10
View File
@@ -0,0 +1,10 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+24 -47
View File
@@ -2,16 +2,16 @@
Ontime consists of 3 distinct parts
- **client**: A React app for Ontime's UI and web clients
- **electron**: An electron app which facilitates the cross-platform distribution of Ontime
- **server**: A node application which handles the domains services and integrations
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- **node** (~22)
- **pnpm** (~10)
- **docker** (only necessary to run and build docker images)
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -21,21 +21,18 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands
- **Install the project dependencies** by running `pnpm i`
- **Create a local build** by running `pnpm build`, this will populate local dependencies
- **Run dev mode** by running `pnpm dev` or `pnpm dev:electron` to get the electron window
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
### Debugging backend
The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
To debug backend code in Node.js:
We do that by creating two terminals an running
- **Run the React UI** by running `pnpm dev --filter=ontime-ui`
- **Run the nodejs server** by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING
@@ -48,7 +45,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `pnpm test:pipeline` from the project root.
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -68,7 +65,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e:ui` open playwright UI
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -78,9 +75,9 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- **Install the project dependencies** by running `pnpm i`
- **Build the UI and server** by running `pnpm build`
- **Create the package** by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
@@ -91,31 +88,11 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- **Build docker image from** by running `docker build -t getontime/ontime .`
- **Run docker image from compose** by running `docker-compose up -d`
- __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
- **List running processes** by running `docker ps`
- **Kill running process** by running `docker kill <process-id>`
## FORMATTING AND LINTING
Ontime uses [oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) for formatting and [oxlint](https://oxc.rs/docs/guide/usage/linter.html) for linting. Look for an [oxc extension](https://oxc.rs/docs/guide/usage/linter/editors) for your editor of choice.
- **Run formatter** by running `pnpm format`
- **Run linter** by running `pnpm lint`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
For new PRs, please follow the following checklist:
- [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
- [ ] You have ran code formatting and linting in all your changes
- [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
- [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
+27 -30
View File
@@ -1,30 +1,27 @@
ARG NODE_VERSION=22.22.2
FROM node:${NODE_VERSION}-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/
# Prepare UI
COPY apps/client/build/ ./client/
# Prepare Backend
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
FROM node:16-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/external/
WORKDIR /app/
# Prepare UI
COPY /apps/client/build ./client/
# Prepare Backend
COPY /apps/server/dist/ ./server/
COPY /demo-db/ ./preloaded-db/
COPY /apps/server/src/external/ ./external/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
+166 -104
View File
@@ -1,137 +1,199 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://ontime.gitbook.io)
## Download the latest release
## Download the latest releases here
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
</div>
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
Ontime is an application for creating and managing event running order and timers.
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
Ontime is made by entertainment and broadcast engineers and used by
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
- Theatres and opera houses
- Houses of worship
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
## Main features
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
This guides the application into being flexible and efficiently integrating into different workflows.
### For teams
All information added in Ontime is shared with the production team and other software / hardware in your workflow. \
Ontime also improves team collaboration with dedicated views for cuesheets and operators, and for public and production
signage.
### Simple infrastructure
All the data is distributed over the network, making its distribution and infrastructure flexible and cheap. \
With the availability of the docker image, you can also leverage IT infrastructure to make Ontime available online for
your team and clients.
Ontime is made by video engineers and entertainment technicians.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/editor.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/ontime-overview.webp)
[Read the docs to learn more](https://docs.getontime.no)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
## Using Ontime
### Getting started
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
Any device with a browser in the same network can choose one of the supported views to render the available data.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
You can then use the menu in the top left corner to select the desired view.
The menu will be initially hidden until there is mouse interaction.
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL aliases feature
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
```
For the presentation views
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/minimal > Simple timer view
IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
More information is available [in our docs](https://docs.getontime.no)
```
For management views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
```
## Continued development
More documentation is available [in our docs](https://ontime.gitbook.io)
Ontime is under active development. We continue adding and improving features in collaboration with users.
## Feature List (in no specific order)
- [x] Distribute data over network and render it in the browser
- [x] Different screen types
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- OSC (Open Sound Control)
- HTTP
- WebSockets
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We want Ontime to be unique by targeting freelancers instead of roles.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
many environments and workflows.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
work.
## Rich APIs for workflow integrations
The app is currently being developed for a broad user base, from broadcast to entertainment and
conference halls.
Taking advantage of the integrations, we currently use Ontime with:
- `disguise`: trigger Ontime from d3's timeline using the **OSC API**, and **render views** using d3's
webmodule
- `OBS`: **render views** using the Browser Module
- `QLab`: trigger Ontime using **OSC API**
- `Companion`: Ontime has a **companion module**. Issue report and feature requests should be done
in the [repository getontime/ontime](https://github.com/bitfocus/companion-module-getontime-ontime)
### Make your own viewer
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api)
### Headless run
You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
## Roadmap
### Continued development
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Issues
### Issues
We use Github's issue tracking for bug reporting and feature requests. \
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
## Contributing
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or
`Ontime can't be opened because it is from an unidentified developer`
or in Linux
`Could Not Display "ontime-linux.AppImage`
You can circumvent this by allowing the execution of the app manually.
- In Windows: click more and select "Run Anyway"
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
Long story short: Ontime app is unsigned. </br>Purchasing the certificates for both Mac and Windows
would mean a recurrent expense and is not a priority. This is unlikely to change in future. If you
have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
#### Safari
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
There is no plan for any further work on this.
# Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
# Help
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
## License
# License
This project is licensed under the terms of the GNU GPL v3
## Sponsor
You can help the development of this project or say thank you with a one time donation. \
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
<p align="center">
<br>
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
</p>
-24
View File
@@ -1,24 +0,0 @@
# Terms of sponsorship
Ontime does not exist as a fiscal entity. The sponsorship recipient is the project owner. In this light, contributors are funding the project owner's capacity to work on the project, not the project itself. As a contribution recipient, the code owner reserves the right to redistribute.
## Why sponsor
Ontime is and will remain free, regardless of your decision to contribute.
Regardless, you may choose to provide a monetary contribution:
### Improve user experience
Certain parts of the application user experience are tied to our capacity to pay for services. App signing and Google Sheets integration are good examples of this.
### Cool stuff
We may choose not to implement some features. This could be related to the complexity required, to the skill set, or to the fact that we just feel it doesn't improve the app for a significant percentage of users. In those cases, a feature may be considered "not worth it," regardless of its pertinence.
As a user, you can choose to sponsor a particular feature or donate to the app so that we can create bounties for features.
### Priorities
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates.
Additional funding will be considered according to the needs at the time.
-3
View File
@@ -1,3 +0,0 @@
server
external
client
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "vitest"]
}
-52
View File
@@ -1,52 +0,0 @@
# Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers.
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
## Getting started
### Prerequisites
You will need to have installed the correct version of node.js
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system
```bash
npm install -g @getontime/cli
```
... and run using the installed script
```bash
ontime
```
Or install and run ontime (the installation here is temporary for the duration of the session)
```bash
npx install @getontime/cli
```
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env node
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
const ontimeServer = require('./server/index.cjs');
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
async function startOntime() {
await initAssets();
await startServer();
await startIntegrations();
}
startOntime();
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
shutdown();
});
-22
View File
@@ -1,22 +0,0 @@
{
"name": "@getontime/cli",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
"keywords": [
"lighdev",
"ontime",
"timer"
],
"license": "AGPL-3.0-only",
"main": "main.js",
"bin": {
"ontime": "main.js"
},
"files": [
"client",
"external",
"server"
]
}
+2
View File
@@ -0,0 +1,2 @@
node_modules
build
+141
View File
@@ -0,0 +1,141 @@
{
"settings": {
"react": {
"version": "detect"
}
},
"env": {
"browser": true,
"node": true
},
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
"rules": {
"react/jsx-no-bind": [
"error",
{
"ignoreRefs": true,
"allowArrowFunctions": true,
"allowFunctions": false,
"allowBind": false,
"ignoreDOMComponents": true
}
],
"react/jsx-boolean-value": [
"error",
"never",
{
"always": []
}
],
"react/jsx-handler-names": [
"off",
{
"eventHandlerPrefix": "handle",
"eventHandlerPropPrefix": "on"
}
],
"react/no-danger": "warn",
"react/no-deprecated": [
"error"
],
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-undef": "error",
"react/jsx-closing-tag-location": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-unescaped-entities": "error",
"react/jsx-tag-spacing": [
"error",
{
"closingSlash": "never",
"beforeSelfClosing": "always",
"afterOpening": "never",
"beforeClosing": "never"
}
],
"react/jsx-space-before-closing": [
"off",
"always"
],
"react/jsx-curly-brace-presence": [
"error",
{
"props": "never",
"children": "never"
}
],
"react/destructuring-assignment": [
"error",
"always"
],
"react/no-unsafe": "off",
"react/prop-types": [
"error",
{
"ignore": [],
"customValidators": [],
"skipUndeclared": true
}
],
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
},
"overrides": [
// override "simple-import-sort" config
{
"files": [
"*.js",
"*.jsx",
"*.ts",
"*.tsx"
],
"rules": {
"simple-import-sort/imports": [
"error",
{
"groups": [
// Packages `react` related packages come first.
[
"^react",
"^@?\\w"
],
// Internal packages.
[
"^(@|components)(/.*|$)"
],
// Side effect imports.
[
"^\\u0000"
],
// Parent imports. Put `..` last.
[
"^\\.\\.(?!/?$)",
"^\\.\\./?$"
],
// Other relative imports. Put same-folder imports and `.` last.
[
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$"
],
// Style imports.
[
"^.+\\.?(css)$"
]
]
}
]
}
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "react", "vitest"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
+35 -28
View File
@@ -1,30 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
var b = document.querySelector('base');
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
window.location.replace(base + '/timer-legacy' + window.location.search);
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#121212' />
<meta name='ontime' content='ontime - event timer manager' />
<link rel='apple-touch-icon' href='/logo192.png' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-32x32.png'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-16x16.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html>
+63 -49
View File
@@ -1,57 +1,56 @@
{
"name": "ontime-ui",
"version": "4.11.0",
"version": "2.21.3",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.18.0",
"csv-stringify": "^6.6.0",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.3.6"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"dev:electron": "pnpm dev",
"lint": "oxlint --quiet --type-aware",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"analyse": "npx vite-bundle-visualizer"
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all",
"safari >= 14.1"
"not op_mini all"
],
"development": [
"last 1 chrome version",
@@ -60,21 +59,36 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-legacy": "^8.0.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"happy-dom": "^20.9.0",
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^3.0.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
"typescript": "^5.2.2",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+12 -5
View File
@@ -4,16 +4,23 @@
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "ontime-logo.png",
"type": "image/png"
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"scope": "./",
"start_url": "./",
"scope": "/",
"start_url": "/",
"display": "",
"theme_color": "#121212",
"background_color": "#101010"
"background_color": "#ffffff"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+1 -8
View File
@@ -1,8 +1 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+49 -22
View File
@@ -1,45 +1,72 @@
import { Tooltip } from '@base-ui/react/tooltip';
import { useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router';
// apply global axios config defaults
import './common/api/axios.config';
import AppRouter from './AppRouter';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
import { baseURI } from './externals';
import KeepAwake from './features/keep-awake/KeepAwake';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
connectSocket();
// Load Open Sans typeface
// @ts-expect-error no types from font import
import('typeface-open-sans');
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
function App() {
const { isElectron, sendToElectron } = useElectronEvent();
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the alt key is pressed
if (event.altKey) {
if (event.code === 'KeyT') {
// ask to see debug
sendToElectron('set-window', 'show-dev');
}
}
};
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [isElectron, sendToElectron]);
return (
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<BrowserRouter basename={baseURI}>
<ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<KeepAwake />
<AppRouter />
<ContextMenu>
<AppRouter />
</ContextMenu>
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
);
}
+65 -207
View File
@@ -1,115 +1,76 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
import { getRouteFromPreset } from './common/utils/urlPresets';
import { getIsNavigationLocked, sessionScope } from './externals';
import Log from './features/log/Log';
import { initializeSentry } from './sentry.config';
import Loader from './views/common/loader/Loader';
import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader';
import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper';
const Timer = lazy(() => import('./views/timer/Timer'));
const Countdown = lazy(() => import('./views/countdown/Countdown'));
const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView));
const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withAlias(withData(ClockView));
const SCountdown = withAlias(withData(Countdown));
const SBackstage = withAlias(withData(Backstage));
const SPublic = withAlias(withData(Public));
const SLowerThird = withAlias(withData(Lower));
const SStudio = withAlias(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
// Initialize Sentry with our configuration
const SentryRouter = initializeSentry();
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<Suspense fallback={<Loader />}>
<SentryRouter>
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='timer'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timer />
</ViewLoader>
}
/>
<Route
path='countdown'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Countdown />
</ViewLoader>
}
/>
<Route
path='backstage'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Backstage />
</ViewLoader>
}
/>
<Route
path='studio'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<StudioClock />
</ViewLoader>
}
/>
<Route
path='timeline'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timeline />
</ViewLoader>
}
/>
<Route
path='info'
element={
<ViewLoader>
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
<ProjectInfo />
</ViewLoader>
}
/>
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<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 />} />
<Route path='cuesheet' element={<Cuesheet />} />
<Route
path='op'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ViewLoader>
}
/>
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
path='rundown'
path='/rundown'
element={
<EditorFeatureWrapper>
<RundownPanel />
@@ -117,7 +78,7 @@ export default function AppRouter() {
}
/>
<Route
path='timercontrol'
path='/timercontrol'
element={
<EditorFeatureWrapper>
<TimerControl />
@@ -125,7 +86,7 @@ export default function AppRouter() {
}
/>
<Route
path='messagecontrol'
path='/messagecontrol'
element={
<EditorFeatureWrapper>
<MessageControl />
@@ -133,119 +94,16 @@ export default function AppRouter() {
}
/>
<Route
path='log'
path='/info'
element={
<EditorFeatureWrapper>
<Log />
<Info />
</EditorFeatureWrapper>
}
/>
{/**
* If the views are prefixed with the "preset" path, we are in a locked preset
* Locked presets do not expose their parameters
*/}
<Route path='preset/:alias' element={<PresetView />} />
{/**
* If we havent matched any views or presets, we may be in an unlocked preset
* Unlocked presets are unwrapped to expose their target and parameters
*/}
<Route path='*' element={<RedirectPreset />} />
</SentryRouter>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
</Suspense>
);
}
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.Cuesheet]: Cuesheet,
[OntimeView.Operator]: Operator,
[OntimeView.Timer]: Timer,
[OntimeView.Backstage]: Backstage,
[OntimeView.Timeline]: Timeline,
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
};
/**
* This view will mask a configured canonical route
* and inject the preset search parameters to context
* User are not able to configure the parameters locked presets
*/
function PresetView() {
const { data, status } = useUrlPresets();
const { alias } = useParams();
const preset: URLPreset | undefined = useMemo(() => {
if (status === 'pending' || !alias) return;
return data.find((p) => p.alias === alias && p.enabled);
}, [data, status, alias]);
if (status === 'pending') {
return <Loader />;
}
/**
* We need to check the session scope to determine if the user can navigate
* If the user has a global scope, they can navigate freely
* Otherwise, they are locked to the preset view
*/
const showNav = sessionScope === 'rw';
/**
* If we are in a preset path but cannot find a preset, we will need to show a not found page
* This can happen if the preset was deleted or disabled
*/
if (!preset) {
return (
<>
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
<NotFound />
</>
);
}
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
}
function RedirectPreset() {
const { data, status } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// checks if we are in a preset path and resolves a destination URL
const destination = useMemo(() => {
if (status === 'pending') return null;
return getRouteFromPreset(location, data);
}, [data, location, status]);
// if we have a destination, we will navigate to it
useEffect(() => {
if (destination) {
navigate(`/${destination}`, { replace: true });
}
}, [destination, navigate]);
if (status === 'pending') {
return <Loader />;
}
return (
<>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<NotFound />
</>
);
}
@@ -0,0 +1,27 @@
// REST stuff
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const stylesPath = 'external/styles/override.css';
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
+53
View File
@@ -0,0 +1,53 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
}
-51
View File
@@ -1,51 +0,0 @@
import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { TRANSLATION, apiEntryUrl, customTranslationsURL } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
-86
View File
@@ -1,86 +0,0 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
@@ -1,8 +0,0 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => {
return status >= 200 && status < 300;
};
axios.defaults.timeout = axiosConfig.shortTimeout;
-32
View File
@@ -1,32 +0,0 @@
import { serverURL } from '../../externals';
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_SERVER_PORT = ['appServerPort'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const CUSTOM_VIEWS = ['customViews'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const customTranslationsPath = 'translations/translations.json';
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
@@ -1,39 +0,0 @@
import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data;
}
/**
* Sets list of known custom fields
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
*/
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data;
}
-58
View File
@@ -1,58 +0,0 @@
import axios from 'axios';
import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const customViewsPath = `${apiEntryUrl}/custom-views`;
export async function getCustomViews(options?: RequestOptions): Promise<CustomViewsListResponse> {
const response = await axios.get<CustomViewsListResponse>(customViewsPath, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.shortTimeout,
});
return response.data;
}
export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise<MessageResponse> {
const formData = new FormData();
formData.append('indexHtml', file);
return (
await axios.post<MessageResponse>(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
})
).data;
}
export async function downloadCustomView(slug: string, options?: RequestOptions): Promise<void> {
const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
downloadBlob(response.data, `${slug}-index.html`);
}
export async function restoreDemoView(options?: RequestOptions): Promise<MessageResponse> {
return (
await axios.post<MessageResponse>(`${customViewsPath}/restore-demo`, null, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
})
).data;
}
export async function deleteCustomView(slug: string, options?: RequestOptions): Promise<void> {
await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, {
signal: options?.signal,
timeout: options?.timeout ?? axiosConfig.longTimeout,
});
}
-161
View File
@@ -1,161 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
}
/**
* Request download of the current project file
* @param fileName
*/
export async function downloadProject(fileName: string) {
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);
}
}
/**
* HTTP request to upload project file
*/
export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Make patch changes to the objects in the db
*/
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<AxiosResponse<DatabaseModel>> {
return await axios.patch(dbPath, patchDb);
}
/**
* HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/new`, project);
return res.data;
}
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
/**
* HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/load`, {
filename,
});
return res.data;
}
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* Utility function gets project from db
* @param fileName
* @returns
*/
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb(fileName);
const headerLine = response.headers['Content-Disposition'];
// 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 };
}
+90
View File
@@ -0,0 +1,90 @@
import axios from 'axios';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
/**
* @deprecated use fetchCachedRundown instead
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeRundownEntry) {
return axios.post(rundownURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: ReorderEntry) {
return axios.patch(`${rundownURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${rundownURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${rundownURL}/all`);
}
-81
View File
@@ -1,81 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
/**
* upload Excel file to server
* Uploads an Excel file and returns worksheet names plus metadata for the initial worksheet.
*/
export async function upload(file: File, requestOptions?: RequestOptions): Promise<SpreadsheetWorksheetOptions> {
const formData = new FormData();
formData.append('excel', file);
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
export async function importRundownPreview(
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<SpreadsheetPreviewResponse> {
const response: AxiosResponse<SpreadsheetPreviewResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
export async function getWorksheetMetadata(
worksheet: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetMetadata> {
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
`${excelPath}/metadata`,
{ worksheet },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
-20
View File
@@ -1,20 +0,0 @@
import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
version: string;
};
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
+308
View File
@@ -0,0 +1,308 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
GetInfo,
GoogleSheet,
GoogleSheetState,
HttpSettings,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: Settings) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export async function getUserFields(): Promise<UserFields> {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
}
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export async function postUserFields(data: UserFields) {
return axios.post(`${ontimeURL}/userfields`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to mutate osc subscriptions
* @return {Promise}
*/
export async function postOscSubscriptions(data: OscSubscription) {
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
}
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @description sheet Client File
* @return {Promise}
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet-clientsecrect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
export const getSheetsAuthUrl = async () => {
const res = await axios.get(`${ontimeURL}/sheet-authurl`);
return res.data;
};
export const postPreviewSheet = async () => {
const response = await axios.post(`${ontimeURL}/sheet-preview`);
return response.data.data;
};
export const postPushSheet = async () => {
const response = await axios.post(`${ontimeURL}/sheet-push`);
return response.data.data;
};
/**
* @description HTTP request to retrieve google sheets settings
* @return {Promise}
*/
export async function getSheetSettings(): Promise<GoogleSheet> {
const res = await axios.get(`${ontimeURL}/sheet-settings`);
return res.data;
}
/**
* @description HTTP request to mutate google sheets settings
* @return {Promise}
*/
export async function postSheetSettings(data: GoogleSheet): Promise<GoogleSheet> {
const res = await axios.post(`${ontimeURL}/sheet-settings`, data);
return res.data;
}
/**
* @description HTTP request to retrieve google sheets state
* @return {Promise}
*/
export async function getSheetstate(): Promise<GoogleSheetState> {
const res = await axios.get(`${ontimeURL}/sheet-state`);
return res.data;
}
-43
View File
@@ -1,43 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
/**
* HTTP request to mutate project data
*/
export async function postProjectData(data: ProjectData): Promise<ProjectData> {
const response = await axios.post(projectPath, data);
return response.data;
}
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
-26
View File
@@ -1,26 +0,0 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
@@ -1,4 +0,0 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -1,8 +0,0 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
-220
View File
@@ -1,220 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import {
EntryId,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
}
/**
* HTTP request to fetch all entries in the given rundown
*/
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
}
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`);
}
/**
* HTTP request to create a new rundown
*/
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(rundownPath, { title });
}
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/**
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: RundownId,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
}
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
export type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: EntryId[];
};
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
/**
* HTTP request to renumber cues for multiple events
*/
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
}
export type ReorderEntry = {
entryId: EntryId;
destinationId: EntryId;
order: 'before' | 'after' | 'insert';
};
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(
rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request for grouping a list of entries into a group
*/
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
}
/**
* HTTP request for dissolving of a group
*/
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
}
/**
* HTTP request to delete entries of a given rundown
*/
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
}
/**
* HTTP request to delete all entries of a given rundown
*/
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
}
// #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
-23
View File
@@ -1,23 +0,0 @@
import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, options);
return res.data.url;
}
-44
View File
@@ -1,44 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { PortInfo, Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to mutate application settings
*/
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(options?: RequestOptions): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
-133
View File
@@ -1,133 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import type {
AuthenticationStatus,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{
authenticated: AuthenticationStatus;
sheetId: string;
}> => {
const response = await axios.get(`${sheetsPath}/connect`);
return response.data;
};
/**
* HTTP request to initiate the authentication service with google
*/
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
/**
* HTTP request to revoke authentication to google sheet
*/
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${sheetsPath}/revoke`);
return response.data;
};
/**
* HTTP request to upload preview the contents of a google sheet as rundown
*/
export const previewRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<SpreadsheetPreviewResponse> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* Fetches derived metadata for a single worksheet by reading its rows and detecting headers.
*/
export const getWorksheetMetadata = async (
sheetId: string,
worksheet: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetMetadata> => {
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
`${sheetsPath}/${sheetId}/metadata`,
{ worksheet },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* Fetches the available worksheets for a Google Sheet. Metadata is loaded lazily per worksheet.
*/
export const getWorksheetOptions = async (
sheetId: string,
requestOptions?: RequestOptions,
): Promise<SpreadsheetWorksheetOptions> => {
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(
`${sheetsPath}/${sheetId}/worksheet-options`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
-36
View File
@@ -1,36 +0,0 @@
import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to add a preset
*/
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
return (await axios.post(urlPresetsPath, data)).data;
}
/**
* HTTP request to edit a preset
*/
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
}
/**
* HTTP request to delete a preset
*/
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
}
-99
View File
@@ -1,99 +0,0 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
/**
* Utility unwrap a potential axios error
* @param error
* @returns
*/
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwrap a an instance of Error
*/
export function unwrapError(error: unknown) {
if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwraps a potential axios error and sends to logger
* @param prepend
* @param error
*/
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
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 });
}
/**
* downloads a blob
* @param downloadUrl
* @param fileName
*/
export function downloadBlob(blob: Blob, fileName: string) {
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);
}
@@ -1,25 +0,0 @@
import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to update view settings
* needs to update entire objects, not just a patch
*/
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> {
const res = await axios.post(viewSettingsPath, data);
return res.data;
}
@@ -1,61 +0,0 @@
.positioner {
outline: 0;
}
.popup {
font-size: 1rem;
box-sizing: border-box;
padding: 2px;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
color: $ui-white;
&[data-empty][data-hide-empty] {
display: none;
}
}
.list {
box-sizing: border-box;
max-height: 20rem;
max-height: var(--available-height);
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
scroll-padding-block: 1.5rem;
outline: 0;
}
.item,
.empty:not(:empty) {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
line-height: 1rem;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: break-word;
&[data-highlighted] {
background-color: $blue-700;
}
}
.itemLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.empty {
color: $gray-400;
}
@@ -1,121 +0,0 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ReactNode, Ref } from 'react';
import { useEffect, useRef, useState } from 'react';
import { cx } from '../../utils/styleUtils';
import type { InputProps } from '../input/input/Input';
import { getScrollParent } from './autocompleteInput.utils';
import inputStyles from '../input/input/Input.module.scss';
import styles from './AutocompleteInput.module.scss';
export interface AutocompleteInputProps extends Omit<InputProps, 'value' | 'defaultValue' | 'onChange'> {
options: string[];
value: string;
onValueChange: (value: string) => void;
emptyLabel?: string;
trailingElement?: (option: string) => ReactNode;
inputRef?: Ref<HTMLInputElement>;
openOnFocus?: boolean;
}
export default function AutocompleteInput({
className,
disabled,
emptyLabel,
fluid,
height = 'medium',
inputRef,
onValueChange,
options,
openOnFocus = false,
trailingElement,
value,
variant = 'subtle',
...inputProps
}: AutocompleteInputProps) {
const internalInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const { onFocus, ...restInputProps } = inputProps;
const handleInputRef = (node: HTMLInputElement | null) => {
internalInputRef.current = node;
if (!inputRef) {
return;
}
if (typeof inputRef === 'function') {
inputRef(node);
return;
}
inputRef.current = node;
};
// close the popover when the parent scrollable container scrolls or the window resizes
useEffect(() => {
if (!open) {
return;
}
const scrollTarget = getScrollParent(internalInputRef.current);
const handleScroll = () => setOpen(false);
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
scrollTarget.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, [open]);
return (
<BaseAutocomplete.Root
items={options}
autoHighlight
highlightItemOnHover
disabled={disabled}
open={open}
value={value}
onOpenChange={setOpen}
onValueChange={onValueChange}
>
<BaseAutocomplete.Input
ref={handleInputRef}
onFocus={(event) => {
onFocus?.(event);
if (openOnFocus) {
setOpen(true);
}
}}
className={cx([
inputStyles.input,
inputStyles[variant],
inputStyles[height],
fluid && inputStyles.fluid,
className,
])}
{...restInputProps}
/>
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={styles.positioner}>
<BaseAutocomplete.Popup className={styles.popup} data-hide-empty={emptyLabel ? undefined : ''}>
<BaseAutocomplete.List className={styles.list}>
<BaseAutocomplete.Collection>
{(option: string) => (
<BaseAutocomplete.Item key={option} value={option} className={styles.item}>
<span className={styles.itemLabel}>{option}</span>
{trailingElement?.(option)}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
{emptyLabel && <BaseAutocomplete.Empty className={styles.empty}>{emptyLabel}</BaseAutocomplete.Empty>}
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
</BaseAutocomplete.Root>
);
}
@@ -1,14 +0,0 @@
export function getScrollParent(node: HTMLElement | null): HTMLElement | Window {
let parent = node?.parentElement ?? null;
while (parent) {
const { overflowY } = window.getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent;
}
parent = parent.parentElement;
}
return window;
}
@@ -1,84 +0,0 @@
@use './buttonVariants' as base;
@include base.button-variants;
.baseButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
width: fit-content;
padding-inline: 1em;
white-space: nowrap;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&.loading {
cursor: wait;
.content {
opacity: 0;
}
}
}
.content {
display: flex;
align-items: center;
gap: inherit;
}
.loadingOverlay {
position: absolute;
display: grid;
place-content: center;
}
.spinner {
animation: spin 1s linear infinite;
stroke-dasharray: 8;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.xlarge {
height: 3rem;
font-weight: 600;
}
.fluid {
width: 100%;
}
@@ -1,53 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { IoEllipseOutline } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean;
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([
style.baseButton,
style[variant],
style[size],
fluid && style.fluid,
loading && style.loading,
className,
])}
type='button'
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
@@ -1,45 +0,0 @@
@use './buttonVariants' as base;
@include base.button-variants;
.baseIconButton {
aspect-ratio: 1;
display: grid;
place-content: center;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
width: 2rem;
}
.large {
height: 2.5rem;
width: 2.5rem;
}
.xlarge {
height: 3rem;
height: 3rem;
font-size: 1.5rem;
}
@@ -1,37 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
},
);
IconButton.displayName = 'IconButton';
export default IconButton;
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { FiPower } from '@react-icons/all-files/fi/FiPower';
import { Size } from '../../models/Util.type';
import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
const quitBtnStyle = {
color: '#D20300', // $red-700
borderColor: '#D20300', // $red-700
_focus: { boxShadow: 'none' },
_hover: {
background: '#D20300', // $red-700
color: 'white',
_disabled: {
color: '#D20300', // $red-700
background: 'none',
},
},
_active: {
background: '#9A0000', // $red-1000
color: 'white',
},
variant: 'outline',
isRound: true,
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
const cancelRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (window.process?.type === 'renderer') {
window.ipcRenderer.on('user-request-shutdown', () => {
emitInfo('Shutdown request');
setIsOpen(true);
});
}
}, [emitInfo]);
const handleShutdown = useCallback(() => {
onClose();
clickHandler();
}, [clickHandler]);
return (
<>
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size={size}
icon={<FiPower />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
{...rest}
/>
</Tooltip>
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
</Button>
<Button colorScheme='red' onClick={handleShutdown} ml={3}>
Shutdown
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
}
@@ -0,0 +1,17 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
</Tooltip>
);
}
@@ -1,144 +0,0 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,50 +0,0 @@
.checkbox {
box-sizing: border-box;
display: flex;
width: 1rem;
height: 1rem;
align-items: center;
justify-content: center;
border-radius: $component-border-radius-sm;
&[data-unchecked] {
background-color: $gray-1100;
&:hover:not(:disabled) {
background: $gray-1000;
}
&:active:not(:disabled) {
background: $gray-1200;
}
}
&[data-checked] {
background-color: $blue-500;
&:hover:not(:disabled) {
background: $blue-400;
}
&:active:not(:disabled) {
background: $blue-600;
}
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: flex;
color: $ui-white;
&[data-unchecked] {
display: none;
}
}
@@ -1,14 +0,0 @@
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import { IoCheckmark } from 'react-icons/io5';
import style from './Checkbox.module.scss';
export default function Checkbox(checkboxProps: BaseCheckbox.Root.Props) {
return (
<BaseCheckbox.Root className={style.checkbox} {...checkboxProps}>
<BaseCheckbox.Indicator className={style.indicator}>
<IoCheckmark className={style.icon} />
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
);
}

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