Compare commits

..

17 Commits

Author SHA1 Message Date
arc-alex 6397d33eaa feat: option to select all events for countdown 2025-09-16 16:59:29 +02:00
arc-alex 9288566a7f chore: remove unused code 2025-09-16 16:38:11 +02:00
Alex Christoffer Rasmussen 7fd9f83fb0 fix docker ignore (#1780) 2025-09-16 16:27:00 +02:00
Carlos Valente e621f1386e fix(cuesheet): infinite render loop on resizing columns 2025-09-16 06:43:19 +02:00
Carlos Valente b51e7cbd2d fix: maintain multiline in cuesheet cells 2025-09-14 14:19:18 +02:00
Carlos Valente 7cd92ce5f7 docs: add contribution guidelines 2025-09-14 14:19:18 +02:00
Carlos Valente f6a02abf36 fix: skip nested events 2025-09-14 14:19:18 +02:00
Carlos Valente e4b0df42cf feat: allow finding milestones 2025-09-14 14:19:18 +02:00
Carlos Valente ded8bddb2d refactor: improve automated following 2025-09-14 14:19:18 +02:00
Carlos Valente 8d3ce46c56 fix: allow moving a group after another 2025-09-14 14:19:18 +02:00
Carlos Valente 578cb2b244 refactor: improve pin styling 2025-09-14 14:19:18 +02:00
Carlos Valente c79ee193c9 fix: prevent layout reflow on different entry types 2025-09-14 14:19:18 +02:00
Carlos Valente 5810ae0d54 refactor: add default value to secondary source 2025-09-14 14:19:18 +02:00
Carlos Valente bfbf8574e3 fix: phase style overrides in timer 2025-09-14 14:19:18 +02:00
Alex Christoffer Rasmussen 490e429f44 cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-13 16:31:56 +02:00
Alex Christoffer Rasmussen 2e950965e0 fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-08 09:49:01 +02:00
arc-alex cc34db775a chore: bump version 2025-09-08 06:51:07 +02:00
906 changed files with 17943 additions and 40617 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
+3 -2
View File
@@ -1,5 +1,5 @@
# Ignore formatter config files # Ignore config files
.oxfmt* .prettier*
# Ignore install stuff # Ignore install stuff
**/yarn.lock **/yarn.lock
@@ -17,6 +17,7 @@
# Ignore build folders # Ignore build folders
node_modules node_modules
**/node_modules **/node_modules
**/dist
# Ignore default volumes created by running docker compose up # Ignore default volumes created by running docker compose up
ontime-db ontime-db
+1
View File
@@ -0,0 +1 @@
"ONTIME_VERSION.js"
+53
View File
@@ -0,0 +1,53 @@
{
"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-throw-literal": "error",
"no-console": [
"warn",
{
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prettier/prettier": [
"warn",
{
"endOfLine": "auto"
}
]
}
}
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.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 336 KiB

+115
View File
@@ -0,0 +1,115 @@
name: Ontime build
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
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: 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@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- 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@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- 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 }}
+11 -19
View File
@@ -12,26 +12,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version - name: Set up QEMU
- uses: actions/setup-node@v6 uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v3
- 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: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -50,12 +45,9 @@ jobs:
- name: Copy external - name: Copy external
run: cp -R apps/server/src/external apps/cli/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 - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli working-directory: ./apps/cli
+29 -48
View File
@@ -6,62 +6,43 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish_docker: publish_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CI: '' CI: ''
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup pnpm - name: Docker Login
uses: pnpm/action-setup@v6 uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get pnpm store directory - name: Docker Setup Buildx
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV uses: docker/setup-buildx-action@v2.5.0
- name: Setup pnpm cache - name: Build and push stable release
uses: actions/cache@v5 if: github.event.release.prerelease == false
with: uses: docker/build-push-action@v4.0.0
path: ${{ env.PNPM_STORE_PATH }} with:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} context: .
restore-keys: | file: ./Dockerfile
${{ runner.os }}-pnpm-store- platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Install dependencies - name: Build and push pre-release
run: pnpm install --frozen-lockfile if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- 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 }}
-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
+48 -54
View File
@@ -13,99 +13,93 @@ jobs:
CI: '' CI: ''
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Use Node.js
- uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v3
- 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: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Run code quality # Run code quality per package
- name: Run formatter - name: React - Run linter + TypeScript checks
run: pnpm format:check if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Run linter - 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 run: pnpm lint
working-directory: ./packages/types
- name: Run TypeScript checks
run: pnpm typecheck
# We choose to run tests separately # We choose to run tests separately
- name: Run unit tests - name: React - Run unit tests
if: always() if: always()
run: pnpm test 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: e2e-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Use Node.js
- uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v3
- 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: with:
path: ${{ env.PNPM_STORE_PATH }} version: 10
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Get installed Playwright version - name: Build client
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV run: pnpm build:local
- 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 - name: Install Playwright Browsers
run: pnpm playwright install --with-deps run: npx playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm e2e run: pnpm e2e
- uses: actions/upload-artifact@v6 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@v6 - uses: actions/upload-artifact@v4
with: with:
name: automated-screenshots name: automated-screenshots
path: automated-screenshots/ path: automated-screenshots/
-6
View File
@@ -33,16 +33,13 @@ e2e/tests/fixtures/tmp/*
# production # production
build/ build/
dist/ dist/
*.tsbuildinfo
# bundled assets # bundled assets
translations.json translations.json
override.css override.css
**/external/demo/index.html
# working stuff # working stuff
**/TODO.md **/TODO.md
**.local.**
# docker utils # docker utils
ontime-db ontime-db
@@ -54,6 +51,3 @@ ontime-data/
# temporary write files # temporary write files
**.tmp **.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.22.3 v22.15.1
-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
}
+27 -34
View File
@@ -2,16 +2,16 @@
Ontime consists of 3 distinct parts Ontime consists of 3 distinct parts
- **client**: A React app for Ontime's UI and web clients - __client__: A React app for Ontime's UI and web clients
- **electron**: An electron app which facilitates the cross-platform distribution of Ontime - __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- **server**: A node application which handles the domains services and integrations - __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- **node** (~22) - __node__ (~22)
- **pnpm** (~10) - __pnpm__ (~10)
- **docker** (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
@@ -20,10 +20,9 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `pnpm turbo dev`
- **Install the project dependencies** by running `pnpm i`
- **Create a local build** by running `pnpm build`, this will populate local dependencies
- **Run dev mode** by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend ### Debugging backend
@@ -31,11 +30,10 @@ The previous command will start the development servers for both the client, ser
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging. Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
We do that by creating two terminals an running We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- **Run the React UI** by running `pnpm dev --filter=ontime-ui` - If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
- **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`.
## TESTING ## TESTING
@@ -48,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils) 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 `pnpm turbo test:pipeline` from the project root.
This will run all tests and close test runner. 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 Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -68,7 +66,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands 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 - `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -78,12 +76,15 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands From the project root, run the following commands
- **Install the project dependencies** by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- **Build the UI and server** by running `pnpm build` - __Build the UI and server__ by running `pnpm turbo run build:electron`
- **Create the package** by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux` - __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
The build distribution assets will be at `.apps/electron/dist` The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER ## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images. Ontime provides a docker-compose file to aid with building and running docker images.
@@ -91,31 +92,23 @@ 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 From the project root, run the following commands
- **Build docker image from** by running `docker build -t getontime/ontime .` - __Build docker image from__ by running `docker build -t getontime/ontime .`
- **Run docker image from compose** by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
- **List running processes** by running `docker ps` - __List running processes__ by running `docker ps`
- **Kill running process** by running `docker kill <process-id>` - __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 ## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request. 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: 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 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
- [ ] 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 branch is clean and the commits are meaningfully separated and contain descriptive messages * [ ] The PR body contains description and motivation for the changes
- [ ] 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. \ 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 We will review as soon as possible
+16 -9
View File
@@ -1,5 +1,13 @@
ARG NODE_VERSION=22.22.2 FROM node:22-bullseye AS builder
FROM node:${NODE_VERSION}-alpine ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
@@ -7,17 +15,16 @@ ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/data/ ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/ WORKDIR /app/
# Prepare UI # Prepare UI
COPY apps/client/build/ ./client/ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/ COPY --from=builder /app/apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/ COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -25,6 +32,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"] CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# pnpm build:docker # !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime # 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 # docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
+6 -12
View File
@@ -7,31 +7,27 @@
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/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)
## Download the latest release ## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a> <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-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-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</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.AppImage">Linux</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 ... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - 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://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a> - Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## 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. 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? ## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime # Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing. Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
@@ -47,7 +43,6 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship - Houses of worship
## Main features ## Main features
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker. - **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... - **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. - **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
@@ -116,7 +111,6 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no) - [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no) - [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp) - [Video tutorials](https://www.youtube.com/@ontimeapp)
-5
View File
@@ -1,24 +1,19 @@
# Terms of sponsorship # 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. 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 ## Why sponsor
Ontime is and will remain free, regardless of your decision to contribute. Ontime is and will remain free, regardless of your decision to contribute.
Regardless, you may choose to provide a monetary contribution: Regardless, you may choose to provide a monetary contribution:
### Improve user experience ### 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. 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 ### 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. 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. 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 ### Priorities
Monetary contributions are most immediately needed to offset the costs of presenting and distributing Ontime, i.e., hosting, domains, and certificates. 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. Additional funding will be considered according to the needs at the time.
+16
View File
@@ -0,0 +1,16 @@
{
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"plugins": [],
"rules": {
"@typescript-eslint/no-var-requires": "off"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "vitest"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}
-8
View File
@@ -1,7 +1,6 @@
# Ontime CLI # Ontime CLI
![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png) ![Ontime](https://github.com/cpvalente/ontime/blob/9e63261b350e5b7b61c7ffb17c9e75a6c5a8c711/.github/aux-images/editor.png)
> Ontime is an application for managing rundown and event timers. > 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. Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
@@ -11,40 +10,33 @@ The CLI tool is a fast and lightweight way of installing Ontime and is perfect f
## Getting started ## Getting started
### Prerequisites ### Prerequisites
You will need to have installed the correct version of node.js 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. Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
### Running Ontime ### Running Ontime
To get Ontime running, all you need to do is install it in your system using your package manager of choice: To get Ontime running, all you need to do is install it in your system using your package manager of choice:
Install globally in your system Install globally in your system
```bash ```bash
npm install -g @getontime/cli npm install -g @getontime/cli
``` ```
... and run using the installed script ... and run using the installed script
```bash ```bash
ontime ontime
``` ```
Or install and run ontime (the installation here is temporary for the duration of the session) Or install and run ontime (the installation here is temporary for the duration of the session)
```bash ```bash
npx install @getontime/cli npx install @getontime/cli
``` ```
## Links ## Links
- [Ontime's repository](https://github.com/cpvalente/ontime) - [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/) - [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/) - [Ontime's website](https://getontime.no/)
## Sponsoring ## Sponsoring
You can help the development of this project or say thank you with a one time donation. \ 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) See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
+25 -20
View File
@@ -1,22 +1,27 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.12.0", "version": "4.0.0-beta.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"keywords": [ "keywords": [
"lighdev", "lighdev",
"ontime", "ontime",
"timer" "timer"
], ],
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "main.js", "main": "main.js",
"bin": { "bin": {
"ontime": "main.js" "ontime": "main.js"
}, },
"files": [ "files": [
"client", "client",
"external", "external",
"server" "server"
] ],
"devDependencies": {
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:"
}
} }
+2
View File
@@ -0,0 +1,2 @@
node_modules
build
+142
View File
@@ -0,0 +1,142 @@
{
"settings": {
"react": {
"version": "detect"
}
},
"env": {
"browser": true,
"node": true
},
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"plugins": [
"react",
"react-compiler",
"simple-import-sort",
"@tanstack/query"
],
"rules": {
"react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [
"error",
{
"ignoreRefs": true,
"allowArrowFunctions": true,
"allowFunctions": false,
"allowBind": false,
"ignoreDOMComponents": true
}
],
"react/jsx-boolean-value": [
"error",
"never",
{
"always": []
}
],
"react/jsx-handler-names": [
"off",
{
"eventHandlerPrefix": "handle",
"eventHandlerPropPrefix": "on"
}
],
"react/no-danger": "warn",
"react/no-deprecated": [
"error"
],
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-undef": "error",
"react/jsx-closing-tag-location": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-unescaped-entities": "error",
"react/jsx-tag-spacing": [
"error",
{
"closingSlash": "never",
"beforeSelfClosing": "always",
"afterOpening": "never",
"beforeClosing": "never"
}
],
"react/jsx-space-before-closing": [
"off",
"always"
],
"react/jsx-curly-brace-presence": [
"error",
{
"props": "never",
"children": "never"
}
],
"react/destructuring-assignment": [
"error",
"always"
],
"react/no-unsafe": "off",
"react/prop-types": [
"error",
{
"ignore": [],
"customValidators": [],
"skipUndeclared": true
}
],
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
},
"overrides": [
// override "simple-import-sort" config
{
"files": [
"*.js",
"*.jsx",
"*.ts",
"*.tsx"
],
"rules": {
"simple-import-sort/imports": [
"error",
{
"groups": [
// Packages `react` related packages come first.
[
"^react",
"^@?\\w"
],
// Internal packages.
[
"^(@|components)(/.*|$)"
],
// Side effect imports.
[
"^\\u0000"
],
// Parent imports. Put `..` last.
[
"^\\.\\.(?!/?$)",
"^\\.\\./?$"
],
// Other relative imports. Put same-folder imports and `.` last.
[
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$"
],
// Style imports.
[
"^.+\\.?(css)$"
]
]
}
]
}
}
]
}
-5
View File
@@ -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
}
+12 -14
View File
@@ -3,28 +3,26 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<base href="/" /> <base href="/" />
<link rel="icon" href="favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="site.webmanifest" /> <link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>ontime</title> <title>ontime</title>
<style>
body,
html {
background-color: #101010 !important;
}
</style>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <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> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+47 -36
View File
@@ -1,57 +1,58 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.12.0", "version": "4.0.0-beta.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui-components/react": "1.0.0-beta.3",
"@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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.2.8",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.11.0",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"qrcode": "^1.5.4", "prismjs": "^1.30.0",
"react": "^19.2.7", "react": "^19.1.1",
"react-colorful": "^5.7.0", "react-colorful": "^5.6.1",
"react-dom": "^19.2.7", "react-dom": "^19.1.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.62.0",
"react-icons": "5.6.0", "react-icons": "5.5.0",
"react-router": "^8.0.1", "react-qr-code": "^2.0.18",
"react-virtuoso": "^4.18.7", "react-router": "^7.8.2",
"zustand": "^5.0.14" "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.8"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion", "postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite", "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": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer" "analyse": "npx vite-bundle-visualizer"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",
"not dead", "not dead",
"not op_mini all", "not op_mini all"
"safari >= 14.1"
], ],
"development": [ "development": [
"last 1 chrome version", "last 1 chrome version",
@@ -60,21 +61,31 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.1.1", "@sentry/vite-plugin": "^2.16.1",
"@types/qrcode": "^1.5.6", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-legacy": "^8.0.1", "@typescript-eslint/eslint-plugin": "catalog:",
"@vitejs/plugin-react": "5.2.0", "@typescript-eslint/parser": "catalog:",
"babel-plugin-react-compiler": "1.0.0", "@vitejs/plugin-react": "4.5.1",
"happy-dom": "^20.9.0", "eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.0.1", "vite": "6.3.1",
"vite-plugin-compression2": "2.5.1", "vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+3 -3
View File
@@ -11,9 +11,9 @@
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "/",
"start_url": "./", "start_url": "/",
"display": "", "display": "",
"theme_color": "#121212", "theme_color": "#121212",
"background_color": "#101010" "background_color": "#ffffff"
} }
+4 -2
View File
@@ -1,8 +1,10 @@
{ {
"name": "", "name": "",
"short_name": "", "short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }], "icons": [
"theme_color": "#121212", { "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010", "background_color": "#101010",
"display": "standalone" "display": "standalone"
} }
+4 -8
View File
@@ -1,19 +1,16 @@
import { Tooltip } from '@base-ui/react/tooltip'; import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router';
// apply global axios config defaults
import './common/api/axios.config';
import AppRouter from './AppRouter';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay'; import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket'; import { connectSocket } from './common/utils/socket';
import { baseURI } from './externals';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
@@ -27,7 +24,6 @@ function App() {
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
<IdentifyOverlay /> <IdentifyOverlay />
<KeepAwake />
<AppRouter /> <AppRouter />
</TranslationProvider> </TranslationProvider>
</ErrorBoundary> </ErrorBoundary>
+8 -9
View File
@@ -1,18 +1,18 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext'; import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from './common/utils/urlPresets'; import { getRouteFromPreset } from './common/utils/urlPresets';
import { getIsNavigationLocked, sessionScope } from './externals';
import Log from './features/log/Log'; import Log from './features/log/Log';
import { initializeSentry } from './sentry.config';
import Loader from './views/common/loader/Loader'; import Loader from './views/common/loader/Loader';
import NotFound from './views/common/not-found/NotFound'; import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader'; import ViewLoader from './views/ViewLoader';
import { getIsNavigationLocked, sessionScope } from './externals';
import { initializeSentry } from './sentry.config';
const Timer = lazy(() => import('./views/timer/Timer')); const Timer = lazy(() => import('./views/timer/Timer'));
const Countdown = lazy(() => import('./views/countdown/Countdown')); const Countdown = lazy(() => import('./views/countdown/Countdown'));
@@ -102,7 +102,6 @@ export default function AppRouter() {
path='op' path='op'
element={ element={
<ViewLoader> <ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ViewLoader> </ViewLoader>
} }
@@ -208,13 +207,13 @@ function PresetView() {
/** /**
* Locked presets do not allow configuration changes * Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param * 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]; const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return ( return (
<PresetContext value={preset}> <PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings /> {preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />} {Component ? <Component /> : <NotFound />}
</PresetContext> </PresetContext>
); );
+6 -6
View File
@@ -2,16 +2,16 @@ import axios from 'axios';
import { TranslationObject } from 'ontime-types'; import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { TRANSLATION, apiEntryUrl, customTranslationsURL } from './constants';
import type { RequestOptions } from './requestOptions'; import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
const assetsPath = `${apiEntryUrl}/assets`; const assetsPath = `${apiEntryUrl}/assets`;
/** /**
* HTTP request to get css contents * HTTP request to get css contents
*/ */
export async function getCSSContents(options?: RequestOptions): Promise<string> { export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal }); const res = await axios.get(`${assetsPath}/css`);
return res.data; return res.data;
} }
@@ -35,8 +35,8 @@ export async function restoreCSSContents(): Promise<string> {
/** /**
* HTTP request to get user translation * HTTP request to get user translation
*/ */
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> { export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal }); const res = await axios.get(customTranslationsURL);
return res.data; return res.data;
} }
+2 -3
View File
@@ -9,15 +9,14 @@ import type {
} from 'ontime-types'; } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`; const automationsPath = `${apiEntryUrl}/automations`;
/** /**
* HTTP request to get the automations settings * HTTP request to get the automations settings
*/ */
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> { export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal }); const res = await axios.get(automationsPath);
return res.data; return res.data;
} }
+1 -4
View File
@@ -1,8 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => { axios.defaults.validateStatus = (status) => {
return status >= 200 && status < 300; return (status >= 200 && status < 300) || status === 304;
}; };
axios.defaults.timeout = axiosConfig.shortTimeout;
+2 -5
View File
@@ -3,21 +3,16 @@ import { serverURL } from '../../externals';
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_SERVER_PORT = ['appServerPort'];
export const APP_VERSION = ['appVersion']; export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation']; export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const CUSTOM_VIEWS = ['customViews'];
export const PROJECT_DATA = ['project']; export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns']; export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown']; 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 RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
@@ -26,7 +21,9 @@ export const TRANSLATION = ['translation'];
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json'; const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`; export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields * Requests list of known custom fields
*/ */
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> { export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal }); const res = await axios.get(customFieldsPath);
return res.data; 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,
});
}
+27 -9
View File
@@ -1,9 +1,10 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`; const dbPath = `${apiEntryUrl}/db`;
@@ -12,7 +13,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout }); return axios.post(`${dbPath}/download`, { filename });
} }
/** /**
@@ -32,6 +33,27 @@ export async function downloadProject(fileName: string) {
} }
} }
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/** /**
* HTTP request to upload project file * HTTP request to upload project file
*/ */
@@ -39,7 +61,6 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('project', file); formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, { const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -79,11 +100,8 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> { export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, { const res = await axios.get(`${dbPath}/all`);
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
+20 -60
View File
@@ -1,81 +1,41 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import type { import { CustomFields, Rundown } from 'ontime-types';
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
/** /**
* upload Excel file to server * upload Excel file to server
* Uploads an Excel file and returns worksheet names plus metadata for the initial worksheet. * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File, requestOptions?: RequestOptions): Promise<SpreadsheetWorksheetOptions> { export async function upload(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(`${excelPath}/upload`, formData, { await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', '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 * Get Worksheet names
* @return string[] - array of available worksheets
*/ */
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) { export async function getWorksheetNames(): Promise<string[]> {
try { const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
const response = await axios.get(`${excelPath}/${rundownId}/export`, { return response.data;
signal: requestOptions?.signal, }
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob', type PreviewSpreadsheetResponse = {
}); rundown: Rundown;
customFields: CustomFields;
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`); };
} catch (error) { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
console.error('Error downloading file:', error); const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
} options,
});
return response.data;
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { apiRepoLatest } from '../../externals'; import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = { export type HasUpdate = {
url: string; url: string;
@@ -11,8 +10,8 @@ export type HasUpdate = {
/** /**
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> { export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal }); const res = await axios.get(apiRepoLatest);
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+4 -11
View File
@@ -2,28 +2,22 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`; const projectPath = `${apiEntryUrl}/project`;
/** /**
* HTTP request to fetch project data * HTTP request to fetch project data
*/ */
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> { export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath, { const res = await axios.get(projectPath);
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
/** /**
* HTTP request to mutate project data * HTTP request to mutate project data
*/ */
export async function postProjectData(data: ProjectData): Promise<ProjectData> { export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
const response = await axios.post(projectPath, data); return axios.post(projectPath, data);
return response.data;
} }
/** /**
@@ -33,7 +27,6 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, { const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
+4 -4
View File
@@ -2,16 +2,16 @@ import axios from 'axios';
import { OntimeReport } from 'ontime-types'; import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions'; import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> { export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal }); const res = await axios.get(reportUrl);
return res.data; return res.data;
} }
@@ -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;
+21 -107
View File
@@ -1,19 +1,8 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
EntryId,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns ========================= // #region operations on project rundowns =========================
@@ -21,38 +10,24 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/** /**
* HTTP request to fetch a list of existing rundowns * HTTP request to fetch a list of existing rundowns
*/ */
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> { export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal }); const res = await axios.get(rundownPath);
return res.data; return res.data;
} }
/** /**
* HTTP request to fetch all entries in the currently loaded rundown * HTTP request to fetch all entries in the currently loaded rundown
*/ */
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal }); const res = await axios.get(`${rundownPath}/current`);
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; return res.data;
} }
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`); return axios.post(`${rundownPath}/${id}/load`);
} }
/** /**
@@ -62,34 +37,11 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title }); 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 * HTTP request to delete a rundown
*/ */
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${id}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -99,7 +51,7 @@ export async function importRundownWithOptions(
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: RundownId, rundownId: string,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -108,10 +60,7 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry( export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -123,17 +72,10 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); 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 = { export type ReorderEntry = {
entryId: EntryId; entryId: EntryId;
destinationId: EntryId; destinationId: EntryId;
@@ -143,85 +85,57 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap( export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry( export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
rundownId: RundownId, return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for dissolving of a group * HTTP request for dissolving of a group
*/ */
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries of a given rundown * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all entries of a given rundown * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
// #endregion operations on rundown entries ======================= // #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types'; import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`; const sessionPath = `${apiEntryUrl}/session`;
/** /**
* HTTP request to retrieve application info * HTTP request to retrieve application info
*/ */
export async function getInfo(options?: RequestOptions): Promise<GetInfo> { export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal }); const res = await axios.get(`${sessionPath}/info`);
return res.data; return res.data;
} }
+3 -19
View File
@@ -1,16 +1,15 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { PortInfo, Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`; const settingsPath = `${apiEntryUrl}/settings`;
/** /**
* HTTP request to retrieve application settings * HTTP request to retrieve application settings
*/ */
export async function getSettings(options?: RequestOptions): Promise<Settings> { export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal }); const res = await axios.get(settingsPath);
return res.data; return res.data;
} }
@@ -27,18 +26,3 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) { export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show }); 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 });
}
+10 -68
View File
@@ -1,15 +1,8 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import type { import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
AuthenticationStatus,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`; const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -30,7 +23,6 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async ( export const requestConnection = async (
file: File, file: File,
sheetId: string, sheetId: string,
requestOptions?: RequestOptions,
): Promise<{ ): Promise<{
verification_url: string; verification_url: string;
user_code: string; user_code: string;
@@ -39,8 +31,6 @@ export const requestConnection = async (
formData.append('client_secret', file); formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, { const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -63,71 +53,23 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async ( export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
requestOptions?: RequestOptions, ): Promise<{
): Promise<SpreadsheetPreviewResponse> => { rundown: Rundown;
const response = await axios.post( customFields: CustomFields;
`${sheetsPath}/${sheetId}/read`, }> => {
{ options }, const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
/** export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
* Fetches derived metadata for a single worksheet by reading its rows and detecting headers. const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
*/
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; return response.data;
}; };
/** /**
* HTTP request to upload the rundown to a google sheet * HTTP request to upload the rundown to a google sheet
*/ */
export const uploadRundown = async ( export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
sheetId: string, const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
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; return response.data;
}; };
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`; const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/** /**
* HTTP request to retrieve all presets * HTTP request to retrieve all presets
*/ */
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> { export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal }); const res = await axios.get(urlPresetsPath);
return res.data; return res.data;
} }
+2 -3
View File
@@ -2,7 +2,6 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types'; import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`; const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -10,8 +9,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings * HTTP request to fetch view settings
* @returns * @returns
*/ */
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> { export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal }); const res = await axios.get(viewSettingsPath);
return res.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;
}
@@ -0,0 +1,140 @@
.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,6 +1,5 @@
@use './buttonVariants' as base; @use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseButton { .baseButton {
position: relative; position: relative;
@@ -82,3 +81,4 @@
.fluid { .fluid {
width: 100%; width: 100%;
} }
@@ -1,6 +1,5 @@
@use './buttonVariants' as base; @use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseIconButton { .baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
@@ -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,5 +1,5 @@
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import style from './Checkbox.module.scss'; import style from './Checkbox.module.scss';
@@ -1,82 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-areas:
align-items: center; 'label label'
gap: 0.75rem; 'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
margin-left: auto; grid-area: btn;
} }
.label { .label {
color: $gray-200; color: $label-gray;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
} }
@@ -1,15 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import ExternalLink from '../link/external-link/ExternalLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -59,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
]; ];
return ( return (
<Dialog <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
showCloseButton showCloseButton
@@ -68,54 +66,39 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
<Info.Body> Remotely redirect the client to a different URL. <br />
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset. Either by entering a custom path or selecting a URL Preset.
</Info.Body> <br />
<Info.Footer> <br />
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info.Footer>
</Info> </Info>
<section className={style.section}> <div className={style.inlineEntry}>
<div className={style.sectionHeader}> <span className={style.label}>Enter custom path</span>
<span className={style.label}>Enter custom path</span> <label className={style.textEntry}>
<span className={style.description}>Type the path the client should open.</span> {origin}
</div> <Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
<div className={style.inlineEntry}> </label>
<label className={style.textEntry}> <Button
<span className={style.origin}>{origin}</span> variant='primary'
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> aria-label='Redirect'
</label> disabled={path === currentPath || path === ''}
<Button className={style.redirect}
variant='primary' onClick={() => handleRedirect(path)}
aria-label='Redirect to custom path' >
disabled={path === currentPath || path === ''} Redirect
className={style.redirect} <IoArrowForward />
onClick={() => handleRedirect(path)} </Button>
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div> </div>
<section className={style.section}> <div>
<div className={style.sectionHeader}> <span className={style.label}>Select View or URL Preset</span>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
<span className={style.origin}>{origin}</span> {origin}
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
defaultValue={viewOptions[0].value} defaultValue={viewOptions[0].value}
onValueChange={(value) => { onValueChange={(value) => setSelected(value)}
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0} disabled={enabledPresets.length === 0}
/> />
</label> </label>
@@ -129,7 +112,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </div>
</section> </div>
</> </>
} }
/> />
@@ -1,13 +0,0 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ bodyElements={
<div className={style.form}> <Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
} }
footerElements={ footerElements={
<> <>
@@ -1,7 +1,8 @@
import { PropsWithChildren, useRef, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { copyToClipboard } from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -25,19 +26,15 @@ export default function CopyTag({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = async () => { const handleClick = () => {
try { copyToClipboard(copyValue);
await copyToClipboard(copyValue); setCopied(true);
setCopied(true);
// reset copied state // reset copied state
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
} }
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
@@ -1,16 +1,12 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10vh; top: 10%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(420px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: min(80vh, 600px);
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -58,5 +53,4 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -1,6 +1,6 @@
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements: ReactNode;
onClose: () => void; onClose: () => void;
} }
@@ -27,16 +27,16 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-inline: 1rem; padding-inline: 1rem 2rem;
display: flex; display: flex;
justify-content: space-between; gap: 0.5rem;
align-items: flex-start; line-height: 1em;
gap: 1rem;
svg { svg {
color: $gray-500; color: $gray-500;
@@ -61,42 +61,6 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
}
.item .iconStrong {
color: $ui-white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.description {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.3;
}
.shortcut {
color: $gray-500;
font-size: calc(1rem - 4px);
letter-spacing: 0.02em;
} }
.separator { .separator {
@@ -1,18 +1,16 @@
import { Menu as BaseMenu } from '@base-ui/react/menu';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons'; import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss'; import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item' | 'destructive'; type: 'item';
label: string; label: string;
description?: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
shortcut?: string;
}; };
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem; export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -33,21 +31,9 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />; return <BaseMenu.Separator key={index} className={style.separator} />;
} }
return ( return (
<BaseMenu.Item <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
key={index} {item.icon && <item.icon />}
className={style.item} {item.label}
onClick={item.onClick}
disabled={item.disabled}
data-type={item.type}
>
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -83,14 +69,8 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
<span className={style.content}> {item.icon && <item.icon />}
<span className={style.label}> {item.label}
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -1,7 +1,6 @@
.arrow {
transform: rotate(45deg);
}
.corner { .corner {
transform: rotate(45deg);
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -22,10 +21,6 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -59,10 +54,3 @@
height: 0.75em; height: 0.75em;
} }
} }
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -1,33 +1,13 @@
import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react'; import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss'; import style from './EditorUtils.module.scss';
export function CornerExtract({ className, ...elementProps }: IconBaseProps) { export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
} }
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -55,7 +35,3 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />; return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
} }
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -1,9 +1,10 @@
/* eslint-disable react/destructuring-assignment */
import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import React from 'react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { runtimeStore } from '../../stores/runtime'; import { runtimeStore } from '../../stores/runtime';
import { getConnectionState, getReconnectAttempts } from '../../utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -36,7 +37,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: appState,
hasSocket: { hasConnected: getConnectionState(), reconnectAttempts: getReconnectAttempts() }, hasSocket: { hasConnected, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
@@ -48,14 +49,15 @@ class ErrorBoundary extends React.Component {
return ( return (
<div className={style.errorContainer} data-testid='error-container'> <div className={style.errorContainer} data-testid='error-container'>
<div> <div>
<p className={style.error}>: /</p> <p className={style.error}>:/</p>
<p>Something went wrong.</p> <p>Something went wrong</p>
<a <div
role='button'
className={style.report} className={style.report}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`} onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
> >
Report error Report error
</a> </div>
<div <div
role='button' role='button'
className={style.report} className={style.report}
@@ -3,21 +3,24 @@
height: 100%; height: 100%;
display: grid; display: grid;
place-content: center; place-content: center;
background-color: $ui-black; background-color: #121212;
color: $ui-white; color: white;
}
.error { .error {
color: $error-red; color: $error-red;
} font-weight: 600;
}
.report { .report {
color: $blue-500; text-decoration: underline $error-red;
text-decoration: underline; cursor: pointer;
text-underline-offset: 2px; }
cursor: pointer;
&:hover { .report:hover {
color: $ontime-color; color: $error-red;
}
.report:active {
color: white;
} }
} }
@@ -1,6 +1,6 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
@@ -16,33 +16,6 @@
} }
} }
.content {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.title,
.body {
margin: 0;
}
.title {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.body {
color: $gray-400;
line-height: 1.5;
}
.footer {
width: fit-content;
}
.info { .info {
svg { svg {
color: $info-blue; color: $info-blue;
@@ -10,37 +10,13 @@ interface InfoProps {
type?: 'info' | 'warning' | 'error'; type?: 'info' | 'warning' | 'error';
} }
interface InfoSectionProps { export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
className?: string;
}
function InfoTitle({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <p className={cx([style.title, className])}>{children}</p>;
}
function InfoBody({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <p className={cx([style.body, className])}>{children}</p>;
}
function InfoFooter({ className, children }: PropsWithChildren<InfoSectionProps>) {
return <div className={cx([style.footer, className])}>{children}</div>;
}
function InfoRoot({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
return ( return (
<div className={cx([style.infoLabel, style[type], className])}> <div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />} {type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />} {type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />} {type === 'error' && <IoWarning />}
<div className={style.content}>{children}</div> <div>{children}</div>
</div> </div>
); );
} }
const Info = Object.assign(InfoRoot, {
Title: InfoTitle,
Body: InfoBody,
Footer: InfoFooter,
});
export default Info;
@@ -1,6 +1,6 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library // @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize'; import autosize from 'autosize/dist/autosize';
import { RefObject, useEffect } from 'react';
import Textarea, { type TextareaProps } from '../textarea/Textarea'; import Textarea, { type TextareaProps } from '../textarea/Textarea';
@@ -1,4 +1,3 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5'; import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -11,13 +10,12 @@ interface SwatchProps {
isSelected?: boolean; isSelected?: boolean;
} }
export default function Swatch({ color, isSelected, onClick }: SwatchProps) { export default function Swatch(props: SwatchProps) {
const handleClick = (event: MouseEvent) => { const { color, isSelected, onClick } = props;
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]); const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) { if (!color) {
@@ -1,7 +1,7 @@
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks'; import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { UseControllerProps, useController } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { cx, getAccessibleColour } from '../../../utils/styleUtils'; import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker'; import PopoverPicker from '../popover-picker/PopoverPicker';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui/react/radio'; import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss'; import style from './BlockRadio.module.scss';
@@ -8,5 +8,5 @@
text-align: center; text-align: center;
letter-spacing: 1px; letter-spacing: 1px;
max-width: 7em; max-width: 7em;
color: $ontime-delay-text; color: $ontime-delay-text
} }
@@ -1,8 +1,9 @@
import { millisToString, parseUserTime } from 'ontime-utils';
import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction';
import Input from '../input/Input';
import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import BlockRadio from './BlockRadio'; import BlockRadio from './BlockRadio';
import style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -12,13 +13,14 @@ interface DelayInputProps {
duration: number; duration: number;
} }
export default function DelayInput({ eventId, duration }: DelayInputProps) { export default function DelayInput(props: DelayInputProps) {
const { updateEntry } = useEntryActionsContext(); const { eventId, duration } = props;
const { updateEntry } = useEntryActions();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel // avoid wrong submit on cancel
const ignoreChangeRef = useRef(false); let ignoreChange = false;
// set internal value on duration change // set internal value on duration change
useEffect(() => { useEffect(() => {
@@ -33,8 +35,8 @@ export default function DelayInput({ eventId, duration }: DelayInputProps) {
* @param {string} newValue string to be parsed * @param {string} newValue string to be parsed
*/ */
const validateAndSubmit = (newValue: string) => { const validateAndSubmit = (newValue: string) => {
if (ignoreChangeRef.current) { if (ignoreChange) {
ignoreChangeRef.current = false; ignoreChange = false;
return; return;
} }
@@ -76,7 +78,7 @@ export default function DelayInput({ eventId, duration }: DelayInputProps) {
} else if (event.key === 'Tab') { } else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value); validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
ignoreChangeRef.current = true; ignoreChange = true;
setValue(millisToString(duration)); setValue(millisToString(duration));
inputRef.current?.blur(); inputRef.current?.blur();
} }
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-600; color: $gray-500;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -1,4 +1,4 @@
import { InputHTMLAttributes, forwardRef } from 'react'; import { forwardRef, InputHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { Popover } from '@base-ui/react/popover';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover'; import PopoverContents from '../../popover/Popover';
@@ -1,10 +1,10 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
value: string; value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void; onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void; onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
} }
@@ -15,9 +15,7 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean; allowKeyboardNavigation?: boolean;
}, },
@@ -25,10 +23,6 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -84,7 +78,6 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit * @param {string} valueToSubmit
*/ */
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value // force the text to be the initial value
@@ -148,30 +141,16 @@ export default function useReactiveTextInput(
event.stopPropagation(); event.stopPropagation();
} }
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event); hotKeyHandler(event);
}; };
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
value: text, value: text,
onChange: (event) => handleChange(event.target.value), onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event) => { onBlur: (event: ChangeEvent) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) { if (!isKeyboardSubmitting.current) {
handleSubmit(event.target.value); handleSubmit((event.target as HTMLInputElement).value);
} }
}, },
onKeyDown: keyHandler, onKeyDown: keyHandler,
@@ -1,4 +1,4 @@
import { TextareaHTMLAttributes, forwardRef } from 'react'; import { forwardRef, TextareaHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { millisToString, parseUserTime } from 'ontime-utils';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input'; import Input from '../input/Input';
@@ -1,8 +1,7 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import { formatTime, getFormatFromSettings } from '../../../utils/time';
import Input from '../input/Input'; import Input from '../input/Input';
import style from './TimeInput.module.scss'; import style from './TimeInput.module.scss';
@@ -17,7 +16,6 @@ interface TimeInputProps<T extends string> {
align?: 'left' | 'center'; align?: 'left' | 'center';
delayed?: boolean; delayed?: boolean;
className?: string; className?: string;
shouldFormat?: boolean;
} }
export default function TimeInput<T extends string>({ export default function TimeInput<T extends string>({
@@ -30,12 +28,10 @@ export default function TimeInput<T extends string>({
align = 'center', align = 'center',
delayed, delayed,
className, className,
shouldFormat,
}: TimeInputProps<T>) { }: TimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false); const ignoreChange = useRef(false);
const formatAs12Hr = useMemo(() => shouldFormat && getFormatFromSettings() === '12', [shouldFormat]);
/** /**
* @description Resets input value to given * @description Resets input value to given
@@ -43,12 +39,10 @@ export default function TimeInput<T extends string>({
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) { if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00'); setValue('00:00:00');
} else if (shouldFormat) {
setValue(formatTime(time));
} else { } else {
setValue(millisToString(time)); setValue(millisToString(time));
} }
}, [time, shouldFormat]); }, [time]);
/** /**
* @description Selects input text on focus * @description Selects input text on focus
@@ -145,10 +139,9 @@ export default function TimeInput<T extends string>({
onBlur={onBlurHandler} onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
value={value} value={value}
maxLength={formatAs12Hr ? 11 : 8} maxLength={8}
style={{ style={{
textAlign: align, textAlign: align,
width: formatAs12Hr ? '7.5em' : '6.5em',
}} }}
/> />
); );
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -11,7 +11,8 @@ interface AppLinkProps {
* Component used to navigate to an editor link inside the same window * Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL * Handles the path to respect Ontime Clouds base URL
*/ */
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) { export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => navigate({ search }); const handleClick = () => navigate({ search });

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