Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20ea88021 | |||
| ce2b2b9606 | |||
| f223766445 | |||
| 3dfb49e63f | |||
| a13d3dad93 | |||
| fa9695873a | |||
| 70ce1d5553 | |||
| 9974ce24cd | |||
| 93e9c19e0d | |||
| 1694cca92c | |||
| c1d9276dd5 | |||
| f5753a0f5a | |||
| 843f5a968d | |||
| e09b6bbc16 | |||
| 19d6b40bdb | |||
| 1f2706a063 | |||
| aaeadd13cf | |||
| 72e3eb11d7 | |||
| 6f55d8a6c9 | |||
| 70846aefbe | |||
| 47ba90f191 | |||
| cee3c9c070 | |||
| 15f3b9625c | |||
| d34a1f0fd8 | |||
| 0e3b3bcf9a | |||
| 50c91f976b | |||
| 50599eccd2 | |||
| 9f2db10548 | |||
| 5dce1a40bc | |||
| b75b69c3b1 | |||
| e6070e6efd | |||
| 3918664945 | |||
| b65b3fa6d2 | |||
| 162693e19e | |||
| becd734ea8 | |||
| 9dfc8083f0 | |||
| 7bd98c23a4 | |||
| 6f19d78e53 | |||
| 163baa752f | |||
| 6f708df6f6 | |||
| eb5c62cca1 | |||
| 99d2debca8 | |||
| 74e59dccdb | |||
| 812b17a221 | |||
| 32bf0f53f6 | |||
| 17f80baf48 | |||
| e0377a7244 | |||
| b7e5723fb2 | |||
| 897210d5e3 | |||
| ee3d38fce8 | |||
| 37abd3fe47 | |||
| b509ee5b1f | |||
| dd2b59efe5 | |||
| 5788785b3e | |||
| e488ad19b3 | |||
| cbc03c898c | |||
| 155bfbdc42 | |||
| 5d3714a4fc | |||
| a8a1d210d8 | |||
| 49de8b63a7 | |||
| 03c9f51b47 | |||
| 8d00fd3966 | |||
| 80fd21354e | |||
| a87b15825a | |||
| 6a8f98c469 | |||
| 8685298430 | |||
| d43af2db57 | |||
| ef718c48e4 | |||
| 45e7373279 | |||
| 6af2d6e021 | |||
| 9b36f36f73 | |||
| 3a0dbf057e | |||
| dae84ab48a | |||
| aa927597f3 | |||
| ef7f0fb54a | |||
| 324320fdc8 | |||
| 4b27d080b6 | |||
| 7918063753 | |||
| e2202c2417 | |||
| 9817c8f8c0 | |||
| 1d8c04d0b4 | |||
| 10762f10bf | |||
| da7c0d4ff2 | |||
| 4a27973de1 | |||
| bdeb087e28 | |||
| 3662cd58f5 | |||
| 62c52bca07 | |||
| 1fc4a06133 | |||
| 147862e647 | |||
| 12bc323e57 | |||
| de8f9ef587 | |||
| 217402050d | |||
| de75d55849 | |||
| c4e6215b29 | |||
| 57315595a5 | |||
| fc7ed6bd20 | |||
| 750927dc8d | |||
| 00d2ae0a7c | |||
| 56c4d68bae | |||
| a02cdf71c0 | |||
| 25030debe4 | |||
| 27243c4432 | |||
| 22559c59d4 | |||
| 700948bfbb | |||
| 960a39fbf4 | |||
| d986f438d9 | |||
| c2012bd9e3 | |||
| 1f0f92371f | |||
| 4b81725e80 | |||
| 846eaf569d | |||
| ccf1ddf2fd | |||
| 81e00993f2 | |||
| e22c7698c3 | |||
| ef053fc2f7 | |||
| 3b6d3a4631 | |||
| 31c3e603ed | |||
| 1dfe5c980d | |||
| 80e4dc0941 | |||
| bc0b83aa14 | |||
| 971c6c66f0 | |||
| 343697dc93 | |||
| 4058231d6b | |||
| c5ff254267 | |||
| ac14cb9624 | |||
| bcabc50ad1 | |||
| cbd7bce106 | |||
| 00aa8a09fa | |||
| d04f958c11 | |||
| 5716fc31cf | |||
| a5d628af0c | |||
| 111a1f95ba | |||
| 4bf74d86b3 | |||
| d2638d64e4 | |||
| 02cddeec4d | |||
| a8fe8b5f1e | |||
| 8d2db7ffcd | |||
| 313590905f | |||
| 7bbdab7dd9 | |||
| 304832ecde | |||
| 0ab4abfcfe | |||
| 1847822f31 | |||
| 4499495212 | |||
| 0035070db8 | |||
| 890a97d071 | |||
| 5441e5b07f | |||
| ab4b94f2d8 | |||
| fd7ccb4a5b | |||
| 2e6bf234d5 | |||
| c934b0ecdd | |||
| cd6d6f24eb | |||
| e632a08557 | |||
| 20859abda9 | |||
| 1025347c08 | |||
| 43988ea12b | |||
| 722e045b20 | |||
| ad6804019b | |||
| cd40a6e55e | |||
| 08d9e24871 | |||
| 0649678dca | |||
| a00ec2d02a | |||
| 54a74ccc2a | |||
| 6f3ab274bd | |||
| 90870ecfb6 | |||
| b640e0e181 | |||
| c41fe824cf | |||
| 62c8319d70 | |||
| b1d23467a2 | |||
| 9a62daf047 | |||
| b9ab1c6fd7 | |||
| c1054711b0 | |||
| a4d4f29a37 | |||
| 2793aadea0 | |||
| e7cfb7d9d9 | |||
| 256a851c9b | |||
| 4ab9c81cb8 | |||
| 2f13d6c89e | |||
| f1f7bad25e | |||
| a8b52a48f7 | |||
| cd0999b2ab | |||
| b4c60f3f04 | |||
| 5cefad3666 | |||
| 7a6ecd8c34 | |||
| ba96ecfd91 | |||
| 7bed3757f2 | |||
| e2e755b1d2 | |||
| a77d23109d | |||
| 0eb3b8d382 | |||
| 0021185288 | |||
| 94c72ff4f6 | |||
| e4c08dc9b2 | |||
| ec74af0d62 | |||
| b6d72dd082 | |||
| 31c311daf0 | |||
| e9b3cc6090 | |||
| 543b04a097 | |||
| d39b85b6e6 | |||
| bbe107bb2b | |||
| c616240db1 | |||
| 166be66ce3 | |||
| 4180d0a337 | |||
| 78108e316c | |||
| 3bb8b70915 | |||
| 1c4f13a0ed | |||
| 3ca0abad53 | |||
| 030c8f897f | |||
| 68175cfa3b | |||
| fd8f757851 | |||
| 876d111c61 | |||
| 730cb95c04 | |||
| 3c388d4fb5 | |||
| 2e23718d73 | |||
| 89ea8c470b | |||
| 178640bfc4 | |||
| 4ed38340e0 | |||
| 69eb9a5eff | |||
| 351425127a | |||
| 95f2ba37cc |
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
# Ignore build folders
|
# Ignore build folders
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
**/node_modules
|
||||||
|
|
||||||
# Ignore default volumes created by running docker compose up
|
# Ignore default volumes created by running docker compose up
|
||||||
ontime-db
|
ontime-db
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
"ONTIME_VERSION.js"
|
"ONTIME_VERSION.js"
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"jest": true
|
"jest": true
|
||||||
},
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||||
"plugins": ["@typescript-eslint", "prettier"],
|
"plugins": ["@typescript-eslint", "prettier"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|||||||
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 149 KiB |
@@ -14,19 +14,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||||
uses: docker/setup-qemu-action@v3
|
- uses: actions/setup-node@v6
|
||||||
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version-file: '.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
|
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
|
||||||
@@ -45,9 +50,12 @@ 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
|
||||||
|
|||||||
@@ -6,43 +6,63 @@ 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@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker Login
|
- name: Setup pnpm
|
||||||
uses: docker/login-action@v2.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker Setup Buildx
|
- name: Get pnpm store directory
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push stable release
|
- name: Setup pnpm cache
|
||||||
if: github.event.release.prerelease == false
|
uses: actions/cache@v4
|
||||||
uses: docker/build-push-action@v4.0.0
|
with:
|
||||||
with:
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
context: .
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
file: ./Dockerfile
|
restore-keys: |
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
${{ runner.os }}-pnpm-store-
|
||||||
# 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: Build and push pre-release
|
- name: Install dependencies
|
||||||
if: github.event.release.prerelease == true
|
run: pnpm install --frozen-lockfile
|
||||||
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@v2.1.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Setup Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
|
- name: Build and push stable release
|
||||||
|
if: github.event.release.prerelease == false
|
||||||
|
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:latest
|
||||||
|
|
||||||
|
- name: Build and push pre-release
|
||||||
|
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Ontime build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [ "*" ]
|
tags: ['*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -11,15 +11,20 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
|
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
|
||||||
@@ -53,15 +58,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -86,15 +85,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Ontime Resolver build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_resolver:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 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@v4
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
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
|
||||||
@@ -14,55 +14,35 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
|
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 per package
|
# Run code quality
|
||||||
- name: React - Run linter + TypeScript checks
|
- name: Run linter
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./apps/client
|
|
||||||
|
|
||||||
- name: Server - Run linter + TypeScript checks
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./apps/server
|
|
||||||
|
|
||||||
- name: Utils - Run linter + TypeScript checks
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint && tsc --noEmit
|
|
||||||
working-directory: ./packages/utils
|
|
||||||
|
|
||||||
- name: Types - Run linter
|
|
||||||
if: always()
|
|
||||||
run: pnpm lint
|
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: React - Run unit tests
|
- name: Run unit tests
|
||||||
if: always()
|
if: always()
|
||||||
run: pnpm test:pipeline
|
run: pnpm test
|
||||||
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
|
||||||
@@ -70,21 +50,36 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
|
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: Build client
|
- name: Get installed Playwright version
|
||||||
run: pnpm build:local
|
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||||
|
- name: Cache playwright binaries
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-playwright-
|
||||||
|
- run: npx playwright install --with-deps
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ node_modules
|
|||||||
|
|
||||||
playwright-report
|
playwright-report
|
||||||
|
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
**/*.toml
|
**/*.toml
|
||||||
**/*.yml
|
|
||||||
**/*.json
|
**/*.json
|
||||||
|
!tsconfig.common.json
|
||||||
|
!turbo.json
|
||||||
|
!package.json
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Locally, we would need to run both the React client and the node.js server in de
|
|||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Run dev mode__ by running `pnpm turbo dev`
|
- __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
|
||||||
@@ -30,10 +31,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 React UI__ by running `pnpm dev --filter=ontime-ui`
|
||||||
- __Run the nodejs server__ by running `pnpm turbo dev --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 turbo dev:inspect --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
|
||||||
|
|
||||||
@@ -46,7 +47,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 turbo test:pipeline` from the project root.
|
You can run unit tests by running `pnpm 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
|
||||||
@@ -66,7 +67,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)
|
||||||
@@ -77,14 +78,11 @@ 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 turbo run build:electron`
|
- __Build the UI and server__ by running `pnpm build`
|
||||||
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
|
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm 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.
|
||||||
@@ -99,3 +97,16 @@ 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>`
|
||||||
|
|
||||||
|
## CONTRIBUTION GUIDELINES
|
||||||
|
|
||||||
|
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
|
||||||
|
|
||||||
|
For new PRs, please follow the following checklist:
|
||||||
|
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
|
||||||
|
* [ ] You have ran code formatting and linting in all your changes
|
||||||
|
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
|
||||||
|
* [ ] The PR body contains description and motivation for the changes
|
||||||
|
|
||||||
|
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
|
||||||
|
We will review as soon as possible
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
FROM node:22-bullseye AS builder
|
ARG NODE_VERSION=22.15.1
|
||||||
ENV PNPM_HOME="/pnpm"
|
FROM node:${NODE_VERSION}-alpine
|
||||||
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
|
||||||
@@ -15,16 +7,17 @@ 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 --from=builder /app/apps/client/build ./client/
|
COPY apps/client/build/ ./client/
|
||||||
|
|
||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY apps/server/src/external/ ./external/
|
||||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
COPY apps/server/src/user/ ./user/
|
||||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
COPY 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
|
||||||
@@ -32,6 +25,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
|
||||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
# pnpm build:docker
|
||||||
# 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
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
|
|||||||
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.0.0-beta.2",
|
"version": "4.2.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -13,12 +13,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-beta.2",
|
"version": "4.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui-components/react": "1.0.0-beta.3",
|
"@base-ui-components/react": "1.0.0-beta.4",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.6",
|
"@fontsource/open-sans": "^5.2.6",
|
||||||
"@mantine/hooks": "^8.2.8",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.2.0",
|
"@sentry/react": "^10.2.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@tanstack/react-query-devtools": "^5.85.9",
|
"@tanstack/react-query-devtools": "^5.85.9",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.12.2",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -37,15 +37,12 @@
|
|||||||
"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",
|
||||||
"build": "vite build",
|
"dev:electron": "pnpm dev",
|
||||||
"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",
|
"lint": "eslint . --quiet",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "vite build",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
|
|||||||
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 KeepAwake from './features/keep-awake/KeepAwake';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
import { baseURI } from './externals';
|
import { baseURI } from './externals';
|
||||||
@@ -24,6 +25,7 @@ function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
<IdentifyOverlay />
|
<IdentifyOverlay />
|
||||||
|
<KeepAwake />
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default function AppRouter() {
|
|||||||
path='op'
|
path='op'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
<Operator />
|
<Operator />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
@@ -207,13 +208,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}>
|
||||||
{preset.target !== OntimeView.Cuesheet && (
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
|
||||||
)}
|
|
||||||
{Component ? <Component /> : <NotFound />}
|
{Component ? <Component /> : <NotFound />}
|
||||||
</PresetContext>
|
</PresetContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
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 { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
|
|
||||||
@@ -33,27 +30,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { CustomFields, Rundown } from 'ontime-types';
|
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
const excelPath = `${apiEntryUrl}/excel`;
|
const excelPath = `${apiEntryUrl}/excel`;
|
||||||
|
|
||||||
@@ -10,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
|
|||||||
* upload Excel file to server
|
* upload Excel file to server
|
||||||
* @return string - file ID op the uploaded file
|
* @return string - file ID op the uploaded file
|
||||||
*/
|
*/
|
||||||
export async function upload(file: File) {
|
export async function upload(file: File): Promise<string[]> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('excel', file);
|
formData.append('excel', file);
|
||||||
await axios.post(`${excelPath}/upload`, formData, {
|
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Worksheet names
|
|
||||||
* @return string[] - array of available worksheets
|
|
||||||
*/
|
|
||||||
export async function getWorksheetNames(): Promise<string[]> {
|
|
||||||
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewSpreadsheetResponse = {
|
type PreviewSpreadsheetResponse = {
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
};
|
};
|
||||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||||
@@ -39,3 +33,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a xlsx representation of the rundown from the server
|
||||||
|
*/
|
||||||
|
export async function downloadAsExcel(rundownId: string, fileName?: string) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
|
|||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
|
type RundownId = string;
|
||||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||||
|
|
||||||
// #region operations on project rundowns =========================
|
// #region operations on project rundowns =========================
|
||||||
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to switch the currently loaded rundown
|
* HTTP request to switch the currently loaded rundown
|
||||||
*/
|
*/
|
||||||
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
return axios.post(`${rundownPath}/${id}/load`);
|
return axios.post(`${rundownPath}/${rundownId}/load`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +38,25 @@ 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(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
return axios.delete(`${rundownPath}/${id}`);
|
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
|
|||||||
* HTTP request to post new entry
|
* HTTP request to post new entry
|
||||||
*/
|
*/
|
||||||
export async function postAddEntry(
|
export async function postAddEntry(
|
||||||
rundownId: string,
|
rundownId: RundownId,
|
||||||
data: TransientEventPayload,
|
data: TransientEventPayload,
|
||||||
): Promise<AxiosResponse<OntimeEntry>> {
|
): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
@@ -60,7 +75,7 @@ export async function postAddEntry(
|
|||||||
/**
|
/**
|
||||||
* HTTP request to edit an entry
|
* HTTP request to edit an entry
|
||||||
*/
|
*/
|
||||||
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +87,7 @@ export type BatchEditEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to edit multiple events
|
* HTTP request to edit multiple events
|
||||||
*/
|
*/
|
||||||
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,56 +100,56 @@ export type ReorderEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to reorder an entry
|
* HTTP request to reorder an entry
|
||||||
*/
|
*/
|
||||||
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
export async function patchReorderEntry(rundownId: RundownId, 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(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestEventSwap(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: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestApplyDelay(rundownId: RundownId, 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(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function requestGroupEntries(rundownId: RundownId, 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: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestUngroup(rundownId: RundownId, 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: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function deleteEntries(rundownId: RundownId, 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: string): Promise<AxiosResponse<Rundown>> {
|
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
|
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
@@ -56,6 +56,7 @@ export const previewRundown = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PropsWithChildren, useRef, useState } from 'react';
|
import { PropsWithChildren, useRef, useState } from 'react';
|
||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark, IoCopy } 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';
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -61,6 +60,13 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-type='destructive'] {
|
||||||
|
color: $red-500;
|
||||||
|
svg {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
|
|||||||
|
|
||||||
type DropdownMenuItemDivider = { type: 'divider' };
|
type DropdownMenuItemDivider = { type: 'divider' };
|
||||||
type DropdownMenuItem = {
|
type DropdownMenuItem = {
|
||||||
type: 'item';
|
type: 'item' | 'destructive';
|
||||||
label: string;
|
label: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -31,7 +31,7 @@ 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 key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
{item.label}
|
{item.label}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.corner {
|
.arrow {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offsetCorner {
|
||||||
|
right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
|
import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } 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 Corner({ className, ...elementProps }: IconBaseProps) {
|
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
|
||||||
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
return <IoArrowUp className={cx([style.corner, style.arrow, 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>) {
|
||||||
|
|||||||
@@ -49,15 +49,14 @@ 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>
|
||||||
<div
|
<a
|
||||||
role='button'
|
|
||||||
className={style.report}
|
className={style.report}
|
||||||
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
|
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
|
||||||
>
|
>
|
||||||
Report error
|
Report error
|
||||||
</div>
|
</a>
|
||||||
<div
|
<div
|
||||||
role='button'
|
role='button'
|
||||||
className={style.report}
|
className={style.report}
|
||||||
|
|||||||
@@ -3,24 +3,21 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
background-color: #121212;
|
background-color: $ui-black;
|
||||||
color: white;
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: $error-red;
|
color: $error-red;
|
||||||
font-weight: 600;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.report {
|
.report {
|
||||||
text-decoration: underline $error-red;
|
color: $blue-500;
|
||||||
cursor: pointer;
|
text-decoration: underline;
|
||||||
}
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
.report:hover {
|
&:hover {
|
||||||
color: $error-red;
|
color: $ontime-color;
|
||||||
}
|
|
||||||
|
|
||||||
.report:active {
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -10,12 +11,13 @@ interface SwatchProps {
|
|||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Swatch(props: SwatchProps) {
|
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
|
||||||
const { color, isSelected, onClick } = props;
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
onClick?.(color);
|
onClick?.(color);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
|
|||||||
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
|
||||||
let ignoreChange = false;
|
const ignoreChangeRef = useRef(false);
|
||||||
|
|
||||||
// set internal value on duration change
|
// set internal value on duration change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
|
|||||||
* @param {string} newValue string to be parsed
|
* @param {string} newValue string to be parsed
|
||||||
*/
|
*/
|
||||||
const validateAndSubmit = (newValue: string) => {
|
const validateAndSubmit = (newValue: string) => {
|
||||||
if (ignoreChange) {
|
if (ignoreChangeRef.current) {
|
||||||
ignoreChange = false;
|
ignoreChangeRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export default function DelayInput(props: 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') {
|
||||||
ignoreChange = true;
|
ignoreChangeRef.current = 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-500;
|
color: $gray-600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ 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(props: PropsWithChildren<AppLinkProps>) {
|
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
|
||||||
const { search, children } = props;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = () => navigate({ search });
|
const handleClick = () => navigate({ search });
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ interface ExternalLinkProps {
|
|||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExternalLink(props: ExternalLinkProps) {
|
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
|
||||||
const { href, inline, children } = props;
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openLink(href);
|
openLink(href);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
min-width: min(680px, 90vw);
|
min-width: min(880px, 90vw);
|
||||||
min-height: min(200px, 10vh);
|
min-height: min(200px, 10vh);
|
||||||
max-width: min(900px, 90vw);
|
max-width: min(1200px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||||
|
import { LuCoffee } from 'react-icons/lu';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../externals';
|
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||||
|
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
@@ -27,10 +30,12 @@ export default memo(NavigationMenu);
|
|||||||
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||||
const id = useClientStore((store) => store.id);
|
const id = useClientStore((store) => store.id);
|
||||||
const name = useClientStore((store) => store.name);
|
const name = useClientStore((store) => store.name);
|
||||||
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||||
|
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,25 +58,38 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.body}>
|
<div className={style.body}>
|
||||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
{supportsFullscreen && (
|
||||||
Toggle Fullscreen
|
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
Toggle Fullscreen
|
||||||
</NavigationMenuItem>
|
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
)}
|
||||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||||
Flip Screen
|
Flip Screen
|
||||||
<IoSwapVertical />
|
<IoSwapVertical />
|
||||||
{mirror && <span className={style.note}>Active</span>}
|
{mirror && <span className={style.note}>Active</span>}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
{window.isSecureContext && (
|
||||||
|
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
||||||
|
Keep Awake
|
||||||
|
<LuCoffee />
|
||||||
|
{keepAwake && <span className={style.note}>Active</span>}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
)}
|
||||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||||
|
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
|
|
||||||
<EditorNavigation />
|
<EditorNavigation />
|
||||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
<ClientLink
|
||||||
|
to='cuesheet'
|
||||||
|
current={location.pathname === '/cuesheet'}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
@@ -79,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
|
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
<ClientLink
|
||||||
|
key={route.url}
|
||||||
|
to={route.url}
|
||||||
|
current={location.pathname === `/${route.url}`}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
{route.label}
|
{route.label}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
|
|||||||
interface ClientLinkProps {
|
interface ClientLinkProps {
|
||||||
current: boolean;
|
current: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
|
postAction?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
|
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||||
const { isElectron } = useElectronEvent();
|
const { isElectron } = useElectronEvent();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
|
<NavigationMenuItem
|
||||||
|
active={current}
|
||||||
|
onClick={() => {
|
||||||
|
handleLinks(to);
|
||||||
|
postAction?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
|
<NavigationMenuItem
|
||||||
|
active={current}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/${to}`);
|
||||||
|
postAction?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IoApps } from 'react-icons/io5';
|
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
||||||
import { IoSettingsOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pin {
|
.pin {
|
||||||
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
|
|||||||
@@ -62,8 +62,6 @@
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 20rem;
|
|
||||||
border: 1px solid $gray-1000;
|
border: 1px solid $gray-1000;
|
||||||
|
|
||||||
&[data-side='start'] {
|
&[data-side='start'] {
|
||||||
@@ -73,6 +71,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
padding-block: 0.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 20rem;
|
||||||
|
max-height: var(--available-height);
|
||||||
|
scroll-padding-block: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
|||||||
@@ -31,14 +31,17 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
|
|||||||
<BaseSelect.Positioner side='bottom' align='start'>
|
<BaseSelect.Positioner side='bottom' align='start'>
|
||||||
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
||||||
<BaseSelect.Popup className={styles.popup}>
|
<BaseSelect.Popup className={styles.popup}>
|
||||||
{options.map(({ disabled, label, value }) => (
|
<BaseSelect.Arrow />
|
||||||
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
|
<BaseSelect.List className={styles.list}>
|
||||||
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
{options.map(({ disabled, label, value }) => (
|
||||||
<IoCheckmark className={styles.itemIndicatorIcon} />
|
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
|
||||||
</BaseSelect.ItemIndicator>
|
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
||||||
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
|
<IoCheckmark className={styles.itemIndicatorIcon} />
|
||||||
</BaseSelect.Item>
|
</BaseSelect.ItemIndicator>
|
||||||
))}
|
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
|
||||||
|
</BaseSelect.Item>
|
||||||
|
))}
|
||||||
|
</BaseSelect.List>
|
||||||
</BaseSelect.Popup>
|
</BaseSelect.Popup>
|
||||||
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
|
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
|
||||||
</BaseSelect.Positioner>
|
</BaseSelect.Positioner>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
width: 0.75em;
|
width: 0.75em;
|
||||||
height: 0.75em;
|
height: 0.75em;
|
||||||
background: var(--user-bg);
|
background: var(--user-bg, $gray-900);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
||||||
width: 40rem;
|
width: 40rem;
|
||||||
|
max-width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const [_, setSearchParams] = useSearchParams();
|
const [_, setSearchParams] = useSearchParams();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
const { isOpen, close } = useViewParamsEditorStore();
|
const { isOpen, close } = useViewParamsEditorStore();
|
||||||
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
|||||||
options.push({
|
options.push({
|
||||||
value: key,
|
value: key,
|
||||||
label: value.label,
|
label: value.label,
|
||||||
colour: value.colour || 'transparent',
|
colour: value.colour,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ProjectFileListResponse } from 'ontime-types';
|
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_LIST } from '../api/constants';
|
import { PROJECT_LIST } from '../api/constants';
|
||||||
@@ -24,22 +24,33 @@ function useProjectList() {
|
|||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrderedProjectList() {
|
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
||||||
|
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
|
||||||
|
const sortComparators: Record<ProjectSortMode, SortComparator> = {
|
||||||
|
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
|
||||||
|
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
|
||||||
|
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
|
||||||
|
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
|
||||||
const response = useProjectList();
|
const response = useProjectList();
|
||||||
const { files, lastLoadedProject } = response.data;
|
const { files, lastLoadedProject } = response.data;
|
||||||
|
|
||||||
const reorderedProjectFiles = useMemo(() => {
|
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
|
||||||
if (!files.length) return [];
|
if (!files.length) return [];
|
||||||
|
|
||||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
const sorted = [...files].sort(sortComparators[sort]);
|
||||||
|
|
||||||
if (currentlyLoadedIndex === -1) return files;
|
// keep loaded always on top
|
||||||
|
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
|
||||||
|
if (currentlyLoadedIndex > 0) {
|
||||||
|
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
|
||||||
|
sorted.unshift(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
const projectFiles = [...files];
|
return sorted;
|
||||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
}, [files, lastLoadedProject, sort]);
|
||||||
|
|
||||||
return [current, ...projectFiles];
|
|
||||||
}, [files, lastLoadedProject]);
|
|
||||||
|
|
||||||
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
|
|||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||||
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
|
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project rundowns
|
* Project rundowns
|
||||||
@@ -31,6 +31,26 @@ export function useMutateProjectRundowns() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: duplicate } = useMutation({
|
||||||
|
mutationFn: duplicateRundown,
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: rename } = useMutation({
|
||||||
|
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { mutateAsync: remove } = useMutation({
|
const { mutateAsync: remove } = useMutation({
|
||||||
mutationFn: deleteRundown,
|
mutationFn: deleteRundown,
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { create, remove, load };
|
return { create, duplicate, remove, load, rename };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import { fetchCurrentRundown } from '../api/rundown';
|
import { fetchCurrentRundown } from '../api/rundown';
|
||||||
import { useSelectedEventId } from '../hooks/useSocket';
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||||
|
|
||||||
import useProjectData from './useProjectData';
|
import useProjectData from './useProjectData';
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export function useFlatRundown() {
|
|||||||
|
|
||||||
// update data whenever the revision changes
|
// update data whenever the revision changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||||
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
||||||
setFlatRundown(flatRundown);
|
setFlatRundown(flatRundown);
|
||||||
setPrevRevision(data.revision);
|
setPrevRevision(data.revision);
|
||||||
@@ -85,8 +85,8 @@ export function useFlatRundownWithMetadata() {
|
|||||||
/**
|
/**
|
||||||
* Provides access to a partial rundown based on a filter callback
|
* Provides access to a partial rundown based on a filter callback
|
||||||
*/
|
*/
|
||||||
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
|
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||||
const { data, status } = useFlatRundown();
|
const { data, status } = useFlatRundownWithMetadata();
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return data.filter(cb);
|
return data.filter(cb);
|
||||||
}, [data, cb]);
|
}, [data, cb]);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
import { RefObject, useCallback, useEffect } from 'react';
|
||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
import { useSelectedEventId } from './useSocket';
|
|
||||||
|
|
||||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: RefObject<ComponentRef>,
|
componentRef: RefObject<ComponentRef>,
|
||||||
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
|
|||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
|
||||||
componentRef: RefObject<ComponentRef>,
|
|
||||||
scrollRef: RefObject<ScrollRef>,
|
|
||||||
topOffset: number,
|
|
||||||
) {
|
|
||||||
if (!componentRef.current || !scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
|
||||||
|
|
||||||
// maintain current x scroll position
|
|
||||||
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFollowComponentProps {
|
interface UseFollowComponentProps {
|
||||||
followRef: RefObject<HTMLElement | null>;
|
followRef: RefObject<HTMLElement | null>;
|
||||||
scrollRef: RefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
setScrollFlag?: (newValue: boolean) => void;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
|
followTrigger?: MaybeString; // this would be an entry id or null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
export default function useFollowComponent({
|
||||||
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
followRef,
|
||||||
|
scrollRef,
|
||||||
// when cursor moves, view should follow
|
doFollow,
|
||||||
|
topOffset = 100,
|
||||||
|
setScrollFlag,
|
||||||
|
followTrigger,
|
||||||
|
}: UseFollowComponentProps) {
|
||||||
|
// when trigger moves, view should follow
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!doFollow) {
|
if (!doFollow || !followTrigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
setScrollFlag?.(false);
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect
|
|
||||||
}, [followRef?.current, scrollRef?.current]);
|
|
||||||
|
|
||||||
const scrollToRefComponent = useCallback(
|
const scrollToRefComponent = useCallback(
|
||||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
if (componentRef.current && containerRef.current) {
|
if (componentRef && containerRef) {
|
||||||
// @ts-expect-error -- we know this are not null
|
// @ts-expect-error -- we know this are not null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
scrollToComponent(componentRef!, scrollRef!, offset);
|
scrollToComponent(componentRef!, containerRef!, offset);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[followRef, scrollRef, topOffset],
|
[followRef, scrollRef, topOffset],
|
||||||
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
|
|
||||||
const selectedEvenId = useSelectedEventId();
|
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!doFollow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedEvenId && selectedRef.current && scrollRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
snapToComponent(
|
|
||||||
{ current: selectedRef.current } as RefObject<HTMLElement>,
|
|
||||||
{ current: scrollRef.current } as RefObject<HTMLElement>,
|
|
||||||
topOffset,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [doFollow, selectedEvenId, topOffset]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedRef,
|
|
||||||
scrollRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { throttle } from '../utils/throttle';
|
||||||
|
|
||||||
|
export interface UseLongPressOptions {
|
||||||
|
/** Time in milliseconds to trigger the long press, default is 400ms */
|
||||||
|
threshold?: number;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press starts */
|
||||||
|
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press finishes */
|
||||||
|
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press is canceled */
|
||||||
|
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLongPressReturnValue {
|
||||||
|
onMouseDown: (event: React.MouseEvent) => void;
|
||||||
|
onMouseUp: (event: React.MouseEvent) => void;
|
||||||
|
onMouseLeave: (event: React.MouseEvent) => void;
|
||||||
|
onTouchStart: (event: React.TouchEvent) => void;
|
||||||
|
onTouchEnd: (event: React.TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLongPress(
|
||||||
|
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
|
||||||
|
options: UseLongPressOptions = {},
|
||||||
|
): UseLongPressReturnValue {
|
||||||
|
const { threshold = 700, onStart, onFinish, onCancel } = options;
|
||||||
|
const isLongPressActive = useRef(false);
|
||||||
|
const isPressed = useRef(false);
|
||||||
|
const timeout = useRef<number>(-1);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timeout.current), []);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (typeof onLongPress !== 'function') {
|
||||||
|
return {} as UseLongPressReturnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (event: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if (!isMouseEvent(event) && !isTouchEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStart) {
|
||||||
|
onStart(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPressed.current = true;
|
||||||
|
timeout.current = window.setTimeout(() => {
|
||||||
|
onLongPress(event);
|
||||||
|
isLongPressActive.current = true;
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if (!isMouseEvent(event) && !isTouchEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLongPressActive.current) {
|
||||||
|
onFinish?.(event);
|
||||||
|
} else if (isPressed.current) {
|
||||||
|
onCancel?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLongPressActive.current = false;
|
||||||
|
isPressed.current = false;
|
||||||
|
|
||||||
|
if (timeout.current) {
|
||||||
|
window.clearTimeout(timeout.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMouseDown: start,
|
||||||
|
onMouseUp: cancel,
|
||||||
|
onMouseLeave: cancel,
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
onTouchMove: throttle(cancel, 150),
|
||||||
|
};
|
||||||
|
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
|
||||||
|
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
|
||||||
|
return event.nativeEvent instanceof MouseEvent;
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ const createSelector =
|
|||||||
export const setClientRemote = {
|
export const setClientRemote = {
|
||||||
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
||||||
setRedirect: (payload: { target: string; redirect: string }) => {
|
setRedirect: (payload: { target: string; redirect: string }) => {
|
||||||
console.log('--- got', payload);
|
|
||||||
sendSocket('client', payload);
|
sendSocket('client', payload);
|
||||||
},
|
},
|
||||||
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
||||||
@@ -165,46 +164,15 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
|
|||||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
|
|
||||||
plannedStart: state.rundown.plannedStart,
|
|
||||||
actualStart: state.rundown.actualStart,
|
|
||||||
plannedEnd: state.rundown.plannedEnd,
|
|
||||||
expectedEnd: state.offset.expectedRundownEnd,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
|
||||||
playback: state.timer.playback,
|
|
||||||
clock: state.clock,
|
|
||||||
|
|
||||||
numEvents: state.rundown.numEvents,
|
|
||||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
|
||||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
|
||||||
|
|
||||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
|
||||||
clock: state.clock,
|
|
||||||
offset: state.offset.absolute,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
actualStart: state.rundown.actualStart,
|
actualStart: state.rundown.actualStart,
|
||||||
plannedStart: state.rundown.plannedStart,
|
plannedStart: state.rundown.plannedStart,
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
|
||||||
offset: state.offset.absolute,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||||
ping: state.ping,
|
ping: state.ping,
|
||||||
}));
|
}));
|
||||||
@@ -228,6 +196,48 @@ export const usePlayback = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ======================= Overview data subscriptions ======================= */
|
||||||
|
|
||||||
|
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
plannedStart: state.rundown.plannedStart,
|
||||||
|
actualStart: state.rundown.actualStart,
|
||||||
|
plannedEnd: state.rundown.plannedEnd,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
|
||||||
|
expectedEnd: state.offset.expectedRundownEnd,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
numEvents: state.rundown.numEvents,
|
||||||
|
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
|
||||||
|
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
|
clock: state.clock,
|
||||||
|
mode: state.offset.mode,
|
||||||
|
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||||
|
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||||
|
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
|
clock: state.clock,
|
||||||
|
mode: state.offset.mode,
|
||||||
|
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||||
|
actualStart: state.rundown.actualStart ?? 0,
|
||||||
|
plannedStart: state.rundown.plannedStart ?? 0,
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
/* ======================= View specific subscriptions ======================= */
|
/* ======================= View specific subscriptions ======================= */
|
||||||
|
|
||||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -269,11 +279,7 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
|
|||||||
eventNow: state.eventNow,
|
eventNow: state.eventNow,
|
||||||
message: state.message,
|
message: state.message,
|
||||||
time: state.timer,
|
time: state.timer,
|
||||||
offset: state.offset,
|
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||||
rundown: state.rundown,
|
rundown: state.rundown,
|
||||||
}));
|
expectedRundownEnd: state.offset.expectedRundownEnd,
|
||||||
|
|
||||||
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
|
|
||||||
clock: state.clock,
|
|
||||||
offset: state.offset.absolute,
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type Size = 'xs' | 'sm' | 'md' | 'lg';
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export type OverridableOptions = {
|
|
||||||
keyColour?: string;
|
|
||||||
textColour?: string;
|
|
||||||
textBackground?: string;
|
|
||||||
font?: string;
|
|
||||||
size?: number;
|
|
||||||
justifyContent?: 'start' | 'center' | 'end';
|
|
||||||
alignItems?: 'start' | 'center' | 'end';
|
|
||||||
left?: string;
|
|
||||||
top?: string;
|
|
||||||
hideNav?: boolean;
|
|
||||||
hideOvertime?: boolean;
|
|
||||||
hideMessagesOverlay?: boolean;
|
|
||||||
hideEndMessage?: boolean;
|
|
||||||
language?: string;
|
|
||||||
showProgressBar?: boolean;
|
|
||||||
hideTimerSeconds?: boolean;
|
|
||||||
removeLeadingZeros?: boolean;
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
|
|
||||||
|
|
||||||
interface SelectionState {
|
|
||||||
[key: string]: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColumnPermissions {
|
|
||||||
read: string[];
|
|
||||||
write: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CuesheetLinksState {
|
|
||||||
target: Target | null;
|
|
||||||
readSelected: SelectionState;
|
|
||||||
writeSelected: SelectionState;
|
|
||||||
setTarget: (target: Target | null) => void;
|
|
||||||
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
|
|
||||||
toggleField: (field: 'read' | 'write', key: string) => void;
|
|
||||||
selectAll: (field: 'read' | 'write', keys: string[]) => void;
|
|
||||||
clearAll: (field: 'read' | 'write', keys: string[]) => void;
|
|
||||||
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
|
|
||||||
getSelections: () => ColumnPermissions | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
|
|
||||||
target: null,
|
|
||||||
readSelected: {},
|
|
||||||
writeSelected: {},
|
|
||||||
setTarget: (target) => set({ target }),
|
|
||||||
setField: (field, key, value) =>
|
|
||||||
set((state) => ({
|
|
||||||
...(field === 'read'
|
|
||||||
? { readSelected: { ...state.readSelected, [key]: value } }
|
|
||||||
: { writeSelected: { ...state.writeSelected, [key]: value } }),
|
|
||||||
})),
|
|
||||||
toggleField: (field, key) =>
|
|
||||||
set((state) => ({
|
|
||||||
...(field === 'read'
|
|
||||||
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
|
|
||||||
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
|
|
||||||
})),
|
|
||||||
selectAll: (field, keys) =>
|
|
||||||
set((_state) => ({
|
|
||||||
...(field === 'read'
|
|
||||||
? {
|
|
||||||
readSelected: keys.reduce((acc, key) => {
|
|
||||||
acc[key] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as SelectionState),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
writeSelected: keys.reduce((acc, key) => {
|
|
||||||
acc[key] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as SelectionState),
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
clearAll: (field, keys) =>
|
|
||||||
set((_state) => ({
|
|
||||||
...(field === 'read'
|
|
||||||
? {
|
|
||||||
readSelected: keys.reduce((acc, key) => {
|
|
||||||
acc[key] = false;
|
|
||||||
return acc;
|
|
||||||
}, {} as SelectionState),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
writeSelected: keys.reduce((acc, key) => {
|
|
||||||
acc[key] = false;
|
|
||||||
return acc;
|
|
||||||
}, {} as SelectionState),
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
getSelections: () => {
|
|
||||||
const state = get();
|
|
||||||
if (state.target !== 'cuesheet') return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
read: Object.entries(state.readSelected)
|
|
||||||
.filter(([_, selected]) => selected)
|
|
||||||
.map(([key]) => key),
|
|
||||||
write: Object.entries(state.writeSelected)
|
|
||||||
.filter(([_, selected]) => selected)
|
|
||||||
.map(([key]) => key),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
|
import { makeCSVFromArrayOfArrays } from '../csv';
|
||||||
|
|
||||||
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
|
|
||||||
|
|
||||||
describe('makeCSVFromArrayOfArrays()', () => {
|
describe('makeCSVFromArrayOfArrays()', () => {
|
||||||
it('joins an array of arrays with commas and newlines', () => {
|
it('joins an array of arrays with commas and newlines', () => {
|
||||||
@@ -13,34 +11,3 @@ after newline,after comma
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('aggregateRundowns()', () => {
|
|
||||||
it('flattens an object of rundowns into a single array', () => {
|
|
||||||
const rundowns = {
|
|
||||||
first: {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
revision: 0,
|
|
||||||
order: ['1', '2'],
|
|
||||||
flatOrder: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1' } as OntimeEntry,
|
|
||||||
'2': { id: '2' } as OntimeEntry,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
revision: 0,
|
|
||||||
order: ['3', '4'],
|
|
||||||
flatOrder: ['3', '4'],
|
|
||||||
entries: {
|
|
||||||
'3': { id: '3' } as OntimeEntry,
|
|
||||||
'4': { id: '4' } as OntimeEntry,
|
|
||||||
},
|
|
||||||
} as Rundown,
|
|
||||||
} as ProjectRundowns;
|
|
||||||
|
|
||||||
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
|
|||||||
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
||||||
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle ontime app links', () => {
|
||||||
|
const serverUrl = 'https://app.getontime.no/user-hash';
|
||||||
|
const baseUri = 'user-hash';
|
||||||
|
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
|
||||||
|
expect(destination).toBe('https://app.getontime.no/user-hash/path');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 3,
|
eventIndex: 3,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 4,
|
eventIndex: 4,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 5,
|
eventIndex: 5,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 17,
|
totalGap: 7,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { formatTime, nowInMillis } from '../time';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { formatDuration, formatTime, nowInMillis } from '../time';
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
it('should return the current time in milliseconds', () => {
|
it('should return the current time in milliseconds', () => {
|
||||||
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
|
|||||||
expect(time).toStrictEqual('-01:00');
|
expect(time).toStrictEqual('-01:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatDuration()', () => {
|
||||||
|
it('formats durations correctly', () => {
|
||||||
|
expect(formatDuration(0)).toBe('0m');
|
||||||
|
expect(formatDuration(-5000)).toBe('0m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
|
||||||
|
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
|
||||||
|
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
|
||||||
|
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
|
||||||
|
expect(formatDuration(599702, false)).toBe('9m59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on on invalid route', () => {
|
it('throws on invalid route', () => {
|
||||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an array of arrays to a CSV file
|
* Converts an array of arrays to a CSV file
|
||||||
@@ -7,25 +6,3 @@ import { OntimeEntry, ProjectRundowns } from 'ontime-types';
|
|||||||
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
||||||
return stringify(arrayOfArrays);
|
return stringify(arrayOfArrays);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives an object of rundowns, and flattens them into a single, linear rundown
|
|
||||||
* Used for CSV export
|
|
||||||
*/
|
|
||||||
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
|
|
||||||
const rundownKeys = Object.keys(rundowns);
|
|
||||||
if (rundownKeys.length === 0) return [];
|
|
||||||
const flatRundown: OntimeEntry[] = [];
|
|
||||||
|
|
||||||
for (const key of rundownKeys) {
|
|
||||||
const { order, entries } = rundowns[key];
|
|
||||||
|
|
||||||
for (let i = 0; i < order.length; i++) {
|
|
||||||
const entryId = order[i];
|
|
||||||
const entry = entries[entryId];
|
|
||||||
|
|
||||||
flatRundown.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flatRundown;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { enDash } from './styleUtils';
|
import { enDash } from './styleUtils';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
|
|||||||
let offsetText = '';
|
let offsetText = '';
|
||||||
if (offset < 0) offsetText += '-';
|
if (offset < 0) offsetText += '-';
|
||||||
if (offset > 0) offsetText += '+';
|
if (offset > 0) offsetText += '+';
|
||||||
offsetText += millisToString(Math.abs(offset));
|
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
|
||||||
return offsetText;
|
return offsetText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ function processEntry(
|
|||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||||
processedData.totalGap += entry.gap;
|
|
||||||
|
|
||||||
if (!processedData.isPast && !processedData.isLoaded) {
|
if (!processedData.isPast && !processedData.isLoaded) {
|
||||||
/**
|
/**
|
||||||
* isLinkToLoaded is a chain value that we maintain until we
|
* isLinkToLoaded is a chain value that we maintain until we
|
||||||
* a) find an unlinked event
|
* a) find an unlinked event
|
||||||
* b) find a countToEnd event
|
* b) find a countToEnd event
|
||||||
*/
|
*/
|
||||||
|
processedData.totalGap += entry.gap;
|
||||||
processedData.isLinkedToLoaded =
|
processedData.isLinkedToLoaded =
|
||||||
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { MaybeString } from 'ontime-types';
|
|
||||||
|
|
||||||
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
|
||||||
if (!value) return defaultValue;
|
|
||||||
const number = Number(value);
|
|
||||||
if (isNaN(number)) return defaultValue;
|
|
||||||
return number;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
ApiAction,
|
ApiActionTag,
|
||||||
Log,
|
Log,
|
||||||
MessageTag,
|
MessageTag,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
@@ -199,7 +199,7 @@ export const connectSocket = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sendSocket<T extends MessageTag | ApiAction>(
|
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||||
tag: T,
|
tag: T,
|
||||||
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
|
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { APP_SETTINGS } from '../api/constants';
|
|||||||
import { useExpectedStartData } from '../hooks/useSocket';
|
import { useExpectedStartData } from '../hooks/useSocket';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
|
|
||||||
|
import { ExtendedEntry } from './rundownMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns current time in milliseconds from midnight
|
* Returns current time in milliseconds from midnight
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
@@ -110,11 +112,12 @@ export const formatTime = (
|
|||||||
export function formatDuration(duration: number, hideSeconds = true): string {
|
export function formatDuration(duration: number, hideSeconds = true): string {
|
||||||
// durations should never be negative, we handle it here to flag if there is an issue in future
|
// durations should never be negative, we handle it here to flag if there is an issue in future
|
||||||
if (duration <= 0) {
|
if (duration <= 0) {
|
||||||
return '0h 0m';
|
return '0m';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
||||||
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
||||||
|
|
||||||
let result = '';
|
let result = '';
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
result += `${hours}h`;
|
result += `${hours}h`;
|
||||||
@@ -124,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hideSeconds) {
|
if (!hideSeconds) {
|
||||||
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
const remainingMs = duration % MILLIS_PER_MINUTE;
|
||||||
|
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
|
||||||
|
// cap at 59 to avoid showing 60s
|
||||||
|
const seconds = Math.min(59, Math.ceil(exactSeconds));
|
||||||
|
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
result += `${seconds}s`;
|
result += `${seconds}s`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,3 +162,33 @@ export function useTimeUntilExpectedStart(
|
|||||||
);
|
);
|
||||||
return expectedStart - clock;
|
return expectedStart - clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExpectedTimesFromExtendedEvent(
|
||||||
|
event: Pick<
|
||||||
|
ExtendedEntry<OntimeEvent>,
|
||||||
|
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
|
||||||
|
> | null,
|
||||||
|
state: ReturnType<typeof useExpectedStartData>,
|
||||||
|
) {
|
||||||
|
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||||
|
|
||||||
|
const expectedStart = getExpectedStart(
|
||||||
|
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||||
|
{
|
||||||
|
totalGap: event.totalGap,
|
||||||
|
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||||
|
...state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
|
|
||||||
|
return {
|
||||||
|
expectedStart,
|
||||||
|
timeToStart: expectedStart - state.clock,
|
||||||
|
expectedEnd: event.countToEnd
|
||||||
|
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||||
|
: expectedStart + event.duration,
|
||||||
|
plannedEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function validateProjectFile(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Limit file size of a project file to around 1MB
|
// Limit file size of a project file to around 1MB
|
||||||
if (file.size > 1_000_000) {
|
if (file.size > 2_000_000) {
|
||||||
throw new Error('File size limit (1MB) exceeded');
|
throw new Error('File size limit (2MB) exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +56,8 @@ export function validateLogo(file: File) {
|
|||||||
throw new Error('File is empty');
|
throw new Error('File is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit file size of a project file to around 1MB
|
// Limit file size of a project file to around 1.5MB
|
||||||
if (file.size > 1_000_000) {
|
if (file.size > 1_500_000) {
|
||||||
throw new Error('File size limit (1MB) exceeded');
|
throw new Error('File size limit (1.5MB) exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ declare global {
|
|||||||
process: {
|
process: {
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
// Experimental browser feature
|
||||||
|
documentPictureInPicture: {
|
||||||
|
requestWindow: () => Promise<Window>;
|
||||||
|
window: Window;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
|||||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
|
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
@@ -17,12 +18,15 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
|||||||
|
|
||||||
// resolve environment
|
// resolve environment
|
||||||
export const appVersion = version;
|
export const appVersion = version;
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isDocker = import.meta.env.IS_DOCKER; // this env is made available by the vite.config.js define function
|
||||||
export const isDev = !isProduction;
|
export const isProduction = import.meta.env.PROD;
|
||||||
|
export const isDev = import.meta.env.DEV;
|
||||||
export const currentHostName = window.location.hostname;
|
export const currentHostName = window.location.hostname;
|
||||||
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
|
||||||
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
|
||||||
|
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
export const supportsFullscreen = document.fullscreenEnabled;
|
||||||
|
|
||||||
// resolve entrypoint URLs
|
// resolve entrypoint URLs
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/* eslint-disable react/display-name */
|
|
||||||
import { ComponentType, useEffect } from 'react';
|
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import useUrlPresets from '../common/hooks-query/useUrlPresets';
|
|
||||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
|
||||||
|
|
||||||
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
|
||||||
return (props: Partial<P>) => {
|
|
||||||
const { data } = useUrlPresets();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// navigate if is alias route
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data) return;
|
|
||||||
const destination = getRouteFromPreset(location, data);
|
|
||||||
|
|
||||||
// navigate to this destination if its not null
|
|
||||||
if (destination) {
|
|
||||||
navigate(destination);
|
|
||||||
}
|
|
||||||
}, [data, navigate, location]);
|
|
||||||
|
|
||||||
return <Component {...(props as P)} />;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withPreset;
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
documentationUrl,
|
documentationUrl,
|
||||||
githubSponsorUrl,
|
githubSponsorUrl,
|
||||||
githubUrl,
|
githubUrl,
|
||||||
|
subredditUrl,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
} from '../../../../externals';
|
} from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
@@ -38,6 +39,7 @@ export default function AboutPanel() {
|
|||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
|
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function AppVersion() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`You are currently using Ontime version ${appVersion}`}
|
{`You are currently using Ontime version ${appVersion}`}
|
||||||
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
<Panel.Error>Could not fetch version information</Panel.Error>
|
||||||
</Panel.Paragraph>
|
</Panel.Paragraph>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,16 +107,16 @@ export default function AutomationsList(props: AutomationsListProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</tr>
|
</tr>
|
||||||
{deleteError && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5}>
|
|
||||||
<Panel.Error>{deleteError}</Panel.Error>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{deleteError && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<Panel.Error>{deleteError}</Panel.Error>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Panel.Table>
|
</Panel.Table>
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
|
|||||||
New time
|
New time
|
||||||
<Input
|
<Input
|
||||||
{...register(`outputs.${index}.time`, {
|
{...register(`outputs.${index}.time`, {
|
||||||
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
|
required: { value: true, message: 'Required field' },
|
||||||
})}
|
})}
|
||||||
fluid
|
fluid
|
||||||
placeholder='eg: 10m5s'
|
placeholder='eg: 10m5s'
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
|||||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm<URLPreset>({
|
} = useForm<URLPreset>({
|
||||||
defaultValues: urlPreset ?? defaultValues,
|
defaultValues: urlPreset ?? defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Alias</Panel.Description>
|
<Panel.Description>Alias</Panel.Description>
|
||||||
<Input {...register('alias', { required: 'Alias is required' })} />
|
<Input
|
||||||
|
{...register('alias', {
|
||||||
|
required: 'Alias is required',
|
||||||
|
pattern: {
|
||||||
|
value: isUrlSafe,
|
||||||
|
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.expand}>
|
<div className={style.expand}>
|
||||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<div> - or -</div>
|
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||||
|
<div>
|
||||||
|
{enDash} or {enDash}
|
||||||
|
</div>
|
||||||
<div>2. Choose a view and its parameters</div>
|
<div>2. Choose a view and its parameters</div>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Target</Panel.Description>
|
<Panel.Description>Target</Panel.Description>
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current {
|
.current {
|
||||||
background-color: $blue-1100;
|
background-color: $blue-1100 !important; // fighting zebra styles
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import {
|
||||||
|
IoAdd,
|
||||||
|
IoDocumentOutline,
|
||||||
|
IoDownloadOutline,
|
||||||
|
IoDuplicateOutline,
|
||||||
|
IoEllipsisHorizontal,
|
||||||
|
IoPencilOutline,
|
||||||
|
IoTrash,
|
||||||
|
} from 'react-icons/io5';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import RundownRenameForm from './composite/RundownRenameForm';
|
||||||
import { ManageRundownForm } from './ManageRundownForm';
|
import { ManageRundownForm } from './ManageRundownForm';
|
||||||
|
|
||||||
import style from './ManagePanel.module.scss';
|
import style from './ManagePanel.module.scss';
|
||||||
|
|
||||||
export default function ManageRundowns() {
|
export default function ManageRundowns() {
|
||||||
const { data } = useProjectRundowns();
|
const { data } = useProjectRundowns();
|
||||||
const { remove, load } = useMutateProjectRundowns();
|
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||||
const [isOpenLoad, loadHandlers] = useDisclosure();
|
const [isOpenLoad, loadHandlers] = useDisclosure();
|
||||||
const [isNewLoad, newHandlers] = useDisclosure();
|
const [isNewLoad, newHandlers] = useDisclosure();
|
||||||
const [targetRundown, setTargetRundown] = useState('');
|
const [targetRundown, setTargetRundown] = useState('');
|
||||||
|
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const openLoad = (id: string) => {
|
const openLoad = (id: string) => {
|
||||||
@@ -35,6 +48,11 @@ export default function ManageRundowns() {
|
|||||||
deleteHandlers.open();
|
deleteHandlers.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRename = (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setRenamingRundown(id);
|
||||||
|
};
|
||||||
|
|
||||||
const submitRundownLoad = async () => {
|
const submitRundownLoad = async () => {
|
||||||
try {
|
try {
|
||||||
await load(targetRundown);
|
await load(targetRundown);
|
||||||
@@ -45,6 +63,27 @@ export default function ManageRundowns() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitRundownDuplicate = async (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setRenamingRundown(null);
|
||||||
|
setTargetRundown('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await duplicate(id);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRundownRename = async (id: string, newTitle: string) => {
|
||||||
|
try {
|
||||||
|
await rename([id, newTitle]);
|
||||||
|
setRenamingRundown(null);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitRundownDelete = async () => {
|
const submitRundownDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await remove(targetRundown);
|
await remove(targetRundown);
|
||||||
@@ -55,6 +94,10 @@ export default function ManageRundowns() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadXlsx = async (rundownId: string, title: string) => {
|
||||||
|
await downloadAsExcel(rundownId, title);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -87,25 +130,70 @@ export default function ManageRundowns() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||||
const isLoaded = data.loaded === id;
|
const isLoaded = data.loaded === id;
|
||||||
|
const isRenaming = renamingRundown === id;
|
||||||
|
|
||||||
|
if (isRenaming) {
|
||||||
|
return (
|
||||||
|
<tr key={id}>
|
||||||
|
<td colSpan={3}>
|
||||||
|
<RundownRenameForm
|
||||||
|
onCancel={() => setRenamingRundown(null)}
|
||||||
|
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
|
||||||
|
initialTitle={title}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||||
<td>{numEntries}</td>
|
<td>{numEntries}</td>
|
||||||
<td>
|
<td>
|
||||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements as='td'>
|
<td>
|
||||||
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
|
<DropdownMenu
|
||||||
Load
|
render={<IconButton variant='ghosted-white' />}
|
||||||
</Button>
|
items={[
|
||||||
<Button
|
{
|
||||||
size='small'
|
type: 'item',
|
||||||
variant='subtle-destructive'
|
icon: IoPencilOutline,
|
||||||
onClick={() => openDelete(id)}
|
label: 'Rename',
|
||||||
disabled={isLoaded}
|
onClick: () => openRename(id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDownloadOutline,
|
||||||
|
label: 'Load',
|
||||||
|
onClick: () => openLoad(id),
|
||||||
|
disabled: isLoaded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDocumentOutline,
|
||||||
|
label: 'Download .xlsx',
|
||||||
|
onClick: () => handleDownloadXlsx(id, title),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDuplicateOutline,
|
||||||
|
label: 'Duplicate',
|
||||||
|
onClick: () => submitRundownDuplicate(id),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'destructive',
|
||||||
|
icon: IoTrash,
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: () => openDelete(id),
|
||||||
|
disabled: isLoaded,
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
Delete
|
<IoEllipsisHorizontal />
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
</Panel.InlineElements>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -117,7 +205,7 @@ export default function ManageRundowns() {
|
|||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isOpenDelete}
|
isOpen={isOpenDelete}
|
||||||
onClose={deleteHandlers.close}
|
onClose={deleteHandlers.close}
|
||||||
title='Load rundown'
|
title='Delete rundown'
|
||||||
showBackdrop
|
showBackdrop
|
||||||
showCloseButton
|
showCloseButton
|
||||||
bodyElements={
|
bodyElements={
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { checkRegex } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
|
import Input from '../../../../../common/components/input/input/Input';
|
||||||
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
interface RundownRenameFormProps {
|
||||||
|
onSubmit: (newTitle: string) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
setFocus,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
defaultValues: { title: initialTitle },
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSubmit = async (values: FormData) => {
|
||||||
|
try {
|
||||||
|
await onSubmit(values.title);
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { type: 'custom', message: maybeAxiosError(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Give initial focus to the title input
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus('title');
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
|
const canSubmit = isDirty && isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||||
|
<label>
|
||||||
|
<Panel.Description>Rundown title</Panel.Description>
|
||||||
|
<Input
|
||||||
|
{...register('title', {
|
||||||
|
required: { value: true, message: 'Title is required' },
|
||||||
|
validate: (value) => {
|
||||||
|
if (value.trim().length === 0) return 'Title cannot be empty';
|
||||||
|
if (checkRegex.isAlphanumericWithSpace(value) === false)
|
||||||
|
return 'Title can only contain alphanumeric characters, spaces and underscores';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
|
||||||
|
</label>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
|
<Button variant='ghosted' onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.Indent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CustomFields, Rundown } from 'ontime-types';
|
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
|
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||||
|
import { formatDuration } from '../../../../../common/utils/time';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||||
@@ -11,13 +14,21 @@ import { useSheetStore } from './useSheetStore';
|
|||||||
interface ImportReviewProps {
|
interface ImportReviewProps {
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
onFinished: () => void;
|
onFinished: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportReview(props: ImportReviewProps) {
|
export default function ImportReview({
|
||||||
const { rundown, customFields, onFinished, onCancel } = props;
|
rundown,
|
||||||
|
customFields,
|
||||||
|
summary,
|
||||||
|
onFinished,
|
||||||
|
onCancel,
|
||||||
|
onBack,
|
||||||
|
}: ImportReviewProps) {
|
||||||
|
const { data: currentRundown } = useRundown();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { importRundown } = useGoogleSheet();
|
const { importRundown } = useGoogleSheet();
|
||||||
const resetPreview = useSheetStore((state) => state.resetPreview);
|
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||||
@@ -29,9 +40,12 @@ export default function ImportReview(props: ImportReviewProps) {
|
|||||||
|
|
||||||
const applyImport = async () => {
|
const applyImport = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// we need to import on-top of the currently loaded rundown
|
||||||
|
// so the id needs to match
|
||||||
await importRundown(
|
await importRundown(
|
||||||
{
|
{
|
||||||
[rundown.id]: rundown,
|
[currentRundown.id]: { ...rundown, id: currentRundown.id, title: currentRundown.title },
|
||||||
},
|
},
|
||||||
customFields,
|
customFields,
|
||||||
);
|
);
|
||||||
@@ -47,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
|
|||||||
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={onBack} variant='subtle' disabled={loading}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
<Button onClick={applyImport} variant='primary' loading={loading}>
|
<Button onClick={applyImport} variant='primary' loading={loading}>
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Title</b> {rundown.title}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Number of entries</b> {rundown.flatOrder.length}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Start time</b> {millisToString(summary.start)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>End time</b> {millisToString(summary.end)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Total duration</b> {formatDuration(summary.duration)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
|||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorksheetNames as getWorksheetNamesExcel,
|
|
||||||
importRundownPreview as importRundownPreviewExcel,
|
importRundownPreview as importRundownPreviewExcel,
|
||||||
upload as uploadExcel,
|
upload as uploadExcel,
|
||||||
} from '../../../../../common/api/excel';
|
} from '../../../../../common/api/excel';
|
||||||
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
|
|||||||
const setRundown = useSheetStore((state) => state.setRundown);
|
const setRundown = useSheetStore((state) => state.setRundown);
|
||||||
const customFields = useSheetStore((state) => state.customFields);
|
const customFields = useSheetStore((state) => state.customFields);
|
||||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||||
|
const summary = useSheetStore((state) => state.summary);
|
||||||
|
const setSummary = useSheetStore((state) => state.setSummary);
|
||||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||||
const sheetId = useSheetStore((state) => state.sheetId);
|
const sheetId = useSheetStore((state) => state.sheetId);
|
||||||
|
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
|
|||||||
try {
|
try {
|
||||||
setHasFile('loading');
|
setHasFile('loading');
|
||||||
validateExcelImport(fileToUpload);
|
validateExcelImport(fileToUpload);
|
||||||
await uploadExcel(fileToUpload);
|
const names = await uploadExcel(fileToUpload);
|
||||||
const names = await getWorksheetNamesExcel();
|
|
||||||
setWorksheets(names);
|
setWorksheets(names);
|
||||||
setImportFlow('excel');
|
setImportFlow('excel');
|
||||||
setHasFile('done');
|
setHasFile('done');
|
||||||
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
|
|||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setWorksheets(null);
|
setWorksheets(null);
|
||||||
setCustomFields(null);
|
setCustomFields(null);
|
||||||
|
setSummary(null);
|
||||||
setError('');
|
setError('');
|
||||||
setSheetId(null);
|
setSheetId(null);
|
||||||
};
|
};
|
||||||
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
|
|||||||
const previewData = await importRundownPreviewExcel(importMap);
|
const previewData = await importRundownPreviewExcel(importMap);
|
||||||
setRundown(previewData.rundown);
|
setRundown(previewData.rundown);
|
||||||
setCustomFields(previewData.customFields);
|
setCustomFields(previewData.customFields);
|
||||||
|
setSummary(previewData.summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(maybeAxiosError(error));
|
setError(maybeAxiosError(error));
|
||||||
}
|
}
|
||||||
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
|
|||||||
const showCompleted = importFlow === 'finished';
|
const showCompleted = importFlow === 'finished';
|
||||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||||
const showReview = rundown !== null && customFields !== null;
|
const showReview = rundown !== null && customFields !== null && summary !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
|
|||||||
<ImportReview
|
<ImportReview
|
||||||
rundown={rundown}
|
rundown={rundown}
|
||||||
customFields={customFields}
|
customFields={customFields}
|
||||||
|
summary={summary}
|
||||||
onFinished={handleFinished}
|
onFinished={handleFinished}
|
||||||
onCancel={cancelImportMap}
|
onCancel={cancelImportMap}
|
||||||
|
onBack={resetPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
|
|||||||
|
|
||||||
import Button from '../../../../../../common/components/buttons/Button';
|
import Button from '../../../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||||
|
import Info from '../../../../../../common/components/info/Info';
|
||||||
import Input from '../../../../../../common/components/input/input/Input';
|
import Input from '../../../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../../../common/components/select/Select';
|
import Select from '../../../../../../common/components/select/Select';
|
||||||
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
|
||||||
@@ -136,6 +137,10 @@ export default function ImportMapForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
|
<Info>
|
||||||
|
Match your spreadsheet columns to Ontime fields. <br />
|
||||||
|
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
|
||||||
|
</Info>
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
timeWarning: namedImportMap['Time warning'],
|
timeWarning: namedImportMap['Time warning'],
|
||||||
timeDanger: namedImportMap['Time danger'],
|
timeDanger: namedImportMap['Time danger'],
|
||||||
custom,
|
custom,
|
||||||
entryId: namedImportMap.ID,
|
id: namedImportMap.ID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
|
|||||||
return value ? 'Yes' : undefined;
|
return value ? 'Yes' : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PreviewRundown(props: PreviewRundownProps) {
|
export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
|
||||||
const { rundown, customFields } = props;
|
|
||||||
|
|
||||||
// we only count Ontime Events which are 1 based in client
|
// we only count Ontime Events which are 1 based in client
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
|
|
||||||
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td className={style.center}>
|
<td /> {/** Index */}
|
||||||
<Tag>-</Tag>
|
|
||||||
</td>
|
|
||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
<Tag>{entry.type}</Tag>
|
<Tag>{entry.type}</Tag>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
|
|||||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||||
const setRundown = useSheetStore((state) => state.setRundown);
|
const setRundown = useSheetStore((state) => state.setRundown);
|
||||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||||
|
const setSummary = useSheetStore((state) => state.setSummary);
|
||||||
|
|
||||||
/** whether the current session has been authenticated */
|
/** whether the current session has been authenticated */
|
||||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
||||||
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
|
|||||||
const data = await previewRundown(sheetId, fileOptions);
|
const data = await previewRundown(sheetId, fileOptions);
|
||||||
setRundown(data.rundown);
|
setRundown(data.rundown);
|
||||||
setCustomFields(data.customFields);
|
setCustomFields(data.customFields);
|
||||||
|
setSummary(data.summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
|
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ type SheetStore = {
|
|||||||
// we get this from a preview response
|
// we get this from a preview response
|
||||||
rundown: Rundown | null;
|
rundown: Rundown | null;
|
||||||
setRundown: (rundown: Rundown | null) => void;
|
setRundown: (rundown: Rundown | null) => void;
|
||||||
|
|
||||||
// we get this from a preview response
|
|
||||||
customFields: CustomFields | null;
|
customFields: CustomFields | null;
|
||||||
setCustomFields: (customFields: CustomFields | null) => void;
|
setCustomFields: (customFields: CustomFields | null) => void;
|
||||||
|
summary: RundownSummary | null;
|
||||||
|
setSummary: (metadata: RundownSummary | null) => void;
|
||||||
|
|
||||||
spreadsheetImportMap: ImportMap;
|
spreadsheetImportMap: ImportMap;
|
||||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
||||||
@@ -43,6 +43,7 @@ const initialState = {
|
|||||||
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||||
rundown: null,
|
rundown: null,
|
||||||
customFields: null,
|
customFields: null,
|
||||||
|
summary: null,
|
||||||
spreadsheetImportMap: defaultImportMap,
|
spreadsheetImportMap: defaultImportMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
|||||||
|
|
||||||
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
||||||
|
|
||||||
|
setSummary: (summary: RundownSummary | null) => set({ summary }),
|
||||||
|
|
||||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
||||||
const currentImportMap = get().spreadsheetImportMap;
|
const currentImportMap = get().spreadsheetImportMap;
|
||||||
if (currentImportMap[field] !== value) {
|
if (currentImportMap[field] !== value) {
|
||||||
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
resetPreview: () => set({ rundown: null, customFields: null }),
|
resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MessageTag } from 'ontime-types';
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { usePing } from '../../../../common/hooks/useSocket';
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
import { sendSocket } from '../../../../common/utils/socket';
|
import { sendSocket } from '../../../../common/utils/socket';
|
||||||
import { isDockerImage } from '../../../../externals';
|
import { isDocker } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
{isDockerImage && (
|
{isDocker && (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<OntimeCloudStats />
|
<OntimeCloudStats />
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||||
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { ProjectSortMode, useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ProjectList() {
|
type SortParameter = 'alphabetical' | 'modified';
|
||||||
const { data, refetch, status } = useOrderedProjectList();
|
|
||||||
|
|
||||||
|
export default function ProjectList() {
|
||||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
|
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
||||||
|
|
||||||
|
const { data, refetch, status } = useOrderedProjectList(sortMode);
|
||||||
|
|
||||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||||
@@ -28,6 +32,13 @@ export default function ProjectList() {
|
|||||||
await refetch();
|
await refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (sortParameter: SortParameter) => {
|
||||||
|
setSortMode((current) => {
|
||||||
|
const isAscending = current === `${sortParameter}-asc`;
|
||||||
|
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className={style.empty}>
|
<div className={style.empty}>
|
||||||
@@ -48,8 +59,18 @@ export default function ProjectList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.containCell}>File Name</th>
|
<th className={style.containCell} onClick={() => handleSort('alphabetical')}>
|
||||||
<th>Last Used</th>
|
<span className={style.sortableHeader}>
|
||||||
|
File Name
|
||||||
|
<SortIcon sortMode={sortMode} type='alphabetical' />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th onClick={() => handleSort('modified')}>
|
||||||
|
<span className={style.sortableHeader}>
|
||||||
|
Last Used
|
||||||
|
<SortIcon sortMode={sortMode} type='modified' />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -72,3 +93,10 @@ export default function ProjectList() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortIcon({ sortMode, type }: { sortMode: ProjectSortMode; type: SortParameter }) {
|
||||||
|
const prefix = `${type}-`;
|
||||||
|
if (sortMode === `${prefix}asc`) return <IoArrowDown />;
|
||||||
|
if (sortMode === `${prefix}desc`) return <IoArrowUp />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
deleteProject,
|
deleteProject,
|
||||||
downloadCSV,
|
|
||||||
downloadProject,
|
downloadProject,
|
||||||
duplicateProject,
|
duplicateProject,
|
||||||
loadProject,
|
loadProject,
|
||||||
@@ -187,10 +186,6 @@ function ActionMenu(props: ActionMenuProps) {
|
|||||||
await downloadProject(filename);
|
await downloadProject(filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportCSV = async () => {
|
|
||||||
await downloadCSV(filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
render={<IconButton variant='ghosted-white' />}
|
render={<IconButton variant='ghosted-white' />}
|
||||||
@@ -213,7 +208,6 @@ function ActionMenu(props: ActionMenuProps) {
|
|||||||
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
|
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
|
||||||
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
|
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
|
||||||
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
|
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
|
||||||
{ type: 'item', icon: IoDocumentOutline, label: 'Export CSV Rundown', onClick: handleExportCSV },
|
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
|
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.containCell {
|
.containCell {
|
||||||
max-width: 400px;
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableHeader {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
|
|||||||
import { postSettings } from '../../../../common/api/settings';
|
import { postSettings } from '../../../../common/api/settings';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||||
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
|
|||||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<Panel.Loader isLoading={isLoading} />
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Time format'
|
title='Time format'
|
||||||
description='Default time format to show in views 12 /24 hours'
|
description='Default time format to show in views 12 / 24 hours'
|
||||||
error={errors.timeFormat?.message}
|
error={errors.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function ProjectData() {
|
|||||||
reset,
|
reset,
|
||||||
formState: { isSubmitting, isValid, isDirty, errors },
|
formState: { isSubmitting, isValid, isDirty, errors },
|
||||||
setError,
|
setError,
|
||||||
|
clearErrors,
|
||||||
watch,
|
watch,
|
||||||
control,
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
@@ -53,6 +54,7 @@ export default function ProjectData() {
|
|||||||
|
|
||||||
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
|
clearErrors('logo');
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
@@ -93,6 +95,7 @@ export default function ProjectData() {
|
|||||||
|
|
||||||
const onSubmit = async (formData: ProjectData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
|
clearErrors();
|
||||||
await updateProjectData(formData);
|
await updateProjectData(formData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = maybeAxiosError(error);
|
const message = maybeAxiosError(error);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Time format'
|
title='Time format'
|
||||||
description='Default time format to show in views 12 /24 hours'
|
description='Default time format to show in views 12 / 24 hours (does not affect editor)'
|
||||||
error={errors.settings?.timeFormat?.message}
|
error={errors.settings?.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const staticOptions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manage',
|
id: 'manage',
|
||||||
label: 'Project data',
|
label: 'Project settings',
|
||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'manage__defaults', label: 'Rundown defaults' },
|
{ id: 'manage__defaults', label: 'Rundown defaults' },
|
||||||
{ id: 'manage__custom', label: 'Custom fields' },
|
{ id: 'manage__custom', label: 'Custom fields' },
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { getIsNavigationLocked } from '../../../externals';
|
||||||
|
|
||||||
import MessageControl from './MessageControl';
|
import MessageControl from './MessageControl';
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ function MessageControlExport() {
|
|||||||
return (
|
return (
|
||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<div className={style.messages} data-testid='panel-messages-control'>
|
<div className={style.messages} data-testid='panel-messages-control'>
|
||||||
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
|
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
|
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
|||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
|
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||||
|
|
||||||
import style from './MessageControl.module.scss';
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export default function TimerPreview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.preview}>
|
<div className={style.preview}>
|
||||||
<Corner onClick={(event) => handleLinks('timer', event)} />
|
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||||
<div className={contentClasses}>
|
<div className={contentClasses}>
|
||||||
<div
|
<div
|
||||||
className={style.mainContent}
|
className={style.mainContent}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
|
import { getIsNavigationLocked } from '../../../externals';
|
||||||
|
|
||||||
import PlaybackControl from './PlaybackControl';
|
import PlaybackControl from './PlaybackControl';
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ function TimerControlExport() {
|
|||||||
return (
|
return (
|
||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<div className={style.playback} data-testid='panel-timer-control'>
|
<div className={style.playback} data-testid='panel-timer-control'>
|
||||||
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
|
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
|
|
||||||
<div className={style.content}>
|
<div className={style.content}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||