Compare commits
387 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54534012bd | |||
| 1a23287863 | |||
| 0b3ba65df3 | |||
| c3a7cd6a39 | |||
| 39e6c15adf | |||
| aec6673b07 | |||
| 9e8cb1c755 | |||
| cdf02f7c3f | |||
| 77779a4648 | |||
| e16ec3f388 | |||
| 63cfc9ec19 | |||
| 4827117163 | |||
| 5777ef3532 | |||
| 80a3b04493 | |||
| 7c5fdd3b19 | |||
| 31fd8e5200 | |||
| b6a01a52e2 | |||
| f9563fca5f | |||
| af1da58718 | |||
| 0f83ceaf01 | |||
| aecf9575b1 | |||
| 773229ce4c | |||
| da02ecd113 | |||
| 967e92e2c8 | |||
| d7af73d9ed | |||
| c5045ad82e | |||
| 8c22968bc9 | |||
| 8aad271ffe | |||
| b28573455a | |||
| e25725ea8b | |||
| 1f8065143a | |||
| 8e32314667 | |||
| 0de55e74a8 | |||
| e5c5ec18d3 | |||
| f9f6cb74ad | |||
| 026e24bf84 | |||
| ec1890c275 | |||
| 046f1176eb | |||
| 04c5b67475 | |||
| 7b5f797c95 | |||
| 14f9472250 | |||
| efede83271 | |||
| 904db95f59 | |||
| 525999440a | |||
| 6e5805977a | |||
| eb897a2a23 | |||
| 05d997a52d | |||
| f8cde5d627 | |||
| 1b55254834 | |||
| 6436e3fda8 | |||
| 680273770c | |||
| 75e77de466 | |||
| 75cbcd8e85 | |||
| 701f24cece | |||
| 166160dda9 | |||
| 9c5116f197 | |||
| deb1f043c7 | |||
| cbba19a9b1 | |||
| a5daa76616 | |||
| 08b8e73393 | |||
| 2f3cf9825a | |||
| b0f02e726d | |||
| ac40292813 | |||
| 2ac00ee8cc | |||
| 972f9e35fd | |||
| 1d7ee163a0 | |||
| 1054ec0a5e | |||
| fc2e76db0a | |||
| ef3d541eb3 | |||
| 8daf3313f2 | |||
| 93c913ec5a | |||
| 71b2fa8e21 | |||
| f2b78e0f32 | |||
| b70ef80320 | |||
| 426d42ab63 | |||
| 242cefd35c | |||
| 6beda1575a | |||
| ba75d52d29 | |||
| 35a4361ae9 | |||
| 77225383a4 | |||
| f0e81c0cf0 | |||
| 9355d7bf27 | |||
| cd39227af7 | |||
| 14e21214af | |||
| 03184b0321 | |||
| cd5af769ab | |||
| d116670318 | |||
| 9ba5ebd4bd | |||
| 3f1f06f7c5 | |||
| a78586d2fa | |||
| 8e93b0af25 | |||
| 9a5d50d933 | |||
| 1189a94bff | |||
| f128f7acd2 | |||
| 80ec2d1186 | |||
| 18e6f0d7c4 | |||
| 48f2511764 | |||
| 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 | |||
| 0057b4a3f1 | |||
| ac8c1e333e | |||
| 321297b685 | |||
| 67e67d6a5d | |||
| 4385e0b9c9 | |||
| 02243242f1 | |||
| 1af42b766d | |||
| 9d6ec3758b | |||
| 9e78ff4d1c | |||
| 2037aef080 | |||
| 48b9cf042f | |||
| 1866e5e78d | |||
| bc1b3800c8 | |||
| a2505c838b | |||
| bffa863fb4 | |||
| d00d7677bc | |||
| 3ae73be562 | |||
| 763080cd4d | |||
| 6ff06b21a8 | |||
| adebde1b43 | |||
| 89922fe394 | |||
| f219ebb8f8 | |||
| eace97ec7a | |||
| ec1df8b958 | |||
| 8d2d8ef979 | |||
| ae15f3cdc5 | |||
| 3c41b40c5f | |||
| 3b38756dda | |||
| 5a4494ba85 | |||
| 02ad852dd4 | |||
| 7ce1097826 | |||
| 7ee0de5341 | |||
| 0db249871b | |||
| 87df656470 | |||
| 54f07c4330 | |||
| ae061cb69a | |||
| ba529def34 | |||
| 210a85c463 | |||
| 3ad3ad088f | |||
| 617da68ab5 | |||
| efceb930e5 | |||
| 836fa52757 | |||
| 902edf4a85 | |||
| 0e770655dc | |||
| db922a67e2 | |||
| 048a9c96ea | |||
| ed93cf313a | |||
| 97a0e17192 | |||
| 022a58e142 | |||
| e0149c1cbf | |||
| 4665f9c2f7 | |||
| 9ab6b30aea | |||
| e5d2457717 | |||
| 486d89ecf4 | |||
| 94b1f63c7c | |||
| 0299463f62 | |||
| f7ccb6bb51 | |||
| 6c6f5c2c0f | |||
| 1695b4dc68 | |||
| 1ed08df198 | |||
| c300fc18e1 | |||
| bea7e2bb2b | |||
| ad7f658f15 | |||
| f23adfa45c | |||
| 20ee391525 | |||
| 78184b23b7 | |||
| e204f671be | |||
| 8c90f5043f | |||
| 7a322406da | |||
| 307f0ffd34 | |||
| 5f8a5c4995 | |||
| 3747dbc1a8 | |||
| 20859abda9 | |||
| 9ebb3decdb | |||
| 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
|
||||||
@@ -38,16 +43,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy server
|
- name: Copy server
|
||||||
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||||
|
|
||||||
- name: Copy client
|
- name: Copy client
|
||||||
run: cp -R apps/client/build apps/cli/client
|
run: cp -R apps/client/build apps/cli/client
|
||||||
|
|
||||||
- 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,62 @@ 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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Setup Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
|
- name: Setup Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
|
with:
|
||||||
|
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
|
||||||
|
|
||||||
|
- name: Build and push stable release
|
||||||
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -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
|
||||||
@@ -30,7 +35,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Electron - Build app
|
- name: Electron - Build app
|
||||||
env:
|
env:
|
||||||
APPLE_ID: ${{ secrets.APPLEID }}
|
APPLE_ID: ${{ secrets.APPLEID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||||
@@ -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
|
||||||
@@ -110,6 +103,9 @@ jobs:
|
|||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: './apps/electron/dist/ontime-linux.AppImage'
|
files: |
|
||||||
|
./apps/electron/dist/ontime-linux-x86_64.AppImage
|
||||||
|
./apps/electron/dist/ontime-linux-arm64.AppImage
|
||||||
|
./apps/electron/dist/ontime-linux-armv7l.AppImage
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ e2e/tests/fixtures/tmp/*
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# bundled assets
|
||||||
|
translations.json
|
||||||
|
override.css
|
||||||
|
|
||||||
# working stuff
|
# working stuff
|
||||||
**/TODO.md
|
**/TODO.md
|
||||||
|
**.local.**
|
||||||
|
|
||||||
# docker utils
|
# docker utils
|
||||||
ontime-db
|
ontime-db
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,19 +20,21 @@ development.
|
|||||||
Locally, we would need to run both the React client and the node.js server in development mode
|
Locally, we would need to run both the React client and the node.js server in development mode
|
||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Run dev mode__ by running `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
|
||||||
|
|
||||||
To debug backend code in Node.js:
|
The previous command will start the development servers for both the client, server and electron applications.
|
||||||
|
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
|
||||||
|
|
||||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
We do that by creating two terminals an running
|
||||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
|
||||||
applications.
|
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
|
||||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
|
||||||
running `pnpm dev:inspect`.
|
- 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
|
||||||
|
|
||||||
@@ -45,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 `turbo run 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
|
||||||
@@ -65,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)
|
||||||
@@ -76,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 `turbo run build:electron`
|
- __Build the UI and server__ by running `pnpm build`
|
||||||
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `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 `turbo run dist-mac:local` command to build a MacOS distribution locally.
|
|
||||||
|
|
||||||
## 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,9 +98,15 @@ 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>`
|
||||||
|
|
||||||
## General Info
|
## CONTRIBUTION GUIDELINES
|
||||||
|
|
||||||
# APP Building
|
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
|
||||||
|
|
||||||
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
|
For new PRs, please follow the following checklist:
|
||||||
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
|
* [ ] 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,37 +1,30 @@
|
|||||||
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
|
# Set environment variables
|
||||||
COPY . /app
|
# Environment Variable to signal that we are running production
|
||||||
WORKDIR /app
|
ENV NODE_ENV=docker
|
||||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
# Ontime Data path
|
||||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
ENV ONTIME_DATA=/data/
|
||||||
|
|
||||||
FROM node:22-alpine
|
RUN mkdir /app
|
||||||
|
WORKDIR /app/
|
||||||
# Set environment variables
|
|
||||||
# Environment Variable to signal that we are running production
|
# Prepare UI
|
||||||
ENV NODE_ENV=docker
|
COPY apps/client/build/ ./client/
|
||||||
# Ontime Data path
|
|
||||||
ENV ONTIME_DATA=/data/
|
# Prepare Backend
|
||||||
|
COPY apps/server/dist/ ./server/
|
||||||
WORKDIR /app/
|
COPY apps/server/src/external/ ./external/
|
||||||
|
COPY apps/server/src/user/ ./user/
|
||||||
# Prepare UI
|
COPY apps/server/src/html/ ./html/
|
||||||
COPY --from=builder /app/apps/client/build ./client/
|
|
||||||
|
# Export default ports
|
||||||
# Prepare Backend
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
CMD ["node", "server/docker.cjs"]
|
||||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
|
||||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
# Build and run commands
|
||||||
|
# pnpm build:docker
|
||||||
# Export default ports
|
# docker buildx build . -t getontime/ontime
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||||
|
|
||||||
CMD ["node", "server/docker.cjs"]
|
|
||||||
|
|
||||||
# Build and run commands
|
|
||||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
|
||||||
# docker buildx build . -t getontime/ontime
|
|
||||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
|
||||||
|
|||||||
@@ -10,13 +10,15 @@
|
|||||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
||||||
|
|
||||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
|
||||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
|
||||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
|
||||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
|
||||||
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
|
||||||
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
|
||||||
|
|
||||||
... or
|
... or
|
||||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||||
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
|
||||||
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||||
|
|
||||||
## Need help?
|
## Need help?
|
||||||
@@ -26,8 +28,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-alpha.2",
|
"version": "4.4.2",
|
||||||
"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,51 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-alpha.2",
|
"version": "4.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui-components/react": "1.0.0-beta.1",
|
"@base-ui/react": "1.0.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/is-prop-valid": "^1.3.1",
|
"@fontsource/open-sans": "^5.2.6",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@mantine/hooks": "^8.1.2",
|
"@sentry/react": "^10.2.0",
|
||||||
"@sentry/react": "^8.43.0",
|
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@tanstack/react-query-devtools": "^5.62.7",
|
"@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.9.0",
|
"axios": "^1.12.2",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"csv-stringify": "^6.6.0",
|
||||||
"csv-stringify": "^6.4.5",
|
"prismjs": "^1.30.0",
|
||||||
"prismjs": "^1.29.0",
|
"react": "^19.2.3",
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.2.3",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-icons": "5.4.0",
|
"react-icons": "5.5.0",
|
||||||
"react-qr-code": "^2.0.12",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router": "^7.11.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"web-vitals": "^3.1.1",
|
"react-virtuoso": "^4.17.0",
|
||||||
"zustand": "^5.0.3"
|
"web-vitals": "^5.1.0",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
"postinstall": "pnpm addversion",
|
"postinstall": "pnpm addversion",
|
||||||
"dev": "cross-env BROWSER=none vite",
|
"dev": "cross-env BROWSER=none vite",
|
||||||
"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": {
|
||||||
@@ -64,11 +60,12 @@
|
|||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "4.5.1",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-config-prettier": "catalog:",
|
"eslint-config-prettier": "catalog:",
|
||||||
"eslint-plugin-jest": "^28.6.0",
|
"eslint-plugin-jest": "^28.6.0",
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router';
|
||||||
import { Tooltip } from '@base-ui-components/react/tooltip';
|
import { Tooltip } from '@base-ui/react/tooltip';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
// apply global axios config defaults
|
||||||
|
import './common/api/axios.config';
|
||||||
|
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
|
import 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 +28,7 @@ function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
<IdentifyOverlay />
|
<IdentifyOverlay />
|
||||||
|
<KeepAwake />
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import React from 'react';
|
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||||
import { Navigate, Route } from 'react-router-dom';
|
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
|
||||||
|
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
|
||||||
|
import { PresetContext } from './common/context/PresetContext';
|
||||||
import { useClientPath } from './common/hooks/useClientPath';
|
import { useClientPath } from './common/hooks/useClientPath';
|
||||||
|
import useUrlPresets from './common/hooks-query/useUrlPresets';
|
||||||
|
import { getRouteFromPreset } from './common/utils/urlPresets';
|
||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import Loader from './views/common/loader/Loader';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import NotFound from './views/common/not-found/NotFound';
|
||||||
import ViewLoader from './views/ViewLoader';
|
import ViewLoader from './views/ViewLoader';
|
||||||
|
import { getIsNavigationLocked, sessionScope } from './externals';
|
||||||
import { initializeSentry } from './sentry.config';
|
import { initializeSentry } from './sentry.config';
|
||||||
|
|
||||||
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
|
const Timer = lazy(() => import('./views/timer/Timer'));
|
||||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
const Countdown = lazy(() => import('./views/countdown/Countdown'));
|
||||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
const Backstage = lazy(() => import('./views/backstage/Backstage'));
|
||||||
|
const StudioClock = lazy(() => import('./views/studio/Studio'));
|
||||||
|
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
|
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
|
|
||||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
|
||||||
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||||
|
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||||
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
|
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
const STimer = withPreset(withData(TimerView));
|
|
||||||
const SCountdown = withPreset(withData(Countdown));
|
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
|
||||||
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
|
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
|
||||||
const STimeline = withPreset(withData(Timeline));
|
|
||||||
const PCuesheet = withPreset(Cuesheet);
|
|
||||||
const POperator = withPreset(Operator);
|
|
||||||
|
|
||||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
|
||||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
|
||||||
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
|
||||||
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
|
||||||
|
|
||||||
// Initialize Sentry with our configuration
|
// Initialize Sentry with our configuration
|
||||||
const SentryRouter = initializeSentry();
|
const SentryRouter = initializeSentry();
|
||||||
@@ -42,73 +38,78 @@ export default function AppRouter() {
|
|||||||
useClientPath();
|
useClientPath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={null}>
|
<Suspense fallback={<Loader />}>
|
||||||
<SentryRouter>
|
<SentryRouter>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route
|
<Route
|
||||||
path='/timer'
|
path='timer'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<STimer />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<Timer />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/countdown'
|
path='countdown'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<SCountdown />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<Countdown />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/backstage'
|
path='backstage'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<SBackstage />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<Backstage />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/studio'
|
path='studio'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<SStudio />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<StudioClock />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/timeline'
|
path='timeline'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<STimeline />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<Timeline />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/info'
|
path='info'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<SProjectInfo />
|
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<ProjectInfo />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
<Route path='cuesheet' element={<Cuesheet />} />
|
||||||
<Route
|
<Route
|
||||||
path='/op'
|
path='op'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
<POperator />
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
|
<Operator />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes - Elements *!/*/}
|
{/*/!* Protected Routes - Elements *!/*/}
|
||||||
<Route
|
<Route
|
||||||
path='/rundown'
|
path='rundown'
|
||||||
element={
|
element={
|
||||||
<EditorFeatureWrapper>
|
<EditorFeatureWrapper>
|
||||||
<RundownPanel />
|
<RundownPanel />
|
||||||
@@ -116,7 +117,7 @@ export default function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/timercontrol'
|
path='timercontrol'
|
||||||
element={
|
element={
|
||||||
<EditorFeatureWrapper>
|
<EditorFeatureWrapper>
|
||||||
<TimerControl />
|
<TimerControl />
|
||||||
@@ -124,7 +125,7 @@ export default function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/messagecontrol'
|
path='messagecontrol'
|
||||||
element={
|
element={
|
||||||
<EditorFeatureWrapper>
|
<EditorFeatureWrapper>
|
||||||
<MessageControl />
|
<MessageControl />
|
||||||
@@ -132,16 +133,119 @@ export default function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/log'
|
path='log'
|
||||||
element={
|
element={
|
||||||
<EditorFeatureWrapper>
|
<EditorFeatureWrapper>
|
||||||
<Log />
|
<Log />
|
||||||
</EditorFeatureWrapper>
|
</EditorFeatureWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/*/!* Send to default if nothing found *!/*/}
|
{/**
|
||||||
<Route path='*' element={<STimer />} />
|
* If the views are prefixed with the "preset" path, we are in a locked preset
|
||||||
|
* Locked presets do not expose their parameters
|
||||||
|
*/}
|
||||||
|
<Route path='preset/:alias' element={<PresetView />} />
|
||||||
|
|
||||||
|
{/**
|
||||||
|
* If we havent matched any views or presets, we may be in an unlocked preset
|
||||||
|
* Unlocked presets are unwrapped to expose their target and parameters
|
||||||
|
*/}
|
||||||
|
<Route path='*' element={<RedirectPreset />} />
|
||||||
</SentryRouter>
|
</SentryRouter>
|
||||||
</React.Suspense>
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
|
||||||
|
[OntimeView.Cuesheet]: Cuesheet,
|
||||||
|
[OntimeView.Operator]: Operator,
|
||||||
|
[OntimeView.Timer]: Timer,
|
||||||
|
[OntimeView.Backstage]: Backstage,
|
||||||
|
[OntimeView.Timeline]: Timeline,
|
||||||
|
[OntimeView.StudioClock]: StudioClock,
|
||||||
|
[OntimeView.Countdown]: Countdown,
|
||||||
|
[OntimeView.ProjectInfo]: ProjectInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This view will mask a configured canonical route
|
||||||
|
* and inject the preset search parameters to context
|
||||||
|
* User are not able to configure the parameters locked presets
|
||||||
|
*/
|
||||||
|
function PresetView() {
|
||||||
|
const { data, status } = useUrlPresets();
|
||||||
|
const { alias } = useParams();
|
||||||
|
|
||||||
|
const preset: URLPreset | undefined = useMemo(() => {
|
||||||
|
if (status === 'pending' || !alias) return;
|
||||||
|
return data.find((p) => p.alias === alias && p.enabled);
|
||||||
|
}, [data, status, alias]);
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to check the session scope to determine if the user can navigate
|
||||||
|
* If the user has a global scope, they can navigate freely
|
||||||
|
* Otherwise, they are locked to the preset view
|
||||||
|
*/
|
||||||
|
const showNav = sessionScope === 'rw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we are in a preset path but cannot find a preset, we will need to show a not found page
|
||||||
|
* This can happen if the preset was deleted or disabled
|
||||||
|
*/
|
||||||
|
if (!preset) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
|
||||||
|
<NotFound />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locked presets do not allow configuration changes
|
||||||
|
* Whether the user can navigate is determined by the locked param
|
||||||
|
*
|
||||||
|
* We inject the preset to the context value for the view to consume
|
||||||
|
*/
|
||||||
|
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||||
|
return (
|
||||||
|
<PresetContext value={preset}>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||||
|
{Component ? <Component /> : <NotFound />}
|
||||||
|
</PresetContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectPreset() {
|
||||||
|
const { data, status } = useUrlPresets();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// checks if we are in a preset path and resolves a destination URL
|
||||||
|
const destination = useMemo(() => {
|
||||||
|
if (status === 'pending') return null;
|
||||||
|
return getRouteFromPreset(location, data);
|
||||||
|
}, [data, location, status]);
|
||||||
|
|
||||||
|
// if we have a destination, we will navigate to it
|
||||||
|
useEffect(() => {
|
||||||
|
if (destination) {
|
||||||
|
navigate(`/${destination}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [destination, navigate]);
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||||
|
<NotFound />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
|
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const assetsPath = `${apiEntryUrl}/assets`;
|
const assetsPath = `${apiEntryUrl}/assets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get css contents
|
* HTTP request to get css contents
|
||||||
*/
|
*/
|
||||||
export async function getCSSContents(): Promise<string> {
|
export async function getCSSContents(options?: RequestOptions): Promise<string> {
|
||||||
const res = await axios.get(`${assetsPath}/css`);
|
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +32,21 @@ export async function restoreCSSContents(): Promise<string> {
|
|||||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to get user translation
|
||||||
|
*/
|
||||||
|
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
|
||||||
|
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to post user translation
|
||||||
|
*/
|
||||||
|
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
|
||||||
|
await axios.post(`${assetsPath}/translations`, {
|
||||||
|
translation,
|
||||||
|
});
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import type {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const automationsPath = `${apiEntryUrl}/automations`;
|
const automationsPath = `${apiEntryUrl}/automations`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get the automations settings
|
* HTTP request to get the automations settings
|
||||||
*/
|
*/
|
||||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
|
||||||
const res = await axios.get(automationsPath);
|
const res = await axios.get(automationsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
axios.defaults.validateStatus = (status) => {
|
axios.defaults.validateStatus = (status) => {
|
||||||
return (status >= 200 && status < 300) || status === 304;
|
return status >= 200 && status < 300;
|
||||||
};
|
};
|
||||||
|
axios.defaults.timeout = axiosConfig.shortTimeout;
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
|
export const TRANSLATION = ['translation'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
const userAssetsPath = 'user';
|
const userAssetsPath = 'user';
|
||||||
const cssOverridePath = 'styles/override.css';
|
const cssOverridePath = 'styles/override.css';
|
||||||
|
const customTranslationsPath = 'translations/translations.json';
|
||||||
|
|
||||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
|||||||
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests list of known custom fields
|
* Requests list of known custom fields
|
||||||
*/
|
*/
|
||||||
export async function getCustomFields(): Promise<CustomFields> {
|
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
|
||||||
const res = await axios.get(customFieldsPath);
|
const res = await axios.get(customFieldsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
|
||||||
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
|
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
import { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
|
|
||||||
const dbPath = `${apiEntryUrl}/db`;
|
const dbPath = `${apiEntryUrl}/db`;
|
||||||
@@ -13,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
|||||||
* HTTP request to the current DB
|
* HTTP request to the current DB
|
||||||
*/
|
*/
|
||||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||||
return axios.post(`${dbPath}/download`, { filename });
|
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,27 +32,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
|
||||||
*/
|
*/
|
||||||
@@ -61,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('project', file);
|
formData.append('project', file);
|
||||||
const response = await axios.post(`${dbPath}/upload`, formData, {
|
const response = await axios.post(`${dbPath}/upload`, formData, {
|
||||||
|
timeout: axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -100,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
|
|||||||
/**
|
/**
|
||||||
* HTTP request to get the list of available project files
|
* HTTP request to get the list of available project files
|
||||||
*/
|
*/
|
||||||
export async function getProjects(): Promise<ProjectFileListResponse> {
|
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
|
||||||
const res = await axios.get(`${dbPath}/all`);
|
const res = await axios.get(`${dbPath}/all`, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
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 type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
const excelPath = `${apiEntryUrl}/excel`;
|
const excelPath = `${apiEntryUrl}/excel`;
|
||||||
|
|
||||||
@@ -10,32 +13,54 @@ 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, requestOptions?: RequestOptions): 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, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
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(
|
||||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
options: ImportMap,
|
||||||
options,
|
requestOptions?: RequestOptions,
|
||||||
});
|
): Promise<PreviewSpreadsheetResponse> {
|
||||||
|
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||||
|
`${excelPath}/preview`,
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a xlsx representation of the rundown from the server
|
||||||
|
*/
|
||||||
|
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import axios from 'axios';
|
|||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
|
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
export type HasUpdate = {
|
export type HasUpdate = {
|
||||||
url: string;
|
url: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -10,8 +12,8 @@ export type HasUpdate = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to get the latest version and url from github
|
* HTTP request to get the latest version and url from github
|
||||||
*/
|
*/
|
||||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
|
||||||
const res = await axios.get(apiRepoLatest);
|
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
|
||||||
return {
|
return {
|
||||||
url: res.data.html_url as string,
|
url: res.data.html_url as string,
|
||||||
version: res.data.tag_name as string,
|
version: res.data.tag_name as string,
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
const projectPath = `${apiEntryUrl}/project`;
|
const projectPath = `${apiEntryUrl}/project`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch project data
|
* HTTP request to fetch project data
|
||||||
*/
|
*/
|
||||||
export async function getProjectData(): Promise<ProjectData> {
|
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
|
||||||
const res = await axios.get(projectPath);
|
const res = await axios.get(projectPath, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||||
|
timeout: axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
|
|||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
import { apiEntryUrl, REPORT } from './constants';
|
import { apiEntryUrl, REPORT } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
export const reportUrl = `${apiEntryUrl}/report`;
|
export const reportUrl = `${apiEntryUrl}/report`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch all reports
|
* HTTP request to fetch all reports
|
||||||
*/
|
*/
|
||||||
export async function fetchReport(): Promise<OntimeReport> {
|
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
|
||||||
const res = await axios.get(reportUrl);
|
const res = await axios.get(reportUrl, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type RequestOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Keep a short global timeout for regular API requests, but allow
|
||||||
|
* longer windows for file transfer and heavy import/export operations.
|
||||||
|
*/
|
||||||
|
export const axiosConfig = {
|
||||||
|
shortTimeout: 20 * 1000, // 20 seconds
|
||||||
|
longTimeout: 3 * 60 * 1000, // 3 minutes
|
||||||
|
} as const;
|
||||||
@@ -1,58 +1,114 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import {
|
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||||
EntryId,
|
|
||||||
MessageResponse,
|
|
||||||
OntimeEntry,
|
|
||||||
OntimeEvent,
|
|
||||||
ProjectRundownsList,
|
|
||||||
Rundown,
|
|
||||||
TransientEventPayload,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
type RundownId = string;
|
||||||
|
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||||
|
|
||||||
|
// #region operations on project rundowns =========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch a list of existing rundowns
|
* HTTP request to fetch a list of existing rundowns
|
||||||
*/
|
*/
|
||||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
|
||||||
const res = await axios.get(rundownPath);
|
const res = await axios.get(rundownPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch all events
|
* HTTP request to fetch all entries in the currently loaded rundown
|
||||||
*/
|
*/
|
||||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
|
||||||
const res = await axios.get(`${rundownPath}/current`);
|
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
|
||||||
|
if (!isValidRundown(res.data)) {
|
||||||
|
throw new Error('Invalid rundown payload');
|
||||||
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|
||||||
|
function isValidRundown(x: any): x is Rundown {
|
||||||
|
return (
|
||||||
|
x &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
typeof x.id === 'string' &&
|
||||||
|
Array.isArray(x.order) &&
|
||||||
|
Array.isArray(x.flatOrder) &&
|
||||||
|
x.entries &&
|
||||||
|
typeof x.entries === 'object' &&
|
||||||
|
typeof x.revision === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to switch the currently loaded rundown
|
||||||
|
*/
|
||||||
|
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/load`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to create a new rundown
|
||||||
|
*/
|
||||||
|
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(rundownPath, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to duplicate an existing rundown
|
||||||
|
*/
|
||||||
|
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to rename an existing rundown
|
||||||
|
*/
|
||||||
|
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.patch(`${rundownPath}/${rundownId}`, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to delete a rundown
|
||||||
|
*/
|
||||||
|
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #endregion operations on project rundowns ======================
|
||||||
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to post new entry
|
* HTTP request to post new entry
|
||||||
*/
|
*/
|
||||||
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
export async function postAddEntry(
|
||||||
return axios.post(rundownPath, data);
|
rundownId: RundownId,
|
||||||
|
data: TransientEventPayload,
|
||||||
|
): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to edit an entry
|
* HTTP request to edit an entry
|
||||||
*/
|
*/
|
||||||
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
export async function putEditEntry(
|
||||||
return axios.put(rundownPath, data);
|
rundownId: RundownId,
|
||||||
|
data: Partial<OntimeEntry>,
|
||||||
|
): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
|
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchEditEntry = {
|
export type BatchEditEntry = {
|
||||||
data: Partial<OntimeEvent>;
|
data: Partial<OntimeEvent>;
|
||||||
ids: string[];
|
ids: EntryId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to edit multiple events
|
* HTTP request to edit multiple events
|
||||||
*/
|
*/
|
||||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.put(`${rundownPath}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReorderEntry = {
|
export type ReorderEntry = {
|
||||||
@@ -64,60 +120,65 @@ export type ReorderEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to reorder an entry
|
* HTTP request to reorder an entry
|
||||||
*/
|
*/
|
||||||
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/reorder`, data);
|
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwapEntry = {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to swap two events
|
* HTTP request to swap two events
|
||||||
*/
|
*/
|
||||||
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestEventSwap(
|
||||||
return axios.patch(`${rundownPath}/swap`, data);
|
rundownId: RundownId,
|
||||||
|
from: EntryId,
|
||||||
|
to: EntryId,
|
||||||
|
): Promise<AxiosResponse<Rundown>> {
|
||||||
|
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(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/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(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function postCloneEntry(
|
||||||
return axios.post(`${rundownPath}/clone/${entryId}`);
|
rundownId: RundownId,
|
||||||
|
entryId: EntryId,
|
||||||
|
options?: { before?: EntryId; after?: EntryId },
|
||||||
|
): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for dissolving of a block
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/ungroup/${blockId}`);
|
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a block
|
* HTTP request for dissolving of a group
|
||||||
*/
|
*/
|
||||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete entries
|
* HTTP request to delete entries of a given rundown
|
||||||
*/
|
*/
|
||||||
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
|
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(rundownPath, { data: { ids: entryIds } });
|
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete all events
|
* HTTP request to delete all entries of a given rundown
|
||||||
*/
|
*/
|
||||||
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(`${rundownPath}/all`);
|
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { GetInfo } from 'ontime-types';
|
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const sessionPath = `${apiEntryUrl}/session`;
|
const sessionPath = `${apiEntryUrl}/session`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve application info
|
* HTTP request to retrieve application info
|
||||||
*/
|
*/
|
||||||
export async function getInfo(): Promise<GetInfo> {
|
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
|
||||||
const res = await axios.get(`${sessionPath}/info`);
|
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get a pre-authenticated URL
|
* HTTP request to get a pre-authenticated URL
|
||||||
*/
|
*/
|
||||||
export async function generateUrl(
|
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
|
||||||
baseUrl: string,
|
const res = await axios.post(`${sessionPath}/url`, options);
|
||||||
path: string,
|
|
||||||
lock: boolean,
|
|
||||||
authenticate: boolean,
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
|
||||||
return res.data.url;
|
return res.data.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const settingsPath = `${apiEntryUrl}/settings`;
|
const settingsPath = `${apiEntryUrl}/settings`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve application settings
|
* HTTP request to retrieve application settings
|
||||||
*/
|
*/
|
||||||
export async function getSettings(): Promise<Settings> {
|
export async function getSettings(options?: RequestOptions): Promise<Settings> {
|
||||||
const res = await axios.get(settingsPath);
|
const res = await axios.get(settingsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
const sheetsPath = `${apiEntryUrl}/sheets`;
|
const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
|
|||||||
export const requestConnection = async (
|
export const requestConnection = async (
|
||||||
file: File,
|
file: File,
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
verification_url: string;
|
verification_url: string;
|
||||||
user_code: string;
|
user_code: string;
|
||||||
@@ -31,6 +34,8 @@ export const requestConnection = async (
|
|||||||
formData.append('client_secret', file);
|
formData.append('client_secret', file);
|
||||||
|
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -53,23 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
|
|||||||
export const previewRundown = async (
|
export const previewRundown = async (
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
options: ImportMap,
|
options: ImportMap,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
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 },
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
|
||||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
const response: AxiosResponse<string[]> = await axios.post(
|
||||||
|
`${sheetsPath}/${sheetId}/worksheets`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to upload the rundown to a google sheet
|
* HTTP request to upload the rundown to a google sheet
|
||||||
*/
|
*/
|
||||||
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
|
export const uploadRundown = async (
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
|
sheetId: string,
|
||||||
|
options: ImportMap,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
|
): Promise<void> => {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${sheetsPath}/${sheetId}/write`,
|
||||||
|
{ options },
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
|||||||
import { URLPreset } from 'ontime-types';
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve all presets
|
* HTTP request to retrieve all presets
|
||||||
*/
|
*/
|
||||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
|
||||||
const res = await axios.get(urlPresetsPath);
|
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
|||||||
import type { ViewSettings } from 'ontime-types';
|
import type { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||||
|
|
||||||
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
|||||||
* HTTP request to fetch view settings
|
* HTTP request to fetch view settings
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function getViewSettings(): Promise<ViewSettings> {
|
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
|
||||||
const res = await axios.get(viewSettingsPath);
|
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
.subtle {
|
|
||||||
background: $gray-1050;
|
|
||||||
color: $blue-400;
|
|
||||||
line-height: 1em;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $blue-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtle-white {
|
|
||||||
background: $gray-1050;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: $gray-1050;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
background: $blue-700;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $blue-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $blue-800;
|
|
||||||
border-color: $blue-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: $viewer-opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.destructive {
|
|
||||||
background: $red-700;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $red-600;
|
|
||||||
color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $red-800;
|
|
||||||
border-color: $red-900;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtle-destructive {
|
|
||||||
background: $gray-1050;
|
|
||||||
color: $red-400;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghosted {
|
|
||||||
background: transparent;
|
|
||||||
color: $blue-500;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $blue-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: $opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghosted-white {
|
|
||||||
background: transparent;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: $opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.ghosted-destructive {
|
|
||||||
background: transparent;
|
|
||||||
color: $red-500;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
color: $red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
background: $gray-1100;
|
|
||||||
border-color: $gray-1250;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: $opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use './buttonVariants' as base;
|
||||||
@import './BaseButtonStyles.module.scss';
|
|
||||||
|
@include base.button-variants;
|
||||||
|
|
||||||
.baseButton {
|
.baseButton {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -81,4 +82,3 @@
|
|||||||
.fluid {
|
.fluid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use './buttonVariants' as base;
|
||||||
@import './BaseButtonStyles.module.scss';
|
|
||||||
|
@include base.button-variants;
|
||||||
|
|
||||||
.baseIconButton {
|
.baseIconButton {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@use '@/theme/themeTokens' as *;
|
||||||
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
@mixin button-variants {
|
||||||
|
.subtle {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $blue-400;
|
||||||
|
line-height: 1em;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-white {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $gray-1050;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: $blue-700;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $blue-800;
|
||||||
|
border-color: $blue-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $viewer-opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.destructive {
|
||||||
|
background: $red-700;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $red-600;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $red-800;
|
||||||
|
border-color: $red-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-destructive {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $red-400;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghosted {
|
||||||
|
background: transparent;
|
||||||
|
color: $blue-500;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghosted-white {
|
||||||
|
background: transparent;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghosted-destructive {
|
||||||
|
background: transparent;
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||||
|
|
||||||
import style from './Checkbox.module.scss';
|
import style from './Checkbox.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
|
|||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
|
import Dialog from '../dialog/Dialog';
|
||||||
import Info from '../info/Info';
|
import Info from '../info/Info';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import AppLink from '../link/app-link/AppLink';
|
import AppLink from '../link/app-link/AppLink';
|
||||||
import Modal from '../modal/Modal';
|
|
||||||
import Select from '../select/Select';
|
import Select from '../select/Select';
|
||||||
|
|
||||||
import style from './RedirectClientModal.module.scss';
|
import style from './RedirectClientModal.module.scss';
|
||||||
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
if (newPath === '/' || newPath === currentPath) {
|
if (newPath === '/' || newPath === currentPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRedirect({ target: id, redirect: newPath });
|
|
||||||
|
if (newPath.startsWith('preset-')) {
|
||||||
|
setRedirect({ target: id, redirect: newPath.slice(7) });
|
||||||
|
} else {
|
||||||
|
setRedirect({ target: id, redirect: newPath });
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,13 +51,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
label: view.label,
|
label: view.label,
|
||||||
})),
|
})),
|
||||||
...enabledPresets.map((preset) => ({
|
...enabledPresets.map((preset) => ({
|
||||||
value: preset.search,
|
value: `preset-${preset.alias}`,
|
||||||
label: `URL Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Dialog
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
showCloseButton
|
showCloseButton
|
||||||
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
<>
|
<>
|
||||||
<Info>
|
<Info>
|
||||||
Remotely redirect the client to a different URL. <br />
|
Remotely redirect the client to a different URL. <br />
|
||||||
Either by selecting a URL Preset or entering a custom path.
|
Either by entering a custom path or selecting a URL Preset.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||||
</Info>
|
</Info>
|
||||||
<div>
|
|
||||||
<span className={style.label}>Select View or URL Preset</span>
|
|
||||||
<div className={style.textEntry}>
|
|
||||||
<Select
|
|
||||||
fluid
|
|
||||||
options={viewOptions}
|
|
||||||
defaultValue={viewOptions[0].value}
|
|
||||||
onValueChange={(value) => setSelected(value)}
|
|
||||||
disabled={enabledPresets.length === 0}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant='primary'
|
|
||||||
aria-label='Redirect to preset'
|
|
||||||
className={style.redirect}
|
|
||||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
|
||||||
onClick={() => handleRedirect(selected)}
|
|
||||||
>
|
|
||||||
Redirect <IoArrowForward />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.inlineEntry}>
|
<div className={style.inlineEntry}>
|
||||||
<span className={style.label}>Enter custom path</span>
|
<span className={style.label}>Enter custom path</span>
|
||||||
<label className={style.textEntry}>
|
<label className={style.textEntry}>
|
||||||
@@ -100,9 +85,37 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
className={style.redirect}
|
className={style.redirect}
|
||||||
onClick={() => handleRedirect(path)}
|
onClick={() => handleRedirect(path)}
|
||||||
>
|
>
|
||||||
Redirect <IoArrowForward />
|
Redirect
|
||||||
|
<IoArrowForward />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={style.label}>Select View or URL Preset</span>
|
||||||
|
<div className={style.inlineEntry}>
|
||||||
|
<label className={style.textEntry}>
|
||||||
|
{origin}
|
||||||
|
<Select
|
||||||
|
fluid
|
||||||
|
options={viewOptions}
|
||||||
|
defaultValue={viewOptions[0].value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === null) return;
|
||||||
|
setSelected(value);
|
||||||
|
}}
|
||||||
|
disabled={enabledPresets.length === 0}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
aria-label='Redirect to preset'
|
||||||
|
className={style.redirect}
|
||||||
|
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||||
|
onClick={() => handleRedirect(selected)}
|
||||||
|
>
|
||||||
|
Redirect <IoArrowForward />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { PropsWithChildren, 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';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
@@ -24,32 +23,33 @@ export default function CopyTag({
|
|||||||
onClick,
|
onClick,
|
||||||
}: PropsWithChildren<CopyTagProps>) {
|
}: PropsWithChildren<CopyTagProps>) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = async () => {
|
||||||
copyToClipboard(copyValue);
|
try {
|
||||||
setCopied(true);
|
await copyToClipboard(copyValue);
|
||||||
|
setCopied(true);
|
||||||
|
|
||||||
// reset copied state
|
// reset copied state
|
||||||
setTimeout(() => setCopied(false), 1000);
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.copytag}>
|
<div className={style.copytag}>
|
||||||
{onClick !== undefined ? (
|
{onClick !== undefined ? (
|
||||||
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
|
<Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className={cx([style.label, style[size]])}>{children}</div>
|
<div className={cx([style.label, style[size]])}>{children}</div>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
|
||||||
className={style.copy}
|
|
||||||
variant='primary'
|
|
||||||
size={size}
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{copied ? <IoCheckmark /> : <IoCopy />}
|
{copied ? <IoCheckmark /> : <IoCopy />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
min-width: min(420px, 90vw);
|
min-width: min(600px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: $box-shadow-l1;
|
box-shadow: $box-shadow-l1;
|
||||||
border: 1px solid $gray-1100;
|
border: 1px solid $gray-1100;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
|
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
|
||||||
|
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ interface DialogProps {
|
|||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
showBackdrop?: boolean;
|
showBackdrop?: boolean;
|
||||||
bodyElements: ReactNode;
|
bodyElements: ReactNode;
|
||||||
footerElements: ReactNode;
|
footerElements?: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
padding-inline: 1rem 2rem;
|
padding-inline: 1rem;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
justify-content: space-between;
|
||||||
line-height: 1em;
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
@@ -61,6 +61,42 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-type='destructive'] {
|
||||||
|
color: $red-500;
|
||||||
|
svg {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item .iconStrong {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: calc(1rem - 4px);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { IconType } from 'react-icons';
|
import { IconType } from 'react-icons';
|
||||||
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
|
import { Menu as BaseMenu } from '@base-ui/react/menu';
|
||||||
|
|
||||||
import style from './DropdownMenu.module.scss';
|
import style from './DropdownMenu.module.scss';
|
||||||
|
|
||||||
type DropdownMenuItemDivider = { type: 'divider' };
|
type DropdownMenuItemDivider = { type: 'divider' };
|
||||||
type DropdownMenuItem = {
|
type DropdownMenuItem = {
|
||||||
type: 'item';
|
type: 'item' | 'destructive';
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
shortcut?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
|
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
|
||||||
@@ -31,9 +33,21 @@ 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
|
||||||
{item.icon && <item.icon />}
|
key={index}
|
||||||
{item.label}
|
className={style.item}
|
||||||
|
onClick={item.onClick}
|
||||||
|
disabled={item.disabled}
|
||||||
|
data-type={item.type}
|
||||||
|
>
|
||||||
|
<span className={style.content}>
|
||||||
|
<span className={style.label}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && <span className={style.description}>{item.description}</span>}
|
||||||
|
</span>
|
||||||
|
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||||
{item.icon && <item.icon />}
|
<span className={style.content}>
|
||||||
{item.label}
|
<span className={style.label}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && <span className={style.description}>{item.description}</span>}
|
||||||
|
</span>
|
||||||
|
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,13 @@
|
|||||||
|
|
||||||
&.vertical {
|
&.vertical {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 0.75em;
|
height: 0.75em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
border-radius: $panel-border-radius;
|
||||||
|
background-color: $bg-container-l2;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>) {
|
||||||
@@ -35,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
|
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
|
||||||
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
|
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cx([style.panel, className])} {...props} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
border: 2px solid $gray-1200;
|
border: 2px solid $gray-1200;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray-500;
|
color: $gray-600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||||
import { Popover } from '@base-ui-components/react/popover';
|
import { Popover } from '@base-ui/react/popover';
|
||||||
|
|
||||||
import PopoverContents from '../../popover/Popover';
|
import PopoverContents from '../../popover/Popover';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import style from './AppLink.module.scss';
|
import style from './AppLink.module.scss';
|
||||||
|
|
||||||
@@ -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(800px, 90vw);
|
max-width: min(1200px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
|
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
|
||||||
|
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export default function Modal({
|
|||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) onClose();
|
if (!isOpen) onClose();
|
||||||
}}
|
}}
|
||||||
dismissible={false}
|
disablePointerDismissal
|
||||||
>
|
>
|
||||||
<BaseDialog.Portal>
|
<BaseDialog.Portal>
|
||||||
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
|
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
|
||||||
|
|||||||
@@ -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 { useLocation } from 'react-router-dom';
|
import { LuCoffee } from 'react-icons/lu';
|
||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { useLocation } from 'react-router';
|
||||||
|
import { Dialog } from '@base-ui/react/dialog';
|
||||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../externals';
|
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||||
|
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
||||||
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>
|
||||||
|
{canUseWakeLock && (
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||||
|
|
||||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||||
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
|
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from './NavigationMenu';
|
||||||
import useViewEditor from './useViewEditor';
|
|
||||||
|
|
||||||
interface ViewNavigationMenuProps {
|
interface ViewNavigationMenuProps {
|
||||||
isLockable?: boolean;
|
/** prevent navigation */
|
||||||
|
isNavigationLocked?: boolean;
|
||||||
|
/** prevent showing settings */
|
||||||
suppressSettings?: boolean;
|
suppressSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ViewNavigationMenu);
|
export default memo(ViewNavigationMenu);
|
||||||
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
|
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
'Space',
|
'Space',
|
||||||
() => {
|
() => {
|
||||||
if (isViewLocked) return;
|
if (isNavigationLocked) return;
|
||||||
menuHandler.toggle();
|
menuHandler.toggle();
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: true },
|
||||||
@@ -28,24 +31,24 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
|
|||||||
[
|
[
|
||||||
'mod + ,',
|
'mod + ,',
|
||||||
() => {
|
() => {
|
||||||
if (isViewLocked || suppressSettings) return;
|
if (suppressSettings) return;
|
||||||
showEditFormDrawer();
|
showEditFormDrawer();
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: true },
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isViewLocked) {
|
if (isNavigationLocked && suppressSettings) {
|
||||||
return <ViewLockedIcon />;
|
return <ViewLockedIcon />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingNavigation
|
<FloatingNavigation
|
||||||
toggleMenu={menuHandler.toggle}
|
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||||
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
|
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||||
/>
|
/>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { IoArrowUp } from 'react-icons/io5';
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
||||||
import { handleLinks } from '../../../utils/linkUtils';
|
import { handleLinks } from '../../../utils/linkUtils';
|
||||||
@@ -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,5 @@
|
|||||||
import { IoLockClosedOutline } from 'react-icons/io5';
|
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
|
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
|
||||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
row-gap: 1rem;
|
row-gap: 1rem;
|
||||||
padding: 0.5em;
|
padding-top: 0.75rem;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -8,7 +7,7 @@ import IconButton from '../../buttons/IconButton';
|
|||||||
import style from './FloatingNavigation.module.scss';
|
import style from './FloatingNavigation.module.scss';
|
||||||
|
|
||||||
interface FloatingNavigationProps {
|
interface FloatingNavigationProps {
|
||||||
toggleMenu: () => void;
|
toggleMenu?: () => void;
|
||||||
toggleSettings?: () => void;
|
toggleSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,15 +19,17 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
|
|||||||
id='fadeable-navigation'
|
id='fadeable-navigation'
|
||||||
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
||||||
>
|
>
|
||||||
<IconButton
|
{toggleMenu && (
|
||||||
variant='subtle-white'
|
<IconButton
|
||||||
size='xlarge'
|
variant='subtle-white'
|
||||||
onClick={toggleMenu}
|
size='xlarge'
|
||||||
aria-label='toggle menu'
|
onClick={toggleMenu}
|
||||||
data-testid='navigation__toggle-menu'
|
aria-label='toggle menu'
|
||||||
>
|
data-testid='navigation__toggle-menu'
|
||||||
<IoApps />
|
>
|
||||||
</IconButton>
|
<IoApps />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{toggleSettings && (
|
{toggleSettings && (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='subtle-white'
|
variant='subtle-white'
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
|
||||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
|
||||||
|
|
||||||
interface EditorVisibilityOptions {
|
|
||||||
isLockable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
|
||||||
|
|
||||||
const isViewLocked = useMemo(() => {
|
|
||||||
if (!isLockable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return isStringBoolean(searchParams.get('locked'));
|
|
||||||
}, [isLockable, searchParams]);
|
|
||||||
|
|
||||||
return { showEditFormDrawer, isViewLocked };
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Popover } from '@base-ui-components/react/popover';
|
import { Popover } from '@base-ui/react/popover';
|
||||||
|
|
||||||
import style from './Popover.module.scss';
|
import style from './Popover.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
.radioGroup {
|
.radioGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
.item {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal {
|
.horizontal {
|
||||||
@@ -32,8 +38,8 @@
|
|||||||
.radio {
|
.radio {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 1rem;
|
width: 16px; // use absolute units to avoid subpixel rendering issues
|
||||||
height: 1rem;
|
height: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
@@ -46,10 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-checked] {
|
&[data-checked] {
|
||||||
background-color: $ui-white;
|
background-color: $blue-700;
|
||||||
&:hover {
|
|
||||||
border-color: $gray-1000;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
@@ -69,8 +72,9 @@
|
|||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
width: 0.5rem;
|
width: 6px; // half of the radio size
|
||||||
height: 0.5rem;
|
height: 6px;
|
||||||
background-color: $gray-1200;
|
aspect-ratio: 1;
|
||||||
|
background-color: $ui-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Radio } from '@base-ui-components/react/radio';
|
import { Radio } from '@base-ui/react/radio';
|
||||||
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
// copied from index.scss
|
||||||
|
$track-color: $white-1;
|
||||||
|
$thumb-color: $white-20;
|
||||||
|
$thumb-color-hover: $white-60;
|
||||||
|
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar {
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
|
||||||
|
&[data-orientation='vertical'] {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-orientation='horizontal'] {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-orientation='horizontal'][data-has-overflow-x] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-scrolling] {
|
||||||
|
transition-duration: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-hovering],
|
||||||
|
&[data-scrolling] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
background: $thumb-color;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $thumb-color-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CSSProperties, PropsWithChildren, Ref } from 'react';
|
||||||
|
import { ScrollArea } from '@base-ui/react/scroll-area';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './ScrollArea.module.scss';
|
||||||
|
|
||||||
|
interface ScrollAreaProps {
|
||||||
|
className?: string;
|
||||||
|
viewportClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
contentStyle?: CSSProperties;
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StyledScrollArea({
|
||||||
|
className,
|
||||||
|
viewportClassName,
|
||||||
|
contentClassName,
|
||||||
|
contentStyle,
|
||||||
|
children,
|
||||||
|
ref,
|
||||||
|
orientation = 'vertical',
|
||||||
|
}: PropsWithChildren<ScrollAreaProps>) {
|
||||||
|
return (
|
||||||
|
<ScrollArea.Root className={cx([style.root, className])}>
|
||||||
|
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
|
||||||
|
<ScrollArea.Content className={contentClassName} style={contentStyle}>
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Content>
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
|
||||||
|
<ScrollArea.Thumb className={style.thumb} />
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
</ScrollArea.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
import { LuChevronsUpDown } from 'react-icons/lu';
|
import { LuChevronsUpDown } from 'react-icons/lu';
|
||||||
import { Select as BaseSelect } from '@base-ui-components/react/select';
|
import { Select as BaseSelect } from '@base-ui/react/select';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
display: block;
|
||||||
|
margin-inline: auto;;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import style from './Empty.module.scss';
|
|||||||
|
|
||||||
interface EmptyProps {
|
interface EmptyProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty({ text, className, ...rest }: EmptyProps) {
|
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
{text && <span className={style.text}>{text}</span>}
|
{text && <span className={style.text}>{text}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import style from './EmptyPage.module.scss';
|
|||||||
|
|
||||||
interface EmptyPageProps {
|
interface EmptyPageProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyPage(props: EmptyPageProps) {
|
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||||
const { text, ...rest } = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.page}>
|
<div className={style.page}>
|
||||||
<Empty text={text} {...rest} />
|
<Empty text={text} injectedStyles={injectedStyles} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
.emptyCell {
|
.emptyCell {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-top: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background-color: $ui-white;
|
background-color: $ui-white;
|
||||||
@@ -55,5 +55,4 @@
|
|||||||
&[data-checked] {
|
&[data-checked] {
|
||||||
translate: calc(var(--width) - var(--height)) 0;
|
translate: calc(var(--width) - var(--height)) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
|
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ReactNode } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import style from './Tag.module.scss';
|
import style from './Tag.module.scss';
|
||||||
|
|
||||||
export default function Tag({ children }: { children: ReactNode }) {
|
interface TagProps {
|
||||||
return <span className={style.tag}>{children}</span>;
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
|
||||||
|
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
.title-card__title {
|
.title-card__title {
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
|
min-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__placeholder {
|
.title-card__placeholder {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
|
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
|
||||||
|
|
||||||
import style from './Tooltip.module.scss';
|
import style from './Tooltip.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
import { projectLogoPath } from '../../api/constants';
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
import './ViewLogo.scss';
|
import './ViewLogo.scss';
|
||||||
@@ -7,13 +9,19 @@ interface ViewLogoProps {
|
|||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewLogo(props: ViewLogoProps) {
|
export default function ViewLogo({ name, className }: ViewLogoProps) {
|
||||||
const { name, className } = props;
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
const hideImage = () => {
|
||||||
|
if (!imageRef.current) return;
|
||||||
|
|
||||||
|
imageRef.current.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
// we wrap the image in a div to help maintain the aspect ratio
|
// we wrap the image in a div to help maintain the aspect ratio
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
|
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
|
||||||
const { name, value } = props;
|
|
||||||
const [colour, setColour] = useState(() => ensureHex(value));
|
const [colour, setColour] = useState(() => ensureHex(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColour(ensureHex(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { ComponentProps, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||||
import Checkbox from '../checkbox/Checkbox';
|
import Checkbox from '../checkbox/Checkbox';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import Select from '../select/Select';
|
import Select, { SelectOption } from '../select/Select';
|
||||||
import Switch from '../switch/Switch';
|
import Switch from '../switch/Switch';
|
||||||
|
|
||||||
import InlineColourPicker from './InlineColourPicker';
|
import InlineColourPicker from './InlineColourPicker';
|
||||||
@@ -35,15 +35,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
return <span className={style.empty}>No options available</span>;
|
return <span className={style.empty}>No options available</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'multi-option') {
|
if (type === 'multi-option') {
|
||||||
return <MultiOption paramField={paramField} />;
|
const optionFromParams = searchParams.getAll(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiOption
|
||||||
|
paramField={paramField}
|
||||||
|
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
|
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
@@ -63,27 +70,28 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'colour') {
|
if (type === 'colour') {
|
||||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
|
||||||
|
|
||||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
|
||||||
const { placeholder } = paramField;
|
const { placeholder } = paramField;
|
||||||
|
|
||||||
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditFormMultiOptionProps {
|
interface EditFormMultiOptionProps {
|
||||||
paramField: ParamField & { type: 'multi-option' };
|
paramField: ParamField & { type: 'multi-option' };
|
||||||
|
options: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||||
const [searchParams] = useSearchParams();
|
const { id, values } = paramField;
|
||||||
const { id, values, defaultValue = [''] } = paramField;
|
const [paramState, setParamState] = useState<string[]>(options);
|
||||||
|
|
||||||
const optionFromParams = searchParams.getAll(id);
|
// synchronise options
|
||||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
useEffect(() => {
|
||||||
|
setParamState(options);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
const toggleValue = (value: string, checked: boolean) => {
|
const toggleValue = (value: string, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -129,5 +137,61 @@ interface ControlledSwitchProps {
|
|||||||
}
|
}
|
||||||
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
||||||
const [checked, setChecked] = useState(initialValue);
|
const [checked, setChecked] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise checked state
|
||||||
|
useEffect(() => {
|
||||||
|
setChecked(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ControlledSelectProps {
|
||||||
|
id: string;
|
||||||
|
initialValue?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
|
||||||
|
const [selected, setSelected] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise selected state
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size='large'
|
||||||
|
name={id}
|
||||||
|
options={options}
|
||||||
|
value={selected}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === null) return;
|
||||||
|
setSelected(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
|
||||||
|
id: string;
|
||||||
|
initialValue: T;
|
||||||
|
}
|
||||||
|
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
// synchronise selected state
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
height='large'
|
||||||
|
name={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value as T)}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
import { FormEvent, memo } from 'react';
|
import { FormEvent, memo } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui/react/dialog';
|
||||||
|
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';
|
||||||
import Info from '../info/Info';
|
import Info from '../info/Info';
|
||||||
|
|
||||||
import { ViewOption } from './viewParams.types';
|
import { ViewOption } from './viewParams.types';
|
||||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
|
||||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||||
|
import { ViewParamsPresets } from './ViewParamsPresets';
|
||||||
import ViewParamsSection from './ViewParamsSection';
|
import ViewParamsSection from './ViewParamsSection';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
|
|
||||||
interface EditFormDrawerProps {
|
interface EditFormDrawerProps {
|
||||||
|
target: OntimeView;
|
||||||
viewOptions: ViewOption[];
|
viewOptions: ViewOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ViewParamsEditor);
|
export default memo(ViewParamsEditor);
|
||||||
|
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||||
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
const [searchParams, 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 getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetParams = () => {
|
const resetParams = () => {
|
||||||
setSearchParams();
|
setSearchParams(getPreservedParams());
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -39,7 +45,15 @@ function ViewParamsEditor({ 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);
|
||||||
|
const preservedParams = getPreservedParams();
|
||||||
|
preservedParams.forEach((value, key) => {
|
||||||
|
newSearchParams.append(key, value);
|
||||||
|
});
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +70,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
<Dialog.Popup className={style.drawer}>
|
<Dialog.Popup className={style.drawer}>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<Dialog.Title>Customise</Dialog.Title>
|
<Dialog.Title>Customise</Dialog.Title>
|
||||||
<IconButton variant='subtle-white' size='large' onClick={handleClose}>
|
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||||
<IoClose />
|
<IoClose />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +78,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
{viewSettings.overrideStyles && (
|
{viewSettings.overrideStyles && (
|
||||||
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||||
)}
|
)}
|
||||||
|
<ViewParamsPresets target={target} />
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||||
{viewOptions.map((section) => (
|
{viewOptions.map((section) => (
|
||||||
<ViewParamsSection
|
<ViewParamsSection
|
||||||
@@ -79,8 +94,14 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
|
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
|
||||||
Reset to default
|
Reset to default
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
|
<Button
|
||||||
Save
|
variant='primary'
|
||||||
|
size='large'
|
||||||
|
form='edit-params-form'
|
||||||
|
type='submit'
|
||||||
|
data-testid='apply-view-params'
|
||||||
|
>
|
||||||
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Popup>
|
</Dialog.Popup>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.presetSection {
|
||||||
|
background-color: $gray-1350;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-height: 10rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetActions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { OntimeView, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
import Button from '../buttons/Button';
|
||||||
|
|
||||||
|
import style from './ViewParamsPresets.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a list of presets for the current view
|
||||||
|
*/
|
||||||
|
export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||||
|
const { viewPresets } = useViewUrlPresets(target);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const handleRecall = (preset: URLPreset) => {
|
||||||
|
const newSearch = new URLSearchParams(preset.search);
|
||||||
|
newSearch.set('alias', preset.alias);
|
||||||
|
setSearchParams(newSearch);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (viewPresets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.presetSection}>
|
||||||
|
{viewPresets.map((preset) => {
|
||||||
|
const active = searchParams.get('alias') === preset.alias;
|
||||||
|
return (
|
||||||
|
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||||
|
<div>{preset.alias}</div>
|
||||||
|
<Button
|
||||||
|
variant={active ? 'ghosted' : 'subtle-white'}
|
||||||
|
onClick={() => handleRecall(preset)}
|
||||||
|
disabled={active}
|
||||||
|
className={style.presetActions}
|
||||||
|
>
|
||||||
|
{active ? 'Applied' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,11 +36,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.closed {
|
.closed {
|
||||||
transition: rotate $transition-time-feedback;
|
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
rotate: 0deg;
|
rotate: 0deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.open {
|
.open {
|
||||||
transition: rotate $transition-time-feedback;
|
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { IoChevronDown } from 'react-icons/io5';
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
|
|
||||||
@@ -16,8 +15,7 @@ interface ViewParamsSectionProps {
|
|||||||
options: ParamField[];
|
options: ParamField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ViewParamsSection);
|
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||||
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
|
||||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||||
|
|
||||||
const handleCollapse = () => {
|
const handleCollapse = () => {
|
||||||
@@ -49,15 +47,11 @@ interface SectionContentsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||||
if (collapsed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
return (
|
return (
|
||||||
<label key={option.title} className={style.label}>
|
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||||
<span className={style.title}>{option.title}</span>
|
<span className={style.title}>{option.title}</span>
|
||||||
<span className={style.description}>{option.description}</span>
|
<span className={style.description}>{option.description}</span>
|
||||||
<ParamInput paramField={option} />
|
<ParamInput paramField={option} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
|
|||||||
type MultiOptionsField = {
|
type MultiOptionsField = {
|
||||||
type: 'multi-option';
|
type: 'multi-option';
|
||||||
values: MultiselectOption[];
|
values: MultiselectOption[];
|
||||||
defaultValue?: string;
|
defaultValue?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
|||||||
/**
|
/**
|
||||||
* Creates data for a select element that displays project custom data
|
* Creates data for a select element that displays project custom data
|
||||||
*/
|
*/
|
||||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
export function makeProjectDataOptions(
|
||||||
return projectData.custom.map((entry, index) => {
|
projectData: ProjectData,
|
||||||
|
additionalOptions: SelectOption[] = [],
|
||||||
|
): SelectOption[] {
|
||||||
|
const generatedOptions = projectData.custom.map((entry, index) => {
|
||||||
return {
|
return {
|
||||||
value: `${index}-${entry.title}`,
|
value: `${index}-${entry.title}`,
|
||||||
label: entry.title,
|
label: entry.title,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...additionalOptions, ...generatedOptions];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
@@ -186,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
|
|||||||
|
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts search params that are not managed by the view params editor
|
||||||
|
* @param currentParams - The current URL search params
|
||||||
|
* @param paramFields - The view options that define the managed parameters
|
||||||
|
* @returns A new URLSearchParams object with preserved params
|
||||||
|
*/
|
||||||
|
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
|
||||||
|
const managedParamIds = new Set<string>();
|
||||||
|
paramFields.forEach((section) => {
|
||||||
|
section.options.forEach((option) => {
|
||||||
|
managedParamIds.add(option.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const preserved = new URLSearchParams();
|
||||||
|
currentParams.forEach((value, key) => {
|
||||||
|
if (!managedParamIds.has(key)) {
|
||||||
|
preserved.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return preserved;
|
||||||
|
}
|
||||||
|
|||||||