Compare commits
609 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| fe8bbd5003 | |||
| c6e38a2b4a | |||
| 56f18fe71c | |||
| 0b9306de82 | |||
| 3b1e6cf33d | |||
| dda219ebbe | |||
| 8bb771e5c6 | |||
| 8723bcbd33 | |||
| 0dcea4f2d7 | |||
| 696c016c90 | |||
| eed6373dbf | |||
| ac61f257c0 | |||
| 8d8625aa77 | |||
| 1e1aa4bca5 | |||
| 1f0cde6d0d | |||
| c4b66a9ed3 | |||
| 133b3aa819 | |||
| 9566fadbbb | |||
| 62570899e8 | |||
| c1f25f8dba | |||
| 37a91026d8 | |||
| c62ce213f5 | |||
| 529751578d | |||
| dce87c3a81 | |||
| a94b10cb0f | |||
| 6817263123 | |||
| ddd629d7db | |||
| 9d2a3d5a12 | |||
| 4bc271208e | |||
| dbd535dd6c | |||
| 57b7fe214a | |||
| b136e2856a | |||
| 03e68c03a9 | |||
| 44048a889d | |||
| f72db62e08 | |||
| 333f2623a5 | |||
| 3ebb3451e4 | |||
| 73afbacdd7 | |||
| d08452d613 | |||
| 4ebe6ede59 | |||
| 09a32e7612 | |||
| d81f5fcfe1 | |||
| 0a6542df76 | |||
| b2eb140ee2 | |||
| e28f692d3d | |||
| dc73e7e637 | |||
| b81e3a4ba5 | |||
| 6fc7b15d23 | |||
| 99dd4b4d5c | |||
| b60b1beeec | |||
| eab916f410 | |||
| 069a003787 | |||
| 9b08080b7a | |||
| 8e7ab54d39 | |||
| 4a16378786 | |||
| e5b30770ce | |||
| 8465b04c89 | |||
| 718a6ad2b7 | |||
| 6ca4bfc363 | |||
| 4859026989 | |||
| 9167a50eda | |||
| 8adca908b3 | |||
| bf99646ad5 | |||
| 308285ce3e | |||
| dd781a32f6 | |||
| c2a6de6195 | |||
| 0ac2ce8e84 | |||
| 3bdd8424eb | |||
| b3a099255f | |||
| 570ff59cfc | |||
| d0b4c6a279 | |||
| e90161aa33 | |||
| e37254107e | |||
| 9d6bc0a137 | |||
| 4cd2dcbab0 | |||
| 8c593b3487 | |||
| ba74622569 | |||
| f76c0d1a6d | |||
| 8e8d3a7824 | |||
| 6c3ae1f914 | |||
| b6507c27a6 | |||
| 21877e4bff | |||
| b8d7324be0 | |||
| 88b8e83494 | |||
| abce0c498c | |||
| 6b2e45e7de | |||
| 5fb30c75c4 | |||
| 636f78e21e | |||
| 1636745fed | |||
| e01ab0c88b | |||
| 0ede753652 | |||
| 1f6f3df0f2 | |||
| a5afb32b8a | |||
| 9c41f8ff6b | |||
| 1edde03bb4 | |||
| 946b6717d2 | |||
| f99ad83049 | |||
| 665496907e | |||
| 2867c8301d | |||
| 84a5b4fe5b | |||
| 86bb739d90 | |||
| 2c8b6180d7 | |||
| 4cc138e3c0 | |||
| d21e1f996f | |||
| 11c1b472a2 | |||
| 891bda6661 | |||
| c65761448d | |||
| 70558c5104 | |||
| c8bf74a719 | |||
| 10bf4b93ee | |||
| f6449a4326 | |||
| 8d38f52b69 | |||
| 3d40e31730 | |||
| 8ac5c0a9a2 | |||
| 649f0af2cf | |||
| fd9b944ecf | |||
| 0b36608134 | |||
| bac11adeb1 | |||
| 31799f8fab | |||
| af177060ff | |||
| d26a3cd393 | |||
| 38a3165a79 | |||
| 1715c19747 | |||
| 6be6e5fd8d | |||
| 44b0cfd928 | |||
| 3d50b8ce49 | |||
| c3d66f5bd1 | |||
| d08787a6ba | |||
| f0e6231338 | |||
| 430bfb38f2 | |||
| 60354f8ea3 | |||
| bc12531729 | |||
| 0b8a694e4f | |||
| 73ada99596 | |||
| f509655d31 | |||
| fb1bbd6d5c | |||
| 221ff7b3af | |||
| 5d5b48aca6 | |||
| 0f57689750 | |||
| d34280c03d | |||
| bb1e9072fd | |||
| 4503d1e411 | |||
| b4caec0644 | |||
| 960f0322ad | |||
| a12694b186 | |||
| a1d34c8f6c | |||
| 7f8dbbfa87 | |||
| 39e012452b | |||
| c333117347 | |||
| eb0e5e6420 | |||
| 8ba65fa2bd | |||
| efe5ac16f2 | |||
| f4f266dbd4 | |||
| bc9345199b | |||
| 2cc434b0e9 | |||
| 1f71d4578c | |||
| 6c4d80158c | |||
| ab4dd300ca | |||
| 8b84963e17 | |||
| 05e61e7bf8 | |||
| a8fe4c8e68 | |||
| d175c788c5 | |||
| f1f628a48e | |||
| d58335d1a1 | |||
| 78c2bd79b5 | |||
| 28452d1e38 | |||
| 46790441e0 | |||
| 3f35b5e297 | |||
| 49cae647d6 | |||
| 90700c85e8 | |||
| 8fac23c078 | |||
| b5cf6c4bd1 | |||
| 0ea00ff6be | |||
| 30e290c320 | |||
| ac52b3f0c1 | |||
| 8e3db6c559 | |||
| 1d2013ad41 | |||
| 9c910c079d | |||
| 420cef8b0d | |||
| b4c7b84f92 | |||
| 0450eb9169 | |||
| 7fe5223af9 | |||
| ce8d534953 | |||
| 4a6fbc2a92 | |||
| 50ebcf4e14 | |||
| f0f7d1cede | |||
| d2bdb1ea03 | |||
| 9be6febcda | |||
| aa929fd0dc | |||
| 7baa6a4ab9 | |||
| 082fe8b8d7 | |||
| e9a7cd0400 | |||
| 9ba40c35a1 | |||
| f94db06812 | |||
| 35424cfff2 | |||
| 13eadeb0af | |||
| 9cfed5a150 | |||
| a22c2d380d | |||
| cd913fc144 | |||
| 743d7fa782 | |||
| 891c43ba77 | |||
| fba20b889e | |||
| bc4d913ba7 | |||
| 533f9798e8 | |||
| 1b62ed5f2d | |||
| bd0c951da8 | |||
| 2d5b86808f | |||
| c69bce3e89 | |||
| d62e314d19 | |||
| dbd56522f6 | |||
| 1ba48c6a6f | |||
| f4f24da47b | |||
| c3609b2aed | |||
| 260d326989 | |||
| a21e32a097 | |||
| 617e4b579b | |||
| 44f65b2fcc | |||
| 760de608e3 | |||
| 0dfef732bf | |||
| f65ec0da3c | |||
| 6bbf8ce795 | |||
| 7f39d035fd | |||
| bdd2a62cee | |||
| b38a7e5c92 | |||
| b173b5a8e8 | |||
| 29243e3f6c | |||
| 1af394cd49 | |||
| 1c85265623 | |||
| 6199d0ad10 | |||
| 2f2c26ed00 | |||
| c9ef2465f8 | |||
| 430b72c1d0 | |||
| 88b114020b | |||
| c445c42f8a | |||
| 2fc4e6da16 | |||
| 4cbe25a845 | |||
| ab6765b332 | |||
| fb38d82855 | |||
| 1061ed2a0e | |||
| 3d37f42fe0 | |||
| 1bdea8e436 | |||
| 2c25910863 | |||
| 8f30c0a7df | |||
| 67fa747aae | |||
| 8116b169b2 | |||
| ec823e0095 | |||
| c7faab00a3 | |||
| cfcf6a5684 | |||
| df1cf7a96b | |||
| 0f3feca7ab | |||
| 41fe213ebb | |||
| e91d5f5cd9 | |||
| bedab221d9 | |||
| 97208c052a | |||
| b048d0f88d | |||
| d8f7d4bba6 | |||
| ed65a4db67 | |||
| 4a70bcfe64 | |||
| 89e68c3b0e | |||
| 976dc9cbde | |||
| 3701ba5174 | |||
| acc6ba63ba | |||
| 6c5afcb8d9 | |||
| d46dfcf82e | |||
| 4c6acc4013 | |||
| 26a24449de | |||
| a1fb642441 | |||
| b64b154330 | |||
| 5032cbf65a | |||
| 466360f9d1 | |||
| ef2ea9a1da | |||
| fb83f48752 | |||
| c1d53b0e55 | |||
| 7915cc822d | |||
| 7dbf64d100 | |||
| 6ffbf2af9d | |||
| fa7ec623f1 | |||
| bfb9b51073 | |||
| 8bc3f5a56c | |||
| acf1afb5e9 | |||
| d8e2a8d092 | |||
| 8e587128a1 | |||
| 607bc40673 | |||
| e6aa7404f7 | |||
| d622a7738f | |||
| b9ba416366 | |||
| b3ce247f54 | |||
| 82a2660fbc | |||
| 1e8706aa73 | |||
| 7dec32aea7 | |||
| 7e4cf6ffce | |||
| e6dfe9c363 | |||
| 29e682ee06 | |||
| 55d349877b | |||
| 8f2a93d6a3 | |||
| 6529a05e2a | |||
| 0f5c47bc82 | |||
| f561e52bb5 | |||
| 35f4472daa | |||
| 272f788c15 | |||
| 85b71627a8 | |||
| 6e51e5083b | |||
| 1f998fda74 | |||
| e7b5baa49a | |||
| f3f4cdb19f | |||
| b489214004 | |||
| cc55414b57 | |||
| e694cb7556 | |||
| 988100c1d9 | |||
| b4eabfae72 | |||
| 22fe65c655 | |||
| 7bf90d669a | |||
| a518dc4882 | |||
| dd5ffb2f47 | |||
| c5a5eab225 | |||
| 5d6345717f | |||
| 4c88f90a15 | |||
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 | |||
| 18575fc558 | |||
| 04e2593939 | |||
| 73d6b97afa | |||
| b55724ad0e | |||
| 3a7c2b8a55 | |||
| 420dfae652 | |||
| fb6896de76 | |||
| f574fdbc02 | |||
| 597be4e710 | |||
| 6e8734c73f | |||
| 9440ad17d2 | |||
| 9ba94288a1 | |||
| 6c78d25af1 | |||
| 0d8c186c77 | |||
| 7ccd41eeee | |||
| 43b390a079 | |||
| 80ff3251ea | |||
| 3591b93c2d | |||
| b637621871 | |||
| ed2efe183b | |||
| 27f99b4c87 | |||
| ed4d0f060b | |||
| 7c19c52332 | |||
| b1bff22b84 | |||
| 83b9fde45e | |||
| 37974df5c6 | |||
| 161ec383fd | |||
| de38f9d467 | |||
| 8ad1f2cb38 | |||
| 143917f11a | |||
| a8756c8afe | |||
| 5501f57b45 | |||
| 46e9fcb3cb | |||
| f2d7f4ab82 | |||
| a5496434f8 | |||
| 42984eed87 | |||
| 59c1c77d48 | |||
| dd13eb31d8 | |||
| 82810f1cb1 | |||
| 71c468d069 | |||
| cf40001a2a | |||
| d458928090 | |||
| 76e12af116 | |||
| b577057768 | |||
| dd4a165b08 | |||
| ab847a324b | |||
| cc5c097ddc | |||
| ea2330e23b | |||
| c5870452e3 | |||
| b73b529c00 | |||
| 8ef807d6cb | |||
| 1d4ddea596 | |||
| 4cba970cda | |||
| bad9dab0a0 | |||
| f11218a757 | |||
| 77e87e1d0c | |||
| a4aa513d1f | |||
| b73448f827 | |||
| 687ad68726 | |||
| 089eba3877 | |||
| 24b0cfd24e | |||
| 5950551da2 | |||
| e46948772e | |||
| 78fa1df99c | |||
| 0500368982 | |||
| 2d53323f60 | |||
| 08ca3cd3a5 | |||
| 16fd44a441 | |||
| 9a6ca7ac67 | |||
| 2c63bbff76 | |||
| d2c34757f9 | |||
| aeadc78578 | |||
| bf98f9792a | |||
| 6d2b59ef73 | |||
| 0a53018930 | |||
| f1cac8d1c4 | |||
| cfb76153a0 | |||
| fee2021ad7 | |||
| db956f7955 | |||
| 887e5c448e | |||
| 6ce275da7a | |||
| 213f516f71 | |||
| 86e6f8b58c | |||
| 1de062a38e | |||
| eb7a30fcf3 | |||
| e2cbe43a2b | |||
| 7877af571f | |||
| 086bd5b644 | |||
| f045cf2292 | |||
| 78e2f217a4 | |||
| 8a5cef79a8 | |||
| 4d8bea2940 | |||
| fa709dc9be | |||
| 177c9a35ec | |||
| 7f7691a452 | |||
| 92a16ea33b | |||
| bc6b7c5596 | |||
| 66083813f9 | |||
| d8cf1abb37 | |||
| 5b471980f5 | |||
| ad8f6dfcc7 | |||
| 4706ed39f6 | |||
| a8182cac8c | |||
| f9a5c55c07 | |||
| 980ae7a7c5 | |||
| db8ed93d41 | |||
| f8ca5b9cef | |||
| b9db08089f | |||
| a3020ef3b2 | |||
| 2492972097 | |||
| aa4ee546ec | |||
| 0abaf7724d | |||
| 7b932ef0f7 | |||
| 8bd7645c95 | |||
| 81f0e61953 | |||
| 3877e207d0 | |||
| d78e8838c3 | |||
| 16c7107069 | |||
| e20cd87673 | |||
| db5fbd8019 | |||
| 25aefe322b | |||
| 73db14c383 | |||
| f5936e5254 | |||
| 25c7915cf7 | |||
| c43b6f37f9 | |||
| 6e5034ef72 | |||
| 66774321a5 | |||
| d71df886f2 | |||
| abcee741e2 | |||
| ee1b5b7fdd | |||
| 1b1823e0fe | |||
| 340f5478fb | |||
| f708dc0bc4 | |||
| 49467c66b7 | |||
| b539b2ecd5 | |||
| dc06a9afc3 | |||
| d3620b52f4 | |||
| 66ed92e215 | |||
| 347f333b3e | |||
| c3fb3061f6 | |||
| a26aa9e01c | |||
| da11ef64c2 | |||
| 3012d73285 | |||
| bad491063d | |||
| a103f8b347 | |||
| dffccd258f | |||
| ce8b0d9850 | |||
| e500fa6b5e | |||
| 67be4a5631 | |||
| c7893938eb | |||
| 158ef05ff0 | |||
| 20838c038a | |||
| 1ddcec993a | |||
| 91d6adf8e0 | |||
| a7fe5eceef | |||
| e6e00c0d4a | |||
| edc7f20d7a | |||
| cb773ded9f | |||
| 2f65711078 | |||
| 3056b75960 | |||
| dd9423abf4 | |||
| dee6a67018 | |||
| a79fb4ecbb | |||
| a53a1f87be | |||
| ef9afcbf3e | |||
| 33b04e01c9 | |||
| c5e821df35 | |||
| 0fd71c9a00 | |||
| 53b453abc8 | |||
| fe0665506e | |||
| 9bd600c4b1 | |||
| bbd4254637 | |||
| 2603c777ef | |||
| 8942a4ba4f | |||
| f97be749dc | |||
| 5836d5ffc7 | |||
| 91f21fdeb1 | |||
| 844eec132b | |||
| adc4b3f8bf | |||
| fb09631db9 | |||
| 19febb98a5 | |||
| 6bfa11dd8d | |||
| 4758bc84b5 | |||
| 917b182e39 | |||
| e6c3322321 | |||
| 00d34eec9b | |||
| 84792bc00d | |||
| dae32e89a3 | |||
| 367997fd16 | |||
| 751c3329f0 | |||
| 9c5e403b18 | |||
| c944e77bc1 | |||
| d94c1d4252 | |||
| 9f395cb16d | |||
| 46195fc043 | |||
| c9ef8d55c1 | |||
| ad0e821cc0 | |||
| 21454947e0 | |||
| ad69c0ff80 | |||
| 20d9df2501 | |||
| de9af5aaa2 | |||
| 61280b06b7 | |||
| ce4a48cd8d | |||
| 2877475a35 | |||
| 73e23ee323 | |||
| 938c5fbb4c | |||
| e90e94828d | |||
| 1f0401632e | |||
| 5c3989c9b6 | |||
| 6ad26f06c7 | |||
| a63fa68d39 | |||
| 20503de6fd | |||
| 0335da9786 | |||
| 9f1a64f53c | |||
| 975356f4f4 | |||
| 1b448835cd | |||
| cbf3d566b9 | |||
| 6c20cb5e99 | |||
| cfc3aab893 |
@@ -14,12 +14,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac
|
||||
run: pnpm dist-mac --env-mode=loose
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -89,12 +89,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Ontime CLI build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -20,13 +20,13 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Docker Image CI Ontime
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,8 +14,6 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2.1.0
|
||||
@@ -32,10 +30,10 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
# Push is a shorthand for --output=type=registry
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||
|
||||
- name: Build and push pre-release
|
||||
if: github.event.release.prerelease == true
|
||||
@@ -43,8 +41,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
# Push is a shorthand for --output=type=registry
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -28,6 +28,7 @@ test-results
|
||||
playwright-report
|
||||
/playwright/.cache/
|
||||
automated-screenshots
|
||||
e2e/tests/fixtures/tmp/*
|
||||
|
||||
# production
|
||||
build/
|
||||
@@ -39,9 +40,7 @@ dist/
|
||||
# docker utils
|
||||
ontime-db
|
||||
ontime-external/
|
||||
|
||||
# working database
|
||||
apps/server/src/preloaded-db/db.json
|
||||
ontime-data/
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (~18.18.2)
|
||||
- __pnpm__ (>=8)
|
||||
- __node__ (~22)
|
||||
- __pnpm__ (~10)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## LOCAL DEVELOPMENT
|
||||
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime .`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
Other useful commands
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
FROM node:18.18-alpine AS builder
|
||||
FROM node:22-bullseye AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||
|
||||
FROM node:18.18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
ENV NODE_ENV=docker
|
||||
# Ontime Data path
|
||||
ENV ONTIME_DATA=/external/
|
||||
ENV ONTIME_DATA=/data/
|
||||
|
||||
WORKDIR /app/
|
||||
|
||||
@@ -22,8 +22,9 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY ./demo-db/ ./preloaded-db/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
@@ -33,4 +34,4 @@ CMD ["node", "server/docker.cjs"]
|
||||
# Build and run commands
|
||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
@@ -11,10 +13,16 @@
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
|
||||
... or
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
||||
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||
|
||||
## Need help?
|
||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
||||
|
||||
## Using Ontime?
|
||||
|
||||
Let us know!
|
||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||
|
||||
@@ -34,6 +42,16 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Theatres and opera houses
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
|
||||
... and a lot more ...
|
||||
|
||||
### For live environments
|
||||
|
||||
Ontime is designed for use in live environments. \
|
||||
@@ -82,6 +100,7 @@ IP.ADDRESS:4001/clock > Simple clock view
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
IP.ADDRESS:4001/timeline > Timeline
|
||||
```
|
||||
|
||||
```
|
||||
@@ -99,26 +118,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Main features
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Collaborative
|
||||
- [x] Extendable
|
||||
- [x] Send messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
@@ -160,8 +166,9 @@ If you are a developer and would like to contribute with code, please open an is
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
## Links
|
||||
|
||||
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.3.2",
|
||||
"version": "3.16.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -20,8 +20,8 @@
|
||||
"server"
|
||||
],
|
||||
"devDependencies": {
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.3"
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
],
|
||||
"plugins": [
|
||||
"react",
|
||||
"testing-library",
|
||||
"simple-import-sort",
|
||||
"@tanstack/query"
|
||||
],
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta name='theme-color' content='#121212' />
|
||||
<meta name='ontime' content='ontime - event timer manager' />
|
||||
<link rel='apple-touch-icon' href='/logo192.png' />
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='32x32'
|
||||
href='/favicon-32x32.png'
|
||||
/>
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='16x16'
|
||||
href='/favicon-16x16.png'
|
||||
/>
|
||||
<link rel='manifest' href='/site.webmanifest' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id='root'></div>
|
||||
<script type='module' src='/src/index.tsx'></script>
|
||||
</body>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.3.2",
|
||||
"version": "3.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -8,29 +8,33 @@
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.92.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"framer-motion": "^10.10.0",
|
||||
"react": "^18.2.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.5.2"
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -39,10 +43,9 @@
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
|
||||
@@ -63,34 +66,30 @@
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"eslint-plugin-testing-library": "^5.9.1",
|
||||
"jsdom": "^21.1.0",
|
||||
"happy-dom": "^16.7.2",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.3.1",
|
||||
"prettier": "catalog:",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
"typescript": "catalog:",
|
||||
"vite": "6.3.1",
|
||||
"vite-plugin-compression2": "1.4.0",
|
||||
"vite-plugin-svgr": "4.3.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -4,18 +4,11 @@
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"src": "ontime-logo.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"scope": "/",
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +1,10 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#2B5ABC",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
import { baseURI } from './externals';
|
||||
|
||||
connectSocket();
|
||||
|
||||
@@ -19,7 +20,7 @@ function App() {
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={baseURI}>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
|
||||
@@ -1,68 +1,171 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./views/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
const PCuesheet = withPreset(Cuesheet);
|
||||
const POperator = withPreset(Operator);
|
||||
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
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'));
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
integrations: [
|
||||
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||
useEffect: React.useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 0.3,
|
||||
release: ONTIME_VERSION,
|
||||
enabled: import.meta.env.PROD,
|
||||
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
});
|
||||
|
||||
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
|
||||
export default function AppRouter() {
|
||||
// handle client path changes
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
|
||||
<Route
|
||||
path='/timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/public'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SPublic />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/minimal'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SMinimalTimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/clock'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
<Route
|
||||
path='/timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route
|
||||
path='/op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
@@ -99,7 +202,7 @@ export default function AppRouter() {
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</SentryRoutes>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post css contents
|
||||
*/
|
||||
export async function postCSSContents(css: string): Promise<void> {
|
||||
await axios.post(`${assetsPath}/css`, {
|
||||
css,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to restore default css
|
||||
*/
|
||||
export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const automationsPath = `${apiEntryUrl}/automations`;
|
||||
|
||||
/**
|
||||
* HTTP request to get the automations settings
|
||||
*/
|
||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit the automations settings
|
||||
*/
|
||||
export async function editAutomationSettings(
|
||||
automationSettings: Partial<AutomationSettings>,
|
||||
): Promise<AutomationSettings> {
|
||||
const res = await axios.post(automationsPath, automationSettings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation trigger
|
||||
*/
|
||||
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
|
||||
const res = await axios.post(`${automationsPath}/trigger`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update an automation trigger
|
||||
*/
|
||||
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
|
||||
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete an automation trigger
|
||||
*/
|
||||
export function deleteTrigger(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/trigger/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation
|
||||
*/
|
||||
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
|
||||
const res = await axios.post(`${automationsPath}/automation`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update a automation
|
||||
*/
|
||||
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
|
||||
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a automation
|
||||
*/
|
||||
export function deleteAutomation(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/automation/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to test automation output
|
||||
* The return is irrelevant as we care for the resolution of the promise
|
||||
*/
|
||||
export function testOutput(output: AutomationOutput): Promise<void> {
|
||||
return axios.post(`${automationsPath}/test`, output);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
// keys in tanstack store
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const AUTOMATION = ['automation'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const PROJECT_LIST = ['projectList'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
@@ -12,24 +14,16 @@ export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
||||
export const userAssetsPath = 'user';
|
||||
export const cssOverridePath = 'styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||
import { makeCSVFromArrayOfArrays } from '../utils/csv';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
@@ -11,8 +12,8 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
@@ -86,6 +87,14 @@ export async function createProject(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a project file
|
||||
*/
|
||||
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/quick`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get the list of available project files
|
||||
*/
|
||||
@@ -104,6 +113,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to load the demo project file
|
||||
*/
|
||||
export async function loadDemo(): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/demo`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to duplicate a project file
|
||||
*/
|
||||
@@ -123,7 +140,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
filename: newFilename,
|
||||
newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -138,14 +155,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${dbPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const httpPath = `${apiEntryUrl}/http`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve http settings
|
||||
*/
|
||||
export async function getHTTP(): Promise<HttpSettings> {
|
||||
const res = await axios.get(httpPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate http settings
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
return axios.post(httpPath, data);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const oscPath = `${apiEntryUrl}/osc`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve osc settings
|
||||
*/
|
||||
export async function getOSC(): Promise<OSCSettings> {
|
||||
const res = await axios.get(oscPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate osc settings
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(oscPath, data);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||
return axios.post(projectPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload a project logo
|
||||
*/
|
||||
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios';
|
||||
import { OntimeReport } from 'ontime-types';
|
||||
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, REPORT } from './constants';
|
||||
|
||||
export const reportUrl = `${apiEntryUrl}/report`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all reports
|
||||
*/
|
||||
export async function fetchReport(): Promise<OntimeReport> {
|
||||
const res = await axios.get(`${reportUrl}/`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteReport(id: string) {
|
||||
await axios.delete(`${reportUrl}/${id}`);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
}
|
||||
|
||||
export async function deleteAllReport() {
|
||||
await axios.delete(`${reportUrl}/all`);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
||||
/**
|
||||
* HTTP request to post new event
|
||||
*/
|
||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
return res.data.url;
|
||||
}
|
||||
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
|
||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||
return axios.post(settingsPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting the welcome modal dialog state from the clients
|
||||
*/
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
return `${statusText}: ${data}`;
|
||||
} else if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
@use "../../../theme/mixins" as *;
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLink(props: AppLinkProps) {
|
||||
const { href, children } = props;
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
||||
{children} <IoArrowUp className={style.linkIcon} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.subtle {
|
||||
aspect-ratio: 1;
|
||||
height: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './IconButton.module.scss';
|
||||
|
||||
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...buttonProps } = props;
|
||||
return (
|
||||
<button className={cx([style.subtle, className])} {...buttonProps}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -$element-spacing;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.textEntry {
|
||||
grid-area: input;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.inlineEntry {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'label label'
|
||||
'input btn';
|
||||
grid-template-columns: 1fr auto;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.redirect {
|
||||
grid-area: btn;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $label-gray;
|
||||
}
|
||||
@@ -1,68 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowForward } from 'react-icons/io5';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import Info from '../info/Info';
|
||||
import AppLink from '../link/app-link/AppLink';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
name: string;
|
||||
currentPath: string;
|
||||
origin: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||
const { id, isOpen, name, currentPath, origin, onClose } = props;
|
||||
const { data } = useUrlPresets();
|
||||
const [path, setPath] = useState(currentPath);
|
||||
const [selected, setSelected] = useState('/');
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
const handleRedirect = () => {
|
||||
if (path !== currentPath && path !== '') {
|
||||
setRedirect({ target: id, redirect: path });
|
||||
const handleRedirect = (newPath: string) => {
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const host = `${window.location.origin}/`;
|
||||
const canSubmit = path !== currentPath && path !== '';
|
||||
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalContent maxWidth='max(480px, 35vw)'>
|
||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InputGroup variant='ontime-filled' size='md'>
|
||||
<InputLeftAddon>{host}</InputLeftAddon>
|
||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.textEntry}>
|
||||
<Select
|
||||
size='md'
|
||||
variant='ontime'
|
||||
isDisabled={enabledPresets.length === 0}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
>
|
||||
<option value='/'>Select view or preset</option>
|
||||
{navigatorConstants.map((view) => {
|
||||
return (
|
||||
<option key={view.url} value={`/${view.url}`}>
|
||||
{view.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{enabledPresets.map((preset) => {
|
||||
return (
|
||||
<option key={preset.pathAndParams} value={preset.pathAndParams}>
|
||||
{`Preset: ${preset.alias}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
icon={<IoArrowForward />}
|
||||
isDisabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
placeholder='eg. /minimal?key=0000ffff'
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
aria-label='Redirect'
|
||||
isDisabled={path === currentPath || path === ''}
|
||||
className={style.redirect}
|
||||
icon={<IoArrowForward />}
|
||||
onClick={() => handleRedirect(path)}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { ContextMenuOption } from './ContextMenuOption';
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IoCopy } from 'react-icons/io5';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', disabled, children } = props;
|
||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||
<ButtonGroup size={size} isAttached>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { millisToDelayString } from '../../utils/dateConfig';
|
||||
@@ -9,32 +8,23 @@ import style from './DelayIndicator.module.scss';
|
||||
|
||||
interface DelayIndicatorProps {
|
||||
delayValue?: number;
|
||||
tooltipPrefix?: string;
|
||||
}
|
||||
|
||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
const { delayValue } = props;
|
||||
const { delayValue, tooltipPrefix } = props;
|
||||
|
||||
if (typeof delayValue === 'number') {
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (typeof delayValue !== 'number' || delayValue === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const delayString = tooltipPrefix
|
||||
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
|
||||
: millisToDelayString(delayValue);
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
|
||||
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,22 @@ class ErrorBoundary extends React.Component {
|
||||
error,
|
||||
errorInfo: info,
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
|
||||
const appState = runtimeStore.getState();
|
||||
if (appState.ping < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras('error', error);
|
||||
scope.setExtras('store', runtimeStore.getState());
|
||||
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
|
||||
scope.setExtras({
|
||||
error,
|
||||
store: appState,
|
||||
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||
});
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copied from
|
||||
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
|
||||
*/
|
||||
|
||||
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { bsearch } from './fitText.utils';
|
||||
|
||||
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
mode?: 'single' | 'multi';
|
||||
min?: number; // inclusive
|
||||
max?: number; // inclusive
|
||||
}
|
||||
|
||||
export function FitText(props: PropsWithChildren<FitTextProps>) {
|
||||
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOverflown = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return false;
|
||||
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
||||
}, []);
|
||||
|
||||
const setFontSize = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const originVisibility = el.style.visibility;
|
||||
|
||||
el.style.visibility = 'hidden';
|
||||
const fontSize = bsearch(min, max + 1, (mid) => {
|
||||
el.style.fontSize = `${mid}px`;
|
||||
return !isOverflown();
|
||||
});
|
||||
el.style.fontSize = `${fontSize}px`;
|
||||
el.style.visibility = originVisibility;
|
||||
}, [isOverflown, min, max]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
setFontSize();
|
||||
const observer = new ResizeObserver(setFontSize);
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [children, mode, setFontSize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
|
||||
}}
|
||||
{...elementProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @param low inclusive, must be true on predicate function
|
||||
* @param high exclusive,
|
||||
* @param predicate predicate function
|
||||
*/
|
||||
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (mid === low) break;
|
||||
|
||||
if (predicate(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
};
|
||||
@@ -54,7 +54,6 @@ function Overlay() {
|
||||
};
|
||||
}, [showOverlay, id, setIdentify, handleClose]);
|
||||
|
||||
console.log('here2');
|
||||
return (
|
||||
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||
<div className={style.name}>{name}</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
|
||||
background-color: $gray-1200;
|
||||
border-radius: 3px;
|
||||
font-size: $text-body-size;
|
||||
padding: 1rem;
|
||||
|
||||
.content {
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
svg {
|
||||
min-width: 1.5rem;
|
||||
align-self: start;
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoAlertCircle } from 'react-icons/io5';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
interface InfoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Info(props: PropsWithChildren<InfoProps>) {
|
||||
const { className, children } = props;
|
||||
|
||||
return (
|
||||
<div className={cx([style.infoLabel, className])}>
|
||||
<IoAlertCircle />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
||||
import { IoBan } from 'react-icons/io5';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
|
||||
const handleClick = () => {
|
||||
onClick?.(color);
|
||||
};
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
<div className={classes} onClick={handleClick}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { IoEyedrop } from 'react-icons/io5';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface SwatchPickerProps {
|
||||
color: string;
|
||||
isSelected?: boolean;
|
||||
onChange: (name: string) => void;
|
||||
alwaysDisplayColor?: boolean;
|
||||
}
|
||||
|
||||
export default function SwatchPicker(props: SwatchPickerProps) {
|
||||
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
||||
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((newValue: string) => {
|
||||
onChange(newValue);
|
||||
}, 500),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
|
||||
<div
|
||||
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
|
||||
style={{ backgroundColor: displayColor }}
|
||||
>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
const displayColor = typeof value === 'string' ? value : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={value as string} onChange={onChange}>
|
||||
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid $blue-500;
|
||||
@@ -17,11 +20,9 @@
|
||||
|
||||
&.selectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
import SwatchPicker from './SwatchPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
outline: none;
|
||||
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import style from './PopoverPicker.module.scss';
|
||||
|
||||
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
return <PopoverPicker color={value as string} onChange={onChange} />;
|
||||
}
|
||||
|
||||
interface PopoverPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||
const { color, onChange, children } = props;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -47,16 +50,25 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit !== initialText) {
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
|
||||
setTimeout(() => {
|
||||
if (options?.allowKeyboardNavigation) {
|
||||
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
|
||||
} else {
|
||||
ref.current?.blur();
|
||||
}
|
||||
}); // Immediate timeout to ensure text is set before blurring
|
||||
},
|
||||
[initialText, ref, submitCallback],
|
||||
[initialText, options, ref, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -66,11 +78,25 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, ref]);
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
}, [initialText, options, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
const hotKeys: HotkeyItem[] = [
|
||||
[
|
||||
'Escape',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
handleEscape();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
@@ -79,7 +105,22 @@ export default function useReactiveTextInput(
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
return getHotkeyHandler(hotKeys);
|
||||
|
||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||
|
||||
return (event: KeyboardEvent<HTMLElement>) => {
|
||||
// allow moving in input field with arrow keys
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
|
||||
interface TimeInputProps<T extends string> {
|
||||
id?: T;
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
|
||||
}
|
||||
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -54,6 +55,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
submitHandler(name, newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
@@ -117,6 +119,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
|
||||
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
color: $label-gray;
|
||||
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.link {
|
||||
color: $label-gray;
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
color: $blue-400;
|
||||
|
||||
&:hover {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component used to navigate to an editor link inside the same window
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={style.link}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
import { IoOpenOutline } from 'react-icons/io5';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { openLink } from '../../../utils/linkUtils';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './ExternalLink.module.scss';
|
||||
|
||||
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
|
||||
|
||||
export default function ExternalLink(props: ExternalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
||||
{children} <IoOpenOutline />
|
||||
<a
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cx([style.link, inline && style.inline])}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-size: 1.5rem;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.multiprogress-bar {
|
||||
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
|
||||
.multiprogress-bar__bg-danger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.multiprogress-bar__bg-danger--overtime {
|
||||
background: var(--timer-overtime-color-override, $timer-finished-color);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { clamp } from '../../utils/math';
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: MaybeNumber;
|
||||
complete: number;
|
||||
complete: MaybeNumber;
|
||||
normalColor: string;
|
||||
warning?: MaybeNumber;
|
||||
warningColor: string;
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
hideOvertime?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -27,20 +29,25 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
hideOvertime,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
const showOvertime = isOvertime && !hideOvertime;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
className={cx([
|
||||
'multiprogress-bar',
|
||||
hidden && 'multiprogress-bar--hidden',
|
||||
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
{now !== null && (
|
||||
<>
|
||||
@@ -51,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { IoApps } from 'react-icons/io5';
|
||||
import { IoSettingsOutline } from 'react-icons/io5';
|
||||
|
||||
import { debounce } from '../../utils/debounce';
|
||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -13,32 +13,10 @@ interface FloatingNavigationProps {
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const isButtonShown = useFadeOutOnInactivity();
|
||||
|
||||
return (
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
|
||||
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
|
||||
$button-bg: $gray-1050;
|
||||
$button-size: 3rem;
|
||||
|
||||
.fadeable {
|
||||
opacity: 1;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
font-size: 1.5rem;
|
||||
color: $active-indicator;
|
||||
padding: 0.5em;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
opacity: 1;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -47,6 +61,8 @@ $button-size: 3rem;
|
||||
@include action-link;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -70,9 +86,33 @@ $button-size: 3rem;
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useRef } from 'react';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
@@ -11,18 +12,18 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -39,18 +40,15 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<div id='navigation-menu-portal'>
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerContent maxWidth='22rem'>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
@@ -58,7 +56,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, fullscreen && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
@@ -70,7 +68,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, mirror && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
@@ -96,39 +94,27 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<hr className={style.separator} />
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
</ClientLink>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
</ClientLink>
|
||||
))}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -137,4 +123,71 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface OtherAddressesProps {
|
||||
currentLocation: string;
|
||||
}
|
||||
|
||||
function OtherAddresses(props: OtherAddressesProps) {
|
||||
const { currentLocation } = props;
|
||||
const { data } = useInfo();
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.bottom}>
|
||||
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => openLink(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientLinkProps {
|
||||
current: boolean;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
||||
const { current, to, children } = props;
|
||||
const { isElectron } = useElectronEvent();
|
||||
|
||||
const classes = cx([style.link, current && style.current]);
|
||||
|
||||
if (isElectron) {
|
||||
return (
|
||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={`/${to}`} className={classes} tabIndex={0}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavigationMenu);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||
|
||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
export default function ViewLockedIcon() {
|
||||
const isLockIconShown = useFadeOutOnInactivity();
|
||||
|
||||
return (
|
||||
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
|
||||
<IoLockClosedOutline />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { memo } from 'react';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import useViewEditor from './useViewEditor';
|
||||
import ViewLockedIcon from './ViewLockedIcon';
|
||||
|
||||
interface ViewNavigationMenuProps {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
|
||||
const { isLockable } = props;
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
if (isViewLocked) {
|
||||
return <ViewLockedIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
interface EditorVisibilityOptions {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const isViewLocked = useMemo(() => {
|
||||
if (!isLockable) {
|
||||
return false;
|
||||
}
|
||||
return isStringBoolean(searchParams.get('locked'));
|
||||
}, [isLockable, searchParams]);
|
||||
|
||||
return { showEditFormDrawer, isViewLocked };
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
overflow: clip;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__indicator {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { clamp } from '../../utils/math';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
|
||||
import './ProgressBar.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
now?: number;
|
||||
complete?: number;
|
||||
hidden?: boolean;
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { now = 0, complete = 100, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
const { current, duration, className } = props;
|
||||
const progress = getProgress(current, duration);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
<div className={`progress-bar__bg ${className}`}>
|
||||
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
@@ -42,4 +46,4 @@
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
@@ -9,7 +10,6 @@ interface ProtectRouteProps {
|
||||
}
|
||||
|
||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(
|
||||
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
if (isLocalhost || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.schedule {
|
||||
width: 100%;
|
||||
border-spacing: 50px;
|
||||
|
||||
.entry {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
|
||||
.entry-colour {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.entry-times {
|
||||
font-family: $viewer-font-family;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&--past {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
|
||||
&--now {
|
||||
.entry-title {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.schedule-nav__item {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-left: 8px;
|
||||
|
||||
&--selected {
|
||||
background-color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleProps {
|
||||
isProduction?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
|
||||
// TODO: design a nice placeholder for empty schedules
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
|
||||
return (
|
||||
<ul className={`schedule ${className}`}>
|
||||
{paginatedEvents.map((event) => {
|
||||
if (scheduleType === 'past' || scheduleType === 'future') {
|
||||
selectedState = scheduleType;
|
||||
} else {
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
}
|
||||
|
||||
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
||||
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
selected={selectedState}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : ''}
|
||||
backstageEvent={!event.isPublic}
|
||||
skip={event.skip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
paginatedEvents: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
scheduleType: 'past' | 'now' | 'future';
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
isBackstage: boolean;
|
||||
}
|
||||
|
||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
events: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
isBackstage?: boolean;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const viewEvents = [...events];
|
||||
if (hidePast) {
|
||||
// we want to show the event after the next
|
||||
viewEvents.splice(0, selectedEventIndex + 2);
|
||||
selectedEventIndex = 0;
|
||||
}
|
||||
|
||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
return 'now';
|
||||
}
|
||||
if (selectedEventIndex > eventEnd) {
|
||||
return 'past';
|
||||
}
|
||||
return 'future';
|
||||
};
|
||||
const scheduleType = resolveScheduleType();
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
}, time * 1000);
|
||||
|
||||
return (
|
||||
<ScheduleContext.Provider
|
||||
value={{
|
||||
events,
|
||||
paginatedEvents,
|
||||
selectedEventId,
|
||||
scheduleType,
|
||||
numPages,
|
||||
visiblePage,
|
||||
isBackstage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScheduleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSchedule = () => {
|
||||
const context = useContext(ScheduleContext);
|
||||
if (!context) {
|
||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { formatTime } from '../../utils/time';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format12: 'hh:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
selected: 'past' | 'now' | 'future';
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
const userColour = colour !== '' ? colour : '';
|
||||
const selectStyle = `entry--${selected}`;
|
||||
|
||||
return (
|
||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||
<div style={{ display: 'flex' }}>
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent ? '*' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleNavProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleNav({ className }: ScheduleNavProps) {
|
||||
const { numPages, visiblePage } = useSchedule();
|
||||
|
||||
return (
|
||||
<div className={`schedule-nav ${className}`}>
|
||||
{numPages > 1 &&
|
||||
[...Array(numPages).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
const { text, ...rest } = props;
|
||||
const { text, className, ...rest } = props;
|
||||
return (
|
||||
<div className={style.emptyContainer} {...rest}>
|
||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
/* share the same style as a page layout */
|
||||
.page {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyPage.module.scss';
|
||||
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EmptyPage(props: EmptyPageProps) {
|
||||
const { text, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,42 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
.title-card__title,
|
||||
.title-card__placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.title-card__placeholder {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1.5rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
title?: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, secondary } = props;
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cx(['title-card', className])} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.viewLogo {
|
||||
max-width: 100%;
|
||||
max-height: 75px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { projectLogoPath } from '../../api/constants';
|
||||
|
||||
import './ViewLogo.scss';
|
||||
|
||||
interface ViewLogoProps {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ViewLogo(props: ViewLogoProps) {
|
||||
const { name, className } = props;
|
||||
|
||||
// we wrap the image in a div to help maintain the aspect ratio
|
||||
return (
|
||||
<div className={className}>
|
||||
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
interface InlineColourPickerProps {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ensureHex = (value: string) => {
|
||||
if (!value.startsWith('#')) {
|
||||
return `#${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
<span>{colour}</span>
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
@@ -23,10 +25,14 @@ interface EditFormInputProps {
|
||||
}
|
||||
|
||||
export default function ParamInput(props: EditFormInputProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'option') {
|
||||
const optionFromParams = searchParams.get(id);
|
||||
const defaultOptionValue = optionFromParams || defaultValue;
|
||||
@@ -77,6 +83,12 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const { prefix, placeholder } = paramField;
|
||||
|
||||
@@ -97,30 +109,26 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuList overflow='auto' maxHeight='200px'>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
value={paramState}
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
const { value, label, colour } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
|
||||
@@ -8,29 +8,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem
|
||||
min-height: 100%;
|
||||
gap: 2rem;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
display: flex;
|
||||
padding: $section-spacing 0;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
.info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||