mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
Compare commits
38 Commits
v4.7.0
...
v4.9.1-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c000118b | |||
| daf2cf67f4 | |||
| 6633cfc869 | |||
| d127367df9 | |||
| b21ec41ffd | |||
| 66292bc0cf | |||
| b946b245c2 | |||
| c238147b75 | |||
| 5f33c993f6 | |||
| 0e6d6cd416 | |||
| 199dea5df6 | |||
| 5a3ea9f7d0 | |||
| 9ae49a7175 | |||
| d30d9cfd4e | |||
| a0e7b9b665 | |||
| 1dcb7fb08d | |||
| a7e84b9903 | |||
| 3bebbf9ce9 | |||
| ab8da07518 | |||
| c8bae76121 | |||
| 2873698959 | |||
| 741ebef90e | |||
| 0741f46aca | |||
| 5a33ffad92 | |||
| 562f00dc7e | |||
| e50ab0150e | |||
| fe44b33da2 | |||
| 22e392ba9d | |||
| 4997602e9b | |||
| bdb8d4d89a | |||
| b1d5ee12e4 | |||
| a5e733246d | |||
| 1a08b39b8b | |||
| ba1c3235b3 | |||
| 7c1d2f4554 | |||
| 23a44b2f04 | |||
| 2d3c8e7eb2 | |||
| 2f138acad7 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
@@ -20,13 +20,13 @@ jobs:
|
|||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ jobs:
|
|||||||
CI: ''
|
CI: ''
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_macos:
|
build_macos:
|
||||||
runs-on: macOS-latest
|
runs-on: macOS-26
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
- name: Upload macOS build artifacts
|
- name: Upload macOS build artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: ontime-macos-build
|
name: ontime-macos-build
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
if: github.ref_type == 'tag'
|
if: github.ref_type == 'tag'
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
@@ -67,14 +67,14 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
run: pnpm dist-win
|
run: pnpm dist-win
|
||||||
|
|
||||||
- name: Upload Windows build artifacts
|
- name: Upload Windows build artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: ontime-windows-build
|
name: ontime-windows-build
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
if: github.ref_type == 'tag'
|
if: github.ref_type == 'tag'
|
||||||
with:
|
with:
|
||||||
files: './apps/electron/dist/ontime-win64.exe'
|
files: './apps/electron/dist/ontime-win64.exe'
|
||||||
@@ -104,14 +104,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
run: pnpm dist-linux
|
run: pnpm dist-linux
|
||||||
|
|
||||||
- name: Upload Linux build artifacts
|
- name: Upload Linux build artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: ontime-linux-build
|
name: ontime-linux-build
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
if: github.ref_type == 'tag'
|
if: github.ref_type == 'tag'
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
@@ -18,13 +18,13 @@ jobs:
|
|||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|||||||
+19
-11
@@ -13,16 +13,20 @@ jobs:
|
|||||||
CI: ''
|
CI: ''
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
@@ -52,16 +56,20 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_STORE_PATH }}
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
@@ -74,30 +82,30 @@ jobs:
|
|||||||
- name: Get installed Playwright version
|
- name: Get installed Playwright version
|
||||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||||
- name: Cache playwright binaries
|
- name: Cache playwright binaries
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/ms-playwright
|
~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-playwright-
|
${{ runner.os }}-playwright-
|
||||||
- run: npx playwright install --with-deps
|
- run: pnpm playwright install --with-deps
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: pnpm playwright install --with-deps
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: pnpm e2e
|
run: pnpm e2e
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v6
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: automated-screenshots
|
name: automated-screenshots
|
||||||
path: automated-screenshots/
|
path: automated-screenshots/
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG NODE_VERSION=22.15.1
|
ARG NODE_VERSION=22.22.2
|
||||||
FROM node:${NODE_VERSION}-alpine
|
FROM node:${NODE_VERSION}-alpine
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="icon" href="favicon.ico" />
|
<link rel="icon" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#101010" />
|
<meta name="theme-color" content="#101010" />
|
||||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
||||||
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
||||||
<link rel="manifest" href="site.webmanifest" />
|
<link rel="manifest" href="site.webmanifest" />
|
||||||
@@ -17,6 +18,13 @@
|
|||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
|
||||||
|
var b = document.querySelector('base');
|
||||||
|
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
|
||||||
|
window.location.replace(base + '/timer-legacy' + window.location.search);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-19
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.3.0",
|
"@base-ui/react": "1.6.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"@codemirror/commands": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -12,28 +12,27 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.6",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@mantine/hooks": "^8.3.7",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.2.0",
|
"@sentry/react": "^10.59.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.85.9",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.18.0",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.7",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.7.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.7",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.72.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.6.0",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.17.0",
|
"react-virtuoso": "^4.18.7",
|
||||||
"web-vitals": "^5.1.0",
|
"zustand": "^5.0.14"
|
||||||
"zustand": "^5.0.9"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
@@ -51,7 +50,8 @@
|
|||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
"not dead",
|
"not dead",
|
||||||
"not op_mini all"
|
"not op_mini all",
|
||||||
|
"safari >= 14.1"
|
||||||
],
|
],
|
||||||
"development": [
|
"development": [
|
||||||
"last 1 chrome version",
|
"last 1 chrome version",
|
||||||
@@ -64,8 +64,10 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-legacy": "^8.0.1",
|
||||||
"@vitejs/plugin-react": "5.2.0",
|
"@vitejs/plugin-react": "5.2.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
@@ -73,7 +75,6 @@
|
|||||||
"vite": "8.0.1",
|
"vite": "8.0.1",
|
||||||
"vite-plugin-compression2": "2.5.1",
|
"vite-plugin-compression2": "2.5.1",
|
||||||
"vite-plugin-svgr": "4.5.0",
|
"vite-plugin-svgr": "4.5.0",
|
||||||
"vite-tsconfig-paths": "6.1.1",
|
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"display": "",
|
"display": "",
|
||||||
"theme_color": "#121212",
|
"theme_color": "#121212",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#101010"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "",
|
"name": "",
|
||||||
"short_name": "",
|
"short_name": "",
|
||||||
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
||||||
"theme_color": "#2B5ABC",
|
"theme_color": "#121212",
|
||||||
"background_color": "#101010",
|
"background_color": "#101010",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
import {
|
||||||
|
EntryId,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
ProjectRundownsList,
|
||||||
|
RenumberCues,
|
||||||
|
Rundown,
|
||||||
|
TransientEventPayload,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import type { RequestOptions } from './requestOptions';
|
import type { RequestOptions } from './requestOptions';
|
||||||
@@ -26,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
|
|||||||
throw new Error('Invalid rundown payload');
|
throw new Error('Invalid rundown payload');
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
function isValidRundown(x: any): x is Rundown {
|
/**
|
||||||
return (
|
* HTTP request to fetch all entries in the given rundown
|
||||||
x &&
|
*/
|
||||||
typeof x === 'object' &&
|
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
|
||||||
typeof x.id === 'string' &&
|
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
|
||||||
Array.isArray(x.order) &&
|
if (!isValidRundown(res.data)) {
|
||||||
Array.isArray(x.flatOrder) &&
|
throw new Error('Invalid rundown payload');
|
||||||
x.entries &&
|
|
||||||
typeof x.entries === 'object' &&
|
|
||||||
typeof x.revision === 'number'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +117,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn
|
|||||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to renumber cues for multiple events
|
||||||
|
*/
|
||||||
|
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
|
||||||
|
}
|
||||||
|
|
||||||
export type ReorderEntry = {
|
export type ReorderEntry = {
|
||||||
entryId: EntryId;
|
entryId: EntryId;
|
||||||
destinationId: EntryId;
|
destinationId: EntryId;
|
||||||
@@ -182,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #endregion operations on rundown entries =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|
||||||
|
function isValidRundown(x: any): x is Rundown {
|
||||||
|
return (
|
||||||
|
x &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
typeof x.id === 'string' &&
|
||||||
|
Array.isArray(x.order) &&
|
||||||
|
Array.isArray(x.flatOrder) &&
|
||||||
|
x.entries &&
|
||||||
|
typeof x.entries === 'object' &&
|
||||||
|
typeof x.revision === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
/* eslint-disable react/destructuring-assignment */
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
|
|
||||||
import { runtimeStore } from '../../stores/runtime';
|
import { runtimeStore } from '../../stores/runtime';
|
||||||
|
import { getConnectionState, getReconnectAttempts } from '../../utils/socket';
|
||||||
|
|
||||||
import style from './ErrorBoundary.module.scss';
|
import style from './ErrorBoundary.module.scss';
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
scope.setExtras({
|
scope.setExtras({
|
||||||
error,
|
error,
|
||||||
store: appState,
|
store: appState,
|
||||||
hasSocket: { hasConnected, reconnectAttempts },
|
hasSocket: { hasConnected: getConnectionState(), reconnectAttempts: getReconnectAttempts() },
|
||||||
});
|
});
|
||||||
const eventId = Sentry.captureException(error);
|
const eventId = Sentry.captureException(error);
|
||||||
this.setState({ eventId, info });
|
this.setState({ eventId, info });
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
|
|||||||
|
|
||||||
interface UseReactiveTextInputReturn {
|
interface UseReactiveTextInputReturn {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (event: ChangeEvent) => void;
|
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||||
onBlur: (event: ChangeEvent) => void;
|
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
|
|||||||
options?: {
|
options?: {
|
||||||
submitOnEnter?: boolean;
|
submitOnEnter?: boolean;
|
||||||
submitOnCtrlEnter?: boolean;
|
submitOnCtrlEnter?: boolean;
|
||||||
|
submitOnTab?: boolean;
|
||||||
onCancelUpdate?: () => void;
|
onCancelUpdate?: () => void;
|
||||||
|
onTabCancel?: () => void;
|
||||||
allowSubmitSameValue?: boolean;
|
allowSubmitSameValue?: boolean;
|
||||||
allowKeyboardNavigation?: boolean;
|
allowKeyboardNavigation?: boolean;
|
||||||
},
|
},
|
||||||
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
|
|||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||||
const isKeyboardSubmitting = useRef(false);
|
const isKeyboardSubmitting = useRef(false);
|
||||||
|
// track escape to prevent the subsequent blur from submitting
|
||||||
|
const isEscaping = useRef(false);
|
||||||
|
// track tab-out to prevent blur from submitting (used by timer cells)
|
||||||
|
const isTabbing = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof initialText === 'undefined') {
|
if (typeof initialText === 'undefined') {
|
||||||
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
|
|||||||
* @param {string} valueToSubmit
|
* @param {string} valueToSubmit
|
||||||
*/
|
*/
|
||||||
const handleEscape = useCallback(() => {
|
const handleEscape = useCallback(() => {
|
||||||
|
isEscaping.current = true;
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
setText(initialText);
|
setText(initialText);
|
||||||
// force the text to be the initial value
|
// force the text to be the initial value
|
||||||
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// for cells that opt out of tab-submit, track tab-out without preventing navigation
|
||||||
|
if (event.key === 'Tab' && options?.submitOnTab === false) {
|
||||||
|
isTabbing.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
hotKeyHandler(event);
|
hotKeyHandler(event);
|
||||||
};
|
};
|
||||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: text,
|
value: text,
|
||||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
onChange: (event) => handleChange(event.target.value),
|
||||||
onBlur: (event: ChangeEvent) => {
|
onBlur: (event) => {
|
||||||
|
if (isTabbing.current) {
|
||||||
|
isTabbing.current = false;
|
||||||
|
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEscaping.current) {
|
||||||
|
isEscaping.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isKeyboardSubmitting.current) {
|
if (!isKeyboardSubmitting.current) {
|
||||||
handleSubmit((event.target as HTMLInputElement).value);
|
handleSubmit(event.target.value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onKeyDown: keyHandler,
|
onKeyDown: keyHandler,
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
padding-top: 10vh;
|
padding-top: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.inline {
|
||||||
width: 100%;
|
display: flex;
|
||||||
opacity: 0.8;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
import { SupportedEntry } from 'ontime-types';
|
||||||
|
import { IoAdd } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
|
import Button from '../buttons/Button';
|
||||||
|
import Empty from './Empty';
|
||||||
|
|
||||||
import style from './EmptyTableBody.module.scss';
|
import style from './EmptyTableBody.module.scss';
|
||||||
|
|
||||||
interface EmptyTableBodyProps {
|
interface EmptyTableBodyProps {
|
||||||
text: string;
|
handleAddNew?: (type: SupportedEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||||
|
const { getLocalizedString } = useTranslation();
|
||||||
|
const text = getLocalizedString('common.no_data');
|
||||||
return (
|
return (
|
||||||
<tbody className={style.emptyContainer}>
|
<tbody className={style.emptyContainer}>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={99} className={style.emptyCell}>
|
<td colSpan={99} className={style.emptyCell}>
|
||||||
<EmptyImage className={style.empty} />
|
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||||
{text && <span className={style.text}>{text}</span>}
|
<span className={style.text}>{text}</span>
|
||||||
|
{handleAddNew && (
|
||||||
|
<div className={style.inline}>
|
||||||
|
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||||
|
<IoAdd />
|
||||||
|
Create Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ function SectionContents({ options, collapsed }: SectionContentsProps) {
|
|||||||
function HiddenContents({ options }: { options: ParamField[] }) {
|
function HiddenContents({ options }: { options: ParamField[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.map((option, index) => {
|
{options.map((option) => {
|
||||||
return <ParamInput key={option.title + index} paramField={option} />;
|
return <ParamInput key={option.id} paramField={option} />;
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,3 +26,14 @@ export const showLeadingZeros: ParamField = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TimeOptions = {
|
||||||
|
timeformat: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get value of 'timeformat' from either source, prioritizing defaultValues
|
||||||
|
*/
|
||||||
|
export function getTimeOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
|
||||||
|
return defaultValues?.get('timeformat') ?? searchParams.get('timeformat');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { langEn } from 'ontime-types';
|
import { langEn } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { getUserTranslation } from '../../common/api/assets';
|
import { getUserTranslation } from '../../common/api/assets';
|
||||||
import { TRANSLATION } from '../../common/api/constants';
|
import { TRANSLATION } from '../../common/api/constants';
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
export function useCustomTranslation() {
|
export function useCustomTranslation() {
|
||||||
const { data, status, refetch } = useQuery({
|
const { data, status, refetch } = useQuery({
|
||||||
queryKey: TRANSLATION,
|
queryKey: TRANSLATION,
|
||||||
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
staleTime: MILLIS_PER_HOUR,
|
||||||
});
|
});
|
||||||
return { data: data ?? langEn, status, refetch };
|
return { data: data ?? langEn, status, refetch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
renameRundown,
|
renameRundown,
|
||||||
} from '../api/rundown';
|
} from '../api/rundown';
|
||||||
|
|
||||||
|
//TODO: make suspends so we don't have to deal with no value all over
|
||||||
/**
|
/**
|
||||||
* Project rundowns
|
* Project rundowns
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
|
|||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
||||||
import { fetchCurrentRundown } from '../api/rundown';
|
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||||
import { useSelectedEventId } from '../hooks/useSocket';
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||||
import { useProjectRundowns } from './useProjectRundowns';
|
import { useProjectRundowns } from './useProjectRundowns';
|
||||||
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalised rundown data
|
* Normalised rundown data for the currently loaded rundown.
|
||||||
|
*
|
||||||
|
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
||||||
|
* independent of the project rundown list. Once the loaded id is known, the
|
||||||
|
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
||||||
*/
|
*/
|
||||||
export default function useRundown() {
|
export default function useRundown() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -34,12 +38,13 @@ export default function useRundown() {
|
|||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
|
// Seed the id-keyed cache when fetching via the bootstrap alias
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || loadedRundownId) return;
|
if (!data || loadedRundownId) return;
|
||||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||||
}, [data, loadedRundownId, queryClient]);
|
}, [data, loadedRundownId, queryClient]);
|
||||||
|
|
||||||
|
// Once we have the ID, drop the temporary current cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadedRundownId) return;
|
if (!loadedRundownId) return;
|
||||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||||
@@ -114,3 +119,21 @@ export function useRundownAuxData() {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
return { data: filteredData, status };
|
return { data: filteredData, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides access to a specific rundown by ID.
|
||||||
|
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
||||||
|
*/
|
||||||
|
export function useRundownById(rundownId: string | null | undefined) {
|
||||||
|
const enabled = Boolean(rundownId);
|
||||||
|
|
||||||
|
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||||
|
queryKey: getRundownQueryKey(rundownId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
||||||
|
enabled,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { EntryId, Rundown } from 'ontime-types';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
|
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
|
||||||
|
import { useProjectRundowns } from './useProjectRundowns';
|
||||||
|
import { useRundownById } from './useRundown';
|
||||||
|
|
||||||
|
export type RundownSource = {
|
||||||
|
rundownId: string | null;
|
||||||
|
rundown: Rundown;
|
||||||
|
flatRundown: ExtendedEntry[];
|
||||||
|
status: string;
|
||||||
|
selectedEventId: EntryId | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
|
||||||
|
*/
|
||||||
|
export function useScopedRundown(rundownId: string | null): RundownSource {
|
||||||
|
const { data: projectRundowns } = useProjectRundowns();
|
||||||
|
return useRundownSource(rundownId, projectRundowns.loaded || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loaded-rundown source for views that must follow the active runtime rundown.
|
||||||
|
*/
|
||||||
|
export function useLoadedRundownSource(): RundownSource {
|
||||||
|
const { data: projectRundowns } = useProjectRundowns();
|
||||||
|
const loadedRundownId = projectRundowns.loaded || null;
|
||||||
|
return useRundownSource(loadedRundownId, loadedRundownId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
|
||||||
|
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
||||||
|
const runtimeSelectedEventId = useSelectedEventId();
|
||||||
|
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
||||||
|
const { data: rundown, status } = useRundownById(rundownId);
|
||||||
|
const flatRundown = useMemo(
|
||||||
|
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||||
|
[effectiveSelectedEventId, rundown],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
rundownId,
|
||||||
|
rundown,
|
||||||
|
flatRundown,
|
||||||
|
status,
|
||||||
|
selectedEventId: effectiveSelectedEventId,
|
||||||
|
}),
|
||||||
|
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
patchReorderEntry,
|
patchReorderEntry,
|
||||||
postAddEntry,
|
postAddEntry,
|
||||||
postCloneEntry,
|
postCloneEntry,
|
||||||
|
patchRenumberCues,
|
||||||
putBatchEditEvents,
|
putBatchEditEvents,
|
||||||
putEditEntry,
|
putEditEntry,
|
||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
@@ -62,9 +63,16 @@ export type EventOptions = Partial<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gather utilities for actions on entries
|
* Gather utilities for actions on entries in the loaded rundown.
|
||||||
*/
|
*/
|
||||||
export const useEntryActions = () => {
|
export const useEntryActions = () => useEntryActionsForRundown(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather utilities for actions on entries in an explicitly selected rundown.
|
||||||
|
*/
|
||||||
|
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
|
||||||
|
|
||||||
|
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
@@ -77,12 +85,12 @@ export const useEntryActions = () => {
|
|||||||
} = useEditorSettings();
|
} = useEditorSettings();
|
||||||
|
|
||||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
if (scopedRundownId !== undefined) {
|
||||||
if (loadedRundownId) {
|
return getRundownQueryKey(scopedRundownId);
|
||||||
return getRundownQueryKey(loadedRundownId);
|
|
||||||
}
|
}
|
||||||
return CURRENT_RUNDOWN_QUERY_KEY;
|
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||||
}, [queryClient]);
|
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||||
|
}, [queryClient, scopedRundownId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the currently loaded rundown
|
* Returns the currently loaded rundown
|
||||||
@@ -523,6 +531,39 @@ export const useEntryActions = () => {
|
|||||||
[batchUpdateEventsMutation, getCurrentRundownData],
|
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: renumberCuesMutation } = useMutation({
|
||||||
|
mutationFn: ([rundownId, body]: Parameters<typeof patchRenumberCues>) => patchRenumberCues(rundownId, body),
|
||||||
|
onMutate: async () => {
|
||||||
|
const queryKey = resolveCurrentRundownQueryKey();
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
|
||||||
|
return { previousRundown, queryKey };
|
||||||
|
},
|
||||||
|
onSuccess: (response, _variables, context) => {
|
||||||
|
if (!response.data || !context?.queryKey) return;
|
||||||
|
const updatedRundown = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(context.queryKey, updatedRundown);
|
||||||
|
},
|
||||||
|
onError: (_error, _vars, context) => {
|
||||||
|
if (context?.previousRundown) queryClient.setQueryData<Rundown>(context.queryKey, context.previousRundown);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renumberCues = useCallback(
|
||||||
|
async (eventIds: EntryId[], prefix: string, start: string, increment: string) => {
|
||||||
|
const rundown = getCurrentRundownData();
|
||||||
|
const rundownId = rundown?.id;
|
||||||
|
if (!rundownId) throw new Error('Rundown not initialized');
|
||||||
|
try {
|
||||||
|
await renumberCuesMutation([rundownId, { ids: eventIds, prefix, start, increment }]);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error renumbering cues', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCurrentRundownData, renumberCuesMutation],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to delete an entry
|
* Calls mutation to delete an entry
|
||||||
* @private
|
* @private
|
||||||
@@ -947,6 +988,7 @@ export const useEntryActions = () => {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
move,
|
move,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
|
renumberCues,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
@@ -963,12 +1005,13 @@ export const useEntryActions = () => {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
move,
|
move,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
|
renumberCues,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility to optimistically delete entries from client cache
|
* Utility to optimistically delete entries from client cache
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ describe('getRouteFromPreset()', () => {
|
|||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const disabledPresets: URLPreset[] = [
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
alias: 'demopage',
|
||||||
|
target: OntimeView.Timer,
|
||||||
|
search: 'user=guest',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it('checks if the current location matches an enabled preset', () => {
|
it('checks if the current location matches an enabled preset', () => {
|
||||||
// we make the current location be the alias
|
// we make the current location be the alias
|
||||||
@@ -47,6 +56,21 @@ describe('getRouteFromPreset()', () => {
|
|||||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks if the current location matches an enabled preset target', () => {
|
||||||
|
const location = resolvePath('/timer');
|
||||||
|
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not redirect disabled presets', () => {
|
||||||
|
const location = resolvePath('/demopage');
|
||||||
|
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when already on a preset path', () => {
|
||||||
|
const location = resolvePath('/preset/demopage');
|
||||||
|
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null if the current location is the exact match of an unwrapped alias', () => {
|
it('returns null if the current location is the exact match of an unwrapped alias', () => {
|
||||||
// we make the current location be the alias
|
// we make the current location be the alias
|
||||||
const location = resolvePath('/timer?user=guest&alias=demopage');
|
const location = resolvePath('/timer?user=guest&alias=demopage');
|
||||||
@@ -81,6 +105,11 @@ describe('getRouteFromPreset()', () => {
|
|||||||
const location = resolvePath('/demopage?n=1&token=123');
|
const location = resolvePath('/demopage?n=1&token=123');
|
||||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
|
||||||
|
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
|
||||||
|
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cuesheet presets', () => {
|
describe('cuesheet presets', () => {
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import {
|
|||||||
Log,
|
Log,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
MessageTag,
|
MessageTag,
|
||||||
ProjectRundownsList,
|
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
Rundown,
|
|
||||||
RuntimeStore,
|
RuntimeStore,
|
||||||
WsPacketToClient,
|
WsPacketToClient,
|
||||||
WsPacketToServer,
|
WsPacketToServer,
|
||||||
@@ -16,16 +14,17 @@ import {
|
|||||||
APP_SETTINGS,
|
APP_SETTINGS,
|
||||||
CLIENT_LIST,
|
CLIENT_LIST,
|
||||||
CSS_OVERRIDE,
|
CSS_OVERRIDE,
|
||||||
|
CURRENT_RUNDOWN_QUERY_KEY,
|
||||||
CUSTOM_FIELDS,
|
CUSTOM_FIELDS,
|
||||||
PROJECT_DATA,
|
PROJECT_DATA,
|
||||||
CURRENT_RUNDOWN_QUERY_KEY,
|
|
||||||
PROJECT_RUNDOWNS,
|
|
||||||
REPORT,
|
REPORT,
|
||||||
|
RUNDOWN,
|
||||||
RUNTIME,
|
RUNTIME,
|
||||||
TRANSLATION,
|
TRANSLATION,
|
||||||
URL_PRESETS,
|
URL_PRESETS,
|
||||||
VIEW_SETTINGS,
|
VIEW_SETTINGS,
|
||||||
getRundownQueryKey,
|
getRundownQueryKey,
|
||||||
|
PROJECT_RUNDOWNS,
|
||||||
} from '../api/constants';
|
} from '../api/constants';
|
||||||
import { invalidateAllCaches } from '../api/utils';
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
@@ -51,8 +50,10 @@ const socketConfig = {
|
|||||||
offlineAttemptsThreshold: 2, // when we consider the client disconnected
|
offlineAttemptsThreshold: 2, // when we consider the client disconnected
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export let hasConnected = false;
|
export const getConnectionState = () => hasConnected;
|
||||||
export let reconnectAttempts = 0;
|
export const getReconnectAttempts = () => reconnectAttempts;
|
||||||
|
let hasConnected = false;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
export const connectSocket = () => {
|
export const connectSocket = () => {
|
||||||
websocket = new WebSocket(websocketUrl);
|
websocket = new WebSocket(websocketUrl);
|
||||||
@@ -179,7 +180,7 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
case MessageTag.Refetch: {
|
case MessageTag.Refetch: {
|
||||||
// the refetch message signals that the rundown has changed in the server side
|
// the refetch message signals that the rundown has changed in the server side
|
||||||
const { target, revision } = payload;
|
const { target, revision, rundownId } = payload;
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case RefetchKey.All:
|
case RefetchKey.All:
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
@@ -194,7 +195,7 @@ export const connectSocket = () => {
|
|||||||
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
break;
|
break;
|
||||||
case RefetchKey.Rundown: {
|
case RefetchKey.Rundown: {
|
||||||
maybeInvalidateRundownCache(revision);
|
maybeInvalidateRundownCache(revision, rundownId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case RefetchKey.UrlPresets:
|
case RefetchKey.UrlPresets:
|
||||||
@@ -212,6 +213,9 @@ export const connectSocket = () => {
|
|||||||
case RefetchKey.Settings:
|
case RefetchKey.Settings:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||||
break;
|
break;
|
||||||
|
case RefetchKey.ProjectRundowns:
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
target satisfies never;
|
target satisfies never;
|
||||||
break;
|
break;
|
||||||
@@ -230,34 +234,30 @@ export const connectSocket = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
|
||||||
* When we receive a refetch message for the rundown
|
if (!rundownId) {
|
||||||
* check which rundown needs to be invalidated
|
// we omit rundownId to signify invalidate all rundowns
|
||||||
*/
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
|
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||||
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
|
|
||||||
?.loaded;
|
|
||||||
|
|
||||||
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
|
||||||
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
|
|
||||||
if (revision === cachedRundown?.revision) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
|
// skip if we dont recognise the ID the revision is lower
|
||||||
|
const queryKey = getRundownQueryKey(rundownId);
|
||||||
|
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
|
||||||
|
|
||||||
if (loadedRundownId) {
|
if (revision === cachedRundown?.revision) {
|
||||||
// Keep bootstrap alias in sync with the ID-based cache
|
// we already have the latest change
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
return;
|
||||||
} else {
|
|
||||||
// During bootstrap, loadedRundownId is not yet known.
|
|
||||||
// Invalidate any ID-based rundown caches that may have been seeded early.
|
|
||||||
ontimeQueryClient.invalidateQueries({
|
|
||||||
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||||
|
|
||||||
|
// keep current alias in sync with the ID-based cache
|
||||||
|
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
||||||
|
if (!loadedRundownId || loadedRundownId === rundownId) {
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
MILLIS_PER_HOUR,
|
MILLIS_PER_HOUR,
|
||||||
MILLIS_PER_MINUTE,
|
MILLIS_PER_MINUTE,
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -75,8 +76,9 @@ function resolveTimeFormat(fallback12: string, fallback24: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FormatOptions = {
|
type FormatOptions = {
|
||||||
format12: string;
|
format12?: string;
|
||||||
format24: string;
|
format24?: string;
|
||||||
|
override?: MaybeString;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +99,7 @@ export const formatTime = (
|
|||||||
return '...';
|
return '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
|
const timeFormat = options?.override ?? resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
|
||||||
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
|
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
|
||||||
|
|
||||||
const isNegative = milliseconds < 0;
|
const isNegative = milliseconds < 0;
|
||||||
@@ -171,13 +173,15 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
) {
|
) {
|
||||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||||
|
|
||||||
|
const expectedStartState = {
|
||||||
|
totalGap: event.totalGap,
|
||||||
|
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
|
||||||
const expectedStart = getExpectedStart(
|
const expectedStart = getExpectedStart(
|
||||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||||
{
|
expectedStartState,
|
||||||
totalGap: event.totalGap,
|
|
||||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
|
||||||
...state,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
@@ -185,9 +189,7 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
|
|||||||
/**
|
/**
|
||||||
* Check if current location is a preset path
|
* Check if current location is a preset path
|
||||||
*/
|
*/
|
||||||
export function isPresetPath(location: Path): boolean {
|
function isPresetPath(location: Path): boolean {
|
||||||
const segments = location.pathname.split('/').filter(Boolean);
|
const firstSegment = location.pathname.split('/').find(Boolean);
|
||||||
return segments[0] === 'preset';
|
return firstSegment === 'preset';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
|||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
||||||
|
export const youtubeUrl = 'https://www.youtube.com/@ontimeapp';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ ul {
|
|||||||
background-color: $red-400;
|
background-color: $red-400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.split {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
|
|||||||
@@ -42,16 +42,9 @@ interface PanelListItemProps {
|
|||||||
location?: string;
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelListItem(props: PanelListItemProps) {
|
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||||
const { panel, isSelected, location } = props;
|
|
||||||
const { setLocation } = useAppSettingsNavigation();
|
const { setLocation } = useAppSettingsNavigation();
|
||||||
|
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
|
||||||
const classes = cx([
|
|
||||||
style.primary,
|
|
||||||
isSelected && style.active,
|
|
||||||
panel.split && style.split,
|
|
||||||
panel.highlight && style.highlight,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={panel.id}>
|
<Fragment key={panel.id}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
githubUrl,
|
githubUrl,
|
||||||
subredditUrl,
|
subredditUrl,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
|
youtubeUrl,
|
||||||
} from '../../../../externals';
|
} from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
@@ -38,6 +39,7 @@ export default function AboutPanel() {
|
|||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
|
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
|
||||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+3
-4
@@ -89,10 +89,9 @@ export default function AutomationSettingsForm({
|
|||||||
|
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Info>
|
<Info>
|
||||||
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||||
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||||
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||||
<br />
|
|
||||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
|
|||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Info>
|
<Info>
|
||||||
Custom fields allow for additional information to be added to an event.
|
<span>Custom fields allow for additional information to be added to an event.</span>
|
||||||
<br />
|
<span>
|
||||||
<br />
|
To use custom fields as a data source in an{' '}
|
||||||
To use custom fields as a data source in an{' '}
|
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
</span>
|
||||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export default function CustomViews() {
|
|||||||
|
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Info>
|
<Info>
|
||||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
<span>
|
||||||
<br />
|
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||||
|
</span>
|
||||||
External imports are not allowed, include all assets inside the html file.
|
External imports are not allowed, include all assets inside the html file.
|
||||||
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IoPencilOutline,
|
IoPencilOutline,
|
||||||
IoTrash,
|
IoTrash,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
|
import { TbGhost3 } from 'react-icons/tb';
|
||||||
|
|
||||||
import { downloadAsExcel } from '../../../../common/api/excel';
|
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
|
|||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import RundownRenameForm from './composite/RundownRenameForm';
|
import RundownRenameForm from './composite/RundownRenameForm';
|
||||||
import { ManageRundownForm } from './ManageRundownForm';
|
import { ManageRundownForm } from './ManageRundownForm';
|
||||||
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
|
|||||||
await downloadAsExcel(rundownId, title);
|
await downloadAsExcel(rundownId, title);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -153,7 +157,7 @@ export default function ManageRundowns() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
render={<IconButton variant='ghosted-white' />}
|
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
|
|||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
onClick: () => submitRundownDuplicate(id),
|
onClick: () => submitRundownDuplicate(id),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: TbGhost3,
|
||||||
|
label: 'Edit in cuesheet',
|
||||||
|
onClick: () => navigateToCuesheet(id),
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
|
|||||||
+26
-19
@@ -15,23 +15,25 @@ import Input from '../../../../../common/components/input/input/Input';
|
|||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import { openLink } from '../../../../../common/utils/linkUtils';
|
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
import { extractSheetId, getPersistedSheetId, persistSheetId } from './gsheetUtils';
|
||||||
|
|
||||||
import style from './SourcesPanel.module.scss';
|
import style from './SourcesPanel.module.scss';
|
||||||
|
|
||||||
interface GSheetSetupProps {
|
interface GSheetSetupProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
|
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
|
||||||
|
closedByUser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GSheetSetup(props: GSheetSetupProps) {
|
export default function GSheetSetup(props: GSheetSetupProps) {
|
||||||
const { onCancel, onSheetLoaded } = props;
|
const { onCancel, onSheetLoaded, closedByUser } = props;
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [sheetId, setSheetId] = useState('');
|
const [sheetId, setSheetId] = useState(getPersistedSheetId);
|
||||||
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
|
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
|
||||||
const [authKey, setAuthKey] = useState<string | null>(null);
|
const [authKey, setAuthKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
|
||||||
const [authLink, setAuthLink] = useState('');
|
const [authLink, setAuthLink] = useState('');
|
||||||
|
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
const [worksheetError, setWorksheetError] = useState('');
|
const [worksheetError, setWorksheetError] = useState('');
|
||||||
const pollTimeoutRef = useRef<number | null>(null);
|
const pollTimeoutRef = useRef<number | null>(null);
|
||||||
@@ -62,6 +64,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
const loadWorksheetOptions = useCallback(
|
const loadWorksheetOptions = useCallback(
|
||||||
async (nextSheetId: string) => {
|
async (nextSheetId: string) => {
|
||||||
const worksheetOptions = await getWorksheetOptions(nextSheetId);
|
const worksheetOptions = await getWorksheetOptions(nextSheetId);
|
||||||
|
persistSheetId(nextSheetId);
|
||||||
onSheetLoaded(nextSheetId, worksheetOptions);
|
onSheetLoaded(nextSheetId, worksheetOptions);
|
||||||
setWorksheetError('');
|
setWorksheetError('');
|
||||||
},
|
},
|
||||||
@@ -71,6 +74,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
const pollUntilAuthenticated = useCallback(
|
const pollUntilAuthenticated = useCallback(
|
||||||
async (attempts: number = 0) => {
|
async (attempts: number = 0) => {
|
||||||
clearPollTimeout();
|
clearPollTimeout();
|
||||||
|
if (closedByUser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await verifyAuthenticationStatus();
|
const result = await verifyAuthenticationStatus();
|
||||||
@@ -82,13 +86,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
pollTimeoutRef.current = window.setTimeout(() => {
|
pollTimeoutRef.current = window.setTimeout(() => {
|
||||||
pollUntilAuthenticated(attempts + 1);
|
pollUntilAuthenticated(attempts + 1);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
return; // Keep authKey for next poll
|
||||||
setLoading('');
|
|
||||||
}
|
}
|
||||||
|
// Polling timed out
|
||||||
|
setAuthKey(null);
|
||||||
|
setLoading('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.authenticated === 'authenticated') {
|
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||||
|
setLoading('load-sheet');
|
||||||
try {
|
try {
|
||||||
await loadWorksheetOptions(result.sheetId);
|
await loadWorksheetOptions(result.sheetId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,13 +103,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAuthKey(null);
|
||||||
setLoading('');
|
setLoading('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(maybeAxiosError(error));
|
setAuthError(maybeAxiosError(error));
|
||||||
|
setAuthKey(null);
|
||||||
setLoading('');
|
setLoading('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearPollTimeout, loadWorksheetOptions],
|
[clearPollTimeout, loadWorksheetOptions, closedByUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** check if the current session has been authenticated */
|
/** check if the current session has been authenticated */
|
||||||
@@ -150,7 +159,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests connection to google auth
|
* Requests a device code from Google. The user can copy it before opening the browser.
|
||||||
*/
|
*/
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -171,7 +180,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open google auth
|
* Opens the Google verification page and starts polling for completion.
|
||||||
*/
|
*/
|
||||||
const handleAuthenticate = () => {
|
const handleAuthenticate = () => {
|
||||||
setLoading('authenticate');
|
setLoading('authenticate');
|
||||||
@@ -180,7 +189,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
clearPollTimeout();
|
clearPollTimeout();
|
||||||
clearAuthFallbackTimeout();
|
clearAuthFallbackTimeout();
|
||||||
|
|
||||||
// open link and schedule a check for when the user focuses again
|
|
||||||
openLink(authLink);
|
openLink(authLink);
|
||||||
authFallbackTimeoutRef.current = window.setTimeout(() => {
|
authFallbackTimeoutRef.current = window.setTimeout(() => {
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
@@ -269,18 +277,19 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
)}
|
)}
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
<Panel.Description>Enter ID of sheet to synchronize</Panel.Description>
|
||||||
<Panel.Error>{worksheetError}</Panel.Error>
|
<Panel.Error>{worksheetError}</Panel.Error>
|
||||||
<Input
|
<Input
|
||||||
fluid
|
fluid
|
||||||
value={sheetId}
|
value={sheetId}
|
||||||
placeholder='Sheet ID'
|
placeholder='Sheet ID or Google Sheets URL'
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setWorksheetError('');
|
setWorksheetError('');
|
||||||
setSheetId(event.target.value);
|
setSheetId(extractSheetId(event.target.value));
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || canAuthenticate}
|
disabled={isLoading || canAuthenticate}
|
||||||
/>
|
/>
|
||||||
|
<div className={style.setupHint}>Paste a Google Sheets URL or the sheet ID from the URL bar.</div>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
@@ -304,18 +313,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
) : (
|
) : (
|
||||||
<Panel.ListGroup className={style.setupBlock}>
|
<Panel.ListGroup className={style.setupBlock}>
|
||||||
<Panel.Description>Authenticate this Ontime session with Google</Panel.Description>
|
<Panel.Description>Copy the device code, then authenticate with Google</Panel.Description>
|
||||||
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
|
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
|
||||||
{isAuthenticating && <span>Authenticating...</span>}
|
{isAuthenticating && <span>Authenticating...</span>}
|
||||||
<CopyTag copyValue={authKey ?? ''} disabled={!canAuthenticate}>
|
<CopyTag copyValue={authKey ?? ''}>{authKey}</CopyTag>
|
||||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
<Button onClick={handleAuthenticate} disabled={isLoading} loading={loading === 'authenticate'}>
|
||||||
</CopyTag>
|
|
||||||
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
|
|
||||||
<IoShieldCheckmarkOutline />
|
<IoShieldCheckmarkOutline />
|
||||||
Authenticate
|
Authenticate
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<div className={style.setupHint}>Open the browser prompt, complete the code flow, then come back here.</div>
|
<div className={style.setupHint}>Copy the code, then open the browser prompt to complete the flow.</div>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
)}
|
)}
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
+27
-8
@@ -37,12 +37,15 @@ type ActiveSource =
|
|||||||
kind: 'excel';
|
kind: 'excel';
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
|
closedByUser: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'gsheet';
|
kind: 'gsheet';
|
||||||
sheetId: string;
|
sheetId: string;
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
|
title: string;
|
||||||
|
closedByUser: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SourcesPanel() {
|
export default function SourcesPanel() {
|
||||||
@@ -73,6 +76,7 @@ export default function SourcesPanel() {
|
|||||||
kind: 'excel',
|
kind: 'excel',
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
|
closedByUser: false,
|
||||||
});
|
});
|
||||||
setImportFlow('excel');
|
setImportFlow('excel');
|
||||||
setHasFile('done');
|
setHasFile('done');
|
||||||
@@ -106,7 +110,13 @@ export default function SourcesPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelImportFlow = () => {
|
const cancelImportFlow = () => {
|
||||||
resetFlow();
|
if (activeSource && activeSource.kind === 'gsheet') {
|
||||||
|
// Return to GSheetSetup so the user can change the sheet ID or revoke auth
|
||||||
|
setActiveSource({ ...activeSource, closedByUser: true });
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
resetFlow();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinished = () => {
|
const handleFinished = () => {
|
||||||
@@ -177,15 +187,22 @@ export default function SourcesPanel() {
|
|||||||
sheetId,
|
sheetId,
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
|
title: worksheetOptions.title ?? '',
|
||||||
|
closedByUser: false,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closedByUser = activeSource?.closedByUser ?? false;
|
||||||
const isGSheetFlow = importFlow === 'gsheet';
|
const isGSheetFlow = importFlow === 'gsheet';
|
||||||
const showInput = importFlow === 'none';
|
const showInput = importFlow === 'none';
|
||||||
const showCompleted = importFlow === 'finished';
|
const showCompleted = importFlow === 'finished';
|
||||||
const showAuth = isGSheetFlow && activeSource === null;
|
|
||||||
const showImportWorkspace = activeSource !== null;
|
const showImportWorkspace = activeSource !== null && !closedByUser;
|
||||||
const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet';
|
const importModalTitle = (() => {
|
||||||
|
if (!activeSource) return '';
|
||||||
|
if (activeSource.kind === 'excel') return 'Import spreadsheet';
|
||||||
|
return activeSource.title ? `Sync: ${activeSource.title}` : 'Synchronize with Google Sheet';
|
||||||
|
})();
|
||||||
const sourceKey = (() => {
|
const sourceKey = (() => {
|
||||||
if (!activeSource) return null;
|
if (!activeSource) return null;
|
||||||
if (activeSource.kind === 'excel') return 'excel';
|
if (activeSource.kind === 'excel') return 'excel';
|
||||||
@@ -195,7 +212,7 @@ export default function SourcesPanel() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
|
<Panel.SubHeader>Synchronize your rundown with an external source</Panel.SubHeader>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<div className={style.introStack}>
|
<div className={style.introStack}>
|
||||||
@@ -233,7 +250,7 @@ export default function SourcesPanel() {
|
|||||||
</section>
|
</section>
|
||||||
<section className={style.sourceCard}>
|
<section className={style.sourceCard}>
|
||||||
<div className={style.sourceHeader}>
|
<div className={style.sourceHeader}>
|
||||||
<h4 className={style.sourceTitle}>Synchronise with Google</h4>
|
<h4 className={style.sourceTitle}>Synchronize with Google</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className={style.sourceDescription}>
|
<p className={style.sourceDescription}>
|
||||||
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
|
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
|
||||||
@@ -241,7 +258,7 @@ export default function SourcesPanel() {
|
|||||||
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
|
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
|
||||||
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
||||||
<IoCloudOutline />
|
<IoCloudOutline />
|
||||||
Synchronise with Google
|
Synchronize with Google
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +274,9 @@ export default function SourcesPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} />}
|
{isGSheetFlow && (
|
||||||
|
<GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} closedByUser={closedByUser} />
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showImportWorkspace}
|
isOpen={showImportWorkspace}
|
||||||
title={importModalTitle}
|
title={importModalTitle}
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractSheetId } from '../gsheetUtils';
|
||||||
|
|
||||||
|
describe('extractSheetId()', () => {
|
||||||
|
it('extracts the ID from a full Google Sheets URL', () => {
|
||||||
|
const url = 'https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZ/edit#gid=0';
|
||||||
|
expect(extractSheetId(url)).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the ID when the URL has no trailing path', () => {
|
||||||
|
expect(extractSheetId('https://docs.google.com/spreadsheets/d/abc123')).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles IDs with hyphens and underscores', () => {
|
||||||
|
const url = 'https://docs.google.com/spreadsheets/d/1a-B_c2/edit';
|
||||||
|
expect(extractSheetId(url)).toBe('1a-B_c2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a raw sheet ID unchanged', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from the input', () => {
|
||||||
|
expect(extractSheetId(' 1aBcDeFg ')).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from a pasted URL', () => {
|
||||||
|
const url = ' https://docs.google.com/spreadsheets/d/1aBcDeFg/edit ';
|
||||||
|
expect(extractSheetId(url)).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips query params from a raw ID', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ?edit=1')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips fragment from a raw ID', () => {
|
||||||
|
expect(extractSheetId('1aBcDeFg#gid=0')).toBe('1aBcDeFg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(extractSheetId('')).toBe('');
|
||||||
|
expect(extractSheetId(' ')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { makeStageKey } from '../../../../../common/utils/localStorage';
|
||||||
|
|
||||||
|
// Matches the document ID segment from a Google Sheets URL:
|
||||||
|
// https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0
|
||||||
|
// ^^^^ captured group
|
||||||
|
// IDs consist of alphanumeric chars, hyphens, and underscores.
|
||||||
|
const sheetIdPattern = /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/;
|
||||||
|
const lastSheetIdKey = makeStageKey('gsheet:lastSheetId');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a Google Sheet ID from a full URL, or returns the raw input if it doesn't match.
|
||||||
|
*/
|
||||||
|
export function extractSheetId(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const match = trimmed.match(sheetIdPattern);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// Strip query params (?...) and fragments (#...) in case the user
|
||||||
|
// pasted a partial URL or an ID with trailing junk like "abc123?edit=1"
|
||||||
|
return trimmed.split(/[?#]/)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistedSheetId(): string {
|
||||||
|
return localStorage.getItem(lastSheetIdKey) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistSheetId(sheetId: string) {
|
||||||
|
localStorage.setItem(lastSheetIdKey, sheetId);
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@ export function getWarningText(warning: MappingWarning): string {
|
|||||||
case 'invalid-name':
|
case 'invalid-name':
|
||||||
return 'Column cannot be converted into an Ontime field name';
|
return 'Column cannot be converted into an Ontime field name';
|
||||||
case 'name-collision':
|
case 'name-collision':
|
||||||
return 'Column name resolves to a duplicate column';
|
return 'Column name resolves to a duplicate Ontime field';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ export default function SheetImportMappingPane({
|
|||||||
return (
|
return (
|
||||||
<section className={style.mappingPane}>
|
<section className={style.mappingPane}>
|
||||||
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
|
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
|
||||||
<span className={style.mappingPaneTitle}>Fields</span>
|
<span className={style.mappingPaneTitle}>Column mapping</span>
|
||||||
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
|
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
|
||||||
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
|
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
|
||||||
<IoAdd />
|
<IoAdd />
|
||||||
|
|||||||
+11
-3
@@ -58,7 +58,7 @@ describe('getImportWarnings()', () => {
|
|||||||
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
|
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when a custom header resolves to an existing Ontime field name', () => {
|
it('warns when a custom header resolves to a built-in or duplicate imported field name', () => {
|
||||||
const values = createDefaultFormValues();
|
const values = createDefaultFormValues();
|
||||||
values.custom = [
|
values.custom = [
|
||||||
{ ontimeName: '', importName: 'Artist-1' },
|
{ ontimeName: '', importName: 'Artist-1' },
|
||||||
@@ -67,12 +67,20 @@ describe('getImportWarnings()', () => {
|
|||||||
{ ontimeName: '', importName: 'Presenter-1' },
|
{ ontimeName: '', importName: 'Presenter-1' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1'], ['Presenter 1']);
|
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1']);
|
||||||
|
|
||||||
expect(warnings['custom.0.importName']).toBeUndefined();
|
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||||
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||||
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||||
expect(warnings['custom.3.importName']).toStrictEqual({ kind: 'name-collision' });
|
expect(warnings['custom.3.importName']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not give false warnings for pre-existing custom fields', () => {
|
||||||
|
const values = createDefaultFormValues();
|
||||||
|
values.custom = [{ ontimeName: '', importName: 'Video Info' }];
|
||||||
|
|
||||||
|
const warnings = getImportWarnings(values, ['Video Info']);
|
||||||
|
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-12
@@ -127,26 +127,26 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||||
const storageKey = getImportMapKey(sourceKey);
|
const storageKey = getImportMapKey(sourceKey);
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (isPersistedFormValues(parsed)) {
|
if (isPersistedFormValues(parsed)) {
|
||||||
return parsed;
|
return { values: parsed, isPersisted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid schema - delete malformed data
|
// Invalid schema - delete malformed data
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
} catch {
|
} catch {
|
||||||
// Parse error - delete corrupted data
|
// Parse error - delete corrupted data
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
return createDefaultFormValues();
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +156,9 @@ export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
|||||||
export function getImportWarnings(
|
export function getImportWarnings(
|
||||||
values: ImportFormValues,
|
values: ImportFormValues,
|
||||||
detectedSpreadsheetColumns: string[],
|
detectedSpreadsheetColumns: string[],
|
||||||
existingCustomFieldLabels: string[] = [],
|
|
||||||
): Record<string, MappingWarning | undefined> {
|
): Record<string, MappingWarning | undefined> {
|
||||||
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
|
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
|
||||||
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
|
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
|
||||||
const existingLabels = new Set(existingCustomFieldLabels.map((label) => label.trim().toLowerCase()).filter(Boolean));
|
|
||||||
const seenColumns = new Set<string>();
|
const seenColumns = new Set<string>();
|
||||||
const seenDerivedLabels = new Set<string>();
|
const seenDerivedLabels = new Set<string>();
|
||||||
const warnings: Record<string, MappingWarning | undefined> = {};
|
const warnings: Record<string, MappingWarning | undefined> = {};
|
||||||
@@ -200,11 +198,7 @@ export function getImportWarnings(
|
|||||||
warnings[key] = { kind: 'missing' };
|
warnings[key] = { kind: 'missing' };
|
||||||
} else {
|
} else {
|
||||||
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
|
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
|
||||||
if (
|
if (builtInLabels.has(normalisedDerivedLabel) || seenDerivedLabels.has(normalisedDerivedLabel)) {
|
||||||
builtInLabels.has(normalisedDerivedLabel) ||
|
|
||||||
existingLabels.has(normalisedDerivedLabel) ||
|
|
||||||
seenDerivedLabels.has(normalisedDerivedLabel)
|
|
||||||
) {
|
|
||||||
warnings[key] = { kind: 'name-collision' };
|
warnings[key] = { kind: 'name-collision' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||||
import useCustomFields from '../../../../../../common/hooks-query/useCustomFields';
|
|
||||||
import { formatDuration } from '../../../../../../common/utils/time';
|
import { formatDuration } from '../../../../../../common/utils/time';
|
||||||
import {
|
import {
|
||||||
type ImportFormValues,
|
type ImportFormValues,
|
||||||
@@ -118,10 +117,15 @@ export function useSheetImportForm({
|
|||||||
onApply,
|
onApply,
|
||||||
onExport,
|
onExport,
|
||||||
}: UseSheetImportFormProps) {
|
}: UseSheetImportFormProps) {
|
||||||
const initialFormValues = useMemo(() => {
|
const { initialFormValues, skipAutoPreview } = useMemo(() => {
|
||||||
const persisted = getPersistedImportState(sourceKey);
|
const { values, isPersisted } = getPersistedImportState(sourceKey);
|
||||||
const worksheet = getPreferredWorksheet(worksheetNames, persisted.worksheet, initialMetadata?.worksheet ?? '');
|
const worksheet = getPreferredWorksheet(worksheetNames, values.worksheet, initialMetadata?.worksheet ?? '');
|
||||||
return { ...persisted, worksheet };
|
// Skip auto-preview when restoring a persisted mapping that was saved for a different worksheet.
|
||||||
|
const hasWorksheetChanged = values.worksheet !== worksheet;
|
||||||
|
return {
|
||||||
|
initialFormValues: { ...values, worksheet },
|
||||||
|
skipAutoPreview: isPersisted && hasWorksheetChanged,
|
||||||
|
};
|
||||||
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
|
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -139,7 +143,6 @@ export function useSheetImportForm({
|
|||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
|
||||||
const values = watch();
|
const values = watch();
|
||||||
const { data: existingCustomFields } = useCustomFields();
|
|
||||||
|
|
||||||
// --- Worksheet metadata via react-query ---
|
// --- Worksheet metadata via react-query ---
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -167,16 +170,14 @@ export function useSheetImportForm({
|
|||||||
const columnLabels = buildColumnLabels(values);
|
const columnLabels = buildColumnLabels(values);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||||
const existingCustomFieldLabels = useMemo(
|
const warnings = getImportWarnings(values, headers);
|
||||||
() => Object.values(existingCustomFields).map((field) => field.label),
|
|
||||||
[existingCustomFields],
|
|
||||||
);
|
|
||||||
const warnings = getImportWarnings(values, headers, existingCustomFieldLabels);
|
|
||||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||||
|
const autoPreviewFiredRef = useRef(false);
|
||||||
|
|
||||||
// Rehydrate the form from persisted/default state whenever the source context changes.
|
// Rehydrate the form from persisted/default state whenever the source context changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
autoPreviewFiredRef.current = false;
|
||||||
reset(initialFormValues);
|
reset(initialFormValues);
|
||||||
dispatch({ type: 'reset' });
|
dispatch({ type: 'reset' });
|
||||||
}, [initialFormValues, reset]);
|
}, [initialFormValues, reset]);
|
||||||
@@ -225,6 +226,16 @@ export function useSheetImportForm({
|
|||||||
[previewImport],
|
[previewImport],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-preview on mount once metadata is ready.
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPreviewFiredRef.current) return;
|
||||||
|
if (skipAutoPreview) return;
|
||||||
|
if (!isValid || headers.length === 0) return;
|
||||||
|
|
||||||
|
autoPreviewFiredRef.current = true;
|
||||||
|
handleSubmit(handlePreview)();
|
||||||
|
}, [skipAutoPreview, isValid, headers, handleSubmit, handlePreview]);
|
||||||
|
|
||||||
const handleApply = useCallback(async () => {
|
const handleApply = useCallback(async () => {
|
||||||
if (!state.preview) return;
|
if (!state.preview) return;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -20,4 +20,5 @@
|
|||||||
border: 1px solid $gray-1100;
|
border: 1px solid $gray-1100;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type SettingsOption = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
secondary?: Readonly<SettingsOption[]>;
|
secondary?: Readonly<SettingsOption[]>;
|
||||||
split?: boolean;
|
|
||||||
highlight?: string;
|
highlight?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'project',
|
id: 'project',
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
split: true,
|
|
||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'project__create', label: 'Create...' },
|
{ id: 'project__create', label: 'Create...' },
|
||||||
{ id: 'project__list', label: 'Manage projects' },
|
{ id: 'project__list', label: 'Manage projects' },
|
||||||
@@ -46,7 +44,6 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'automation',
|
id: 'automation',
|
||||||
label: 'Automation',
|
label: 'Automation',
|
||||||
split: true,
|
|
||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'automation__settings', label: 'Automation settings' },
|
{ id: 'automation__settings', label: 'Automation settings' },
|
||||||
{ id: 'automation__automations', label: 'Manage automations' },
|
{ id: 'automation__automations', label: 'Manage automations' },
|
||||||
@@ -56,7 +53,6 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'sharing',
|
id: 'sharing',
|
||||||
label: 'Sharing and reporting',
|
label: 'Sharing and reporting',
|
||||||
split: true,
|
|
||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||||
{
|
{
|
||||||
@@ -69,7 +65,6 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'network',
|
id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
split: true,
|
|
||||||
secondary: [
|
secondary: [
|
||||||
{
|
{
|
||||||
id: 'network__log',
|
id: 'network__log',
|
||||||
@@ -84,12 +79,10 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
split: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'shutdown',
|
id: 'shutdown',
|
||||||
label: 'Shutdown',
|
label: 'Shutdown',
|
||||||
split: true,
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
|||||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||||
|
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||||
import { handleLinks } from '../../common/utils/linkUtils';
|
import { handleLinks } from '../../common/utils/linkUtils';
|
||||||
@@ -16,6 +17,7 @@ import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditMo
|
|||||||
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||||
import FinderPlacement from './placements/FinderPlacement';
|
import FinderPlacement from './placements/FinderPlacement';
|
||||||
|
import RenumberCuesDialog from './renumber-cues-dialog/RenumberCuesDialog';
|
||||||
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
||||||
import RundownHeader from './rundown-header/RundownHeader';
|
import RundownHeader from './rundown-header/RundownHeader';
|
||||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||||
@@ -103,6 +105,8 @@ interface RundownRootProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||||
|
const source = useLoadedRundownSource();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownRoot}>
|
<div className={style.rundownRoot}>
|
||||||
{isSmallDevice ? (
|
{isSmallDevice ? (
|
||||||
@@ -111,7 +115,8 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
|
|||||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
)}
|
)}
|
||||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
||||||
|
<RenumberCuesDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
import GroupEditor from './GroupEditor';
|
import GroupEditor from './GroupEditor';
|
||||||
import MilestoneEditor from './MilestoneEditor';
|
import MilestoneEditor from './MilestoneEditor';
|
||||||
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
|
|||||||
|
|
||||||
interface CuesheetEntryEditorProps {
|
interface CuesheetEntryEditorProps {
|
||||||
entryId: string;
|
entryId: string;
|
||||||
|
rundown: Rundown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
|
||||||
const { data } = useRundown();
|
|
||||||
|
|
||||||
const entry = useMemo<OntimeEntry | null>(() => {
|
const entry = useMemo<OntimeEntry | null>(() => {
|
||||||
if (data.order.length === 0) {
|
if (rundown.order.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = data.entries[entryId];
|
const event = rundown.entries[entryId];
|
||||||
return event ?? null;
|
return event ?? null;
|
||||||
}, [entryId, data.order, data.entries]);
|
}, [entryId, rundown.entries, rundown.order.length]);
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
|
|
||||||
/* approximating the style of a disabled input */
|
/* approximating the style of a disabled input */
|
||||||
.textLikeInput {
|
.textLikeInput {
|
||||||
|
color: $gray-200;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
width: 7.5em;
|
width: 7.5em;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { sanitiseCue } from 'ontime-utils';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
@@ -24,10 +23,6 @@ export default memo(EventEditorTitles);
|
|||||||
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
||||||
const { updateEntry } = useEntryActionsContext();
|
const { updateEntry } = useEntryActionsContext();
|
||||||
|
|
||||||
const cueSubmitHandler = (_field: string, newValue: string) => {
|
|
||||||
updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const flagSubmitHandler = (newValue: boolean) => {
|
const flagSubmitHandler = (newValue: boolean) => {
|
||||||
updateEntry({ id: eventId, flag: newValue });
|
updateEntry({ id: eventId, flag: newValue });
|
||||||
};
|
};
|
||||||
@@ -48,7 +43,7 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
|||||||
field='cue'
|
field='cue'
|
||||||
label='Cue'
|
label='Cue'
|
||||||
initialValue={cue}
|
initialValue={cue}
|
||||||
submitHandler={cueSubmitHandler}
|
submitHandler={textSubmitHandler}
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+16
-58
@@ -4,18 +4,6 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section,
|
|
||||||
.formSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
color: $label-gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.triggerList {
|
.triggerList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,25 +13,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerForm {
|
.triggerHeader {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formFields {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8rem 1fr;
|
grid-template-columns: 8rem 1fr 2rem;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
align-items: end;
|
padding: 0.375rem 0.75rem;
|
||||||
}
|
font-size: $aux-text-size;
|
||||||
|
color: $label-gray;
|
||||||
.formActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-height: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trigger {
|
.trigger {
|
||||||
@@ -51,40 +27,22 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8rem 1fr 2rem;
|
grid-template-columns: 8rem 1fr 2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
border-top: 1px solid $white-10;
|
border-top: 1px solid $white-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-duplicate] {
|
||||||
|
> button:not(:last-child) {
|
||||||
|
border-color: $orange-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerMeta {
|
.duplicateMessage {
|
||||||
min-width: 0;
|
padding-left: 0.75rem;
|
||||||
}
|
|
||||||
|
|
||||||
.metaLabel {
|
|
||||||
color: $label-gray;
|
|
||||||
font-size: $aux-text-size;
|
font-size: $aux-text-size;
|
||||||
line-height: 1.2;
|
color: $orange-500;
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.automationTitle {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationError,
|
|
||||||
.validationSuccess {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationError {
|
|
||||||
color: $red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validationSuccess {
|
|
||||||
color: $green-500;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
import { Trigger } from 'ontime-types';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||||
@@ -21,174 +19,108 @@ interface EventEditorTriggersProps {
|
|||||||
|
|
||||||
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
||||||
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
||||||
|
const { updateEntry } = useEntryActionsContext();
|
||||||
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
||||||
const showTriggers = triggers.length > 0;
|
|
||||||
|
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
|
||||||
|
value: id,
|
||||||
|
label: title,
|
||||||
|
}));
|
||||||
|
const hasAutomationOptions = allAutomationOptions.length > 0;
|
||||||
|
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||||
|
|
||||||
|
const duplicateIds = new Set<string>();
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicateIds.add(trigger.id);
|
||||||
|
} else {
|
||||||
|
seen.set(key, trigger.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!hasAutomationOptions) return;
|
||||||
|
updateEntry({
|
||||||
|
id: eventId,
|
||||||
|
triggers: [
|
||||||
|
...triggers,
|
||||||
|
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (triggerId: string) => {
|
||||||
|
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
|
||||||
|
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
|
||||||
|
updateEntry({ id: eventId, triggers: newTriggers });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.triggers}>
|
<div className={style.triggers}>
|
||||||
{automationsEnabled === false && (
|
{automationsEnabled === false && (
|
||||||
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
||||||
)}
|
)}
|
||||||
{showTriggers && (
|
{automationsEnabled && !hasAutomationOptions && (
|
||||||
<div className={style.section}>
|
<Info>
|
||||||
<div className={style.sectionTitle}>Applied automations</div>
|
<Info.Title>No automations available</Info.Title>
|
||||||
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
|
||||||
</div>
|
</Info>
|
||||||
)}
|
)}
|
||||||
<Editor.Panel className={style.formSection}>
|
{triggers.length > 0 && (
|
||||||
<div className={style.sectionTitle}>Add automation</div>
|
<div className={style.triggerList}>
|
||||||
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
<div className={style.triggerHeader}>
|
||||||
</Editor.Panel>
|
<span>Lifecycle</span>
|
||||||
</div>
|
<span>Automation</span>
|
||||||
);
|
</div>
|
||||||
}
|
{triggers.map((trigger) => {
|
||||||
|
const isDuplicate = duplicateIds.has(trigger.id);
|
||||||
interface EventTriggerFormProps {
|
const lifecycleOptions = isDuplicate
|
||||||
eventId: string;
|
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
|
||||||
triggers?: Trigger[];
|
: triggerOptions;
|
||||||
automations: NormalisedAutomation;
|
const automationOptions = isDuplicate
|
||||||
}
|
? allAutomationOptions.map((opt) =>
|
||||||
|
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
|
||||||
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
|
)
|
||||||
const { updateEntry } = useEntryActionsContext();
|
: allAutomationOptions;
|
||||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
|
||||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
|
||||||
|
|
||||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
|
||||||
const newTriggers = triggers ?? new Array<Trigger>();
|
|
||||||
const id = generateId();
|
|
||||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
|
||||||
updateEntry({ id: eventId, triggers: newTriggers });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
|
||||||
if (automationId === undefined) {
|
|
||||||
return 'Select an automation';
|
|
||||||
}
|
|
||||||
if (!Object.keys(automations).includes(automationId)) {
|
|
||||||
return 'This automation does not exist';
|
|
||||||
}
|
|
||||||
if (triggers === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
|
||||||
? 'Automation can only be used once'
|
|
||||||
: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationError = getValidationError(cycleValue, automationId);
|
|
||||||
const validationLabel = validationError ?? 'Ready to add automation';
|
|
||||||
|
|
||||||
const triggerOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: null, label: 'Select lifecycle' },
|
|
||||||
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
|
|
||||||
],
|
|
||||||
[], // eventTriggerOptions is a constant, no need for dependency
|
|
||||||
);
|
|
||||||
|
|
||||||
const automationOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: null, label: 'Select Automation' },
|
|
||||||
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
|
|
||||||
],
|
|
||||||
[automations], // This needs to be a dependency as it can change
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.triggerForm}>
|
|
||||||
<div className={style.formFields}>
|
|
||||||
<div>
|
|
||||||
<Editor.Label>Lifecycle</Editor.Label>
|
|
||||||
<Select
|
|
||||||
value={cycleValue}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== null) setCycleValue(value);
|
|
||||||
}}
|
|
||||||
options={triggerOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Editor.Label>Automation</Editor.Label>
|
|
||||||
<Select
|
|
||||||
value={automationId ?? null}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== null) setAutomationId(value);
|
|
||||||
}}
|
|
||||||
options={automationOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.formActions}>
|
|
||||||
<div className={validationError ? style.validationError : style.validationSuccess}>
|
|
||||||
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
|
|
||||||
<span>{validationLabel}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={validationError !== undefined}
|
|
||||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
|
||||||
>
|
|
||||||
Add automation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExistingEventTriggersProps {
|
|
||||||
eventId: string;
|
|
||||||
triggers: Trigger[];
|
|
||||||
automations: NormalisedAutomation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
|
|
||||||
const { updateEntry } = useEntryActionsContext();
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(triggerId: string) => {
|
|
||||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
|
||||||
updateEntry({ id: eventId, triggers: newTriggers });
|
|
||||||
},
|
|
||||||
[eventId, triggers, updateEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
|
||||||
|
|
||||||
// sort triggers out into groups by the Lifecycle they are on
|
|
||||||
timerLifecycleValues.forEach((triggerType) => {
|
|
||||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
|
||||||
if (thisTriggerType.length) {
|
|
||||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.triggerList}>
|
|
||||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
|
||||||
<Fragment key={triggerLifeCycle}>
|
|
||||||
{triggerGroup.map((trigger) => {
|
|
||||||
const { id, automationId } = trigger;
|
|
||||||
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className={style.trigger}>
|
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
|
||||||
<div className={style.triggerMeta}>
|
<Select
|
||||||
<div className={style.metaLabel}>Lifecycle</div>
|
value={trigger.trigger}
|
||||||
<div>{triggerLifeCycle}</div>
|
onValueChange={(value) => {
|
||||||
</div>
|
if (value !== null) handleChange(trigger.id, 'trigger', value);
|
||||||
<div className={style.triggerMeta}>
|
}}
|
||||||
<div className={style.metaLabel}>Automation</div>
|
options={lifecycleOptions}
|
||||||
<div className={style.automationTitle}>{automationTitle}</div>
|
/>
|
||||||
</div>
|
<Select
|
||||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
value={trigger.automationId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== null) handleChange(trigger.id, 'automationId', value);
|
||||||
|
}}
|
||||||
|
options={automationOptions}
|
||||||
|
/>
|
||||||
|
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||||
<IoTrash />
|
<IoTrash />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Fragment>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{duplicateIds.size > 0 && (
|
||||||
|
<span className={style.duplicateMessage}>
|
||||||
|
* Duplicate combinations will only fire once per lifecycle event.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasAutomationOptions && (
|
||||||
|
<Button variant='ghosted' onClick={handleAdd}>
|
||||||
|
<IoAdd /> Add automation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
color: $error-red;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { RenumberCues } from 'ontime-types';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../common/api/utils';
|
||||||
|
import Button from '../../../common/components/buttons/Button';
|
||||||
|
import Dialog from '../../../common/components/dialog/Dialog';
|
||||||
|
import Input from '../../../common/components/input/input/Input';
|
||||||
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
import { orderEntries } from '../rundown.utils';
|
||||||
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
|
import style from './RenumberCuesDialog.module.scss';
|
||||||
|
|
||||||
|
type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
|
||||||
|
|
||||||
|
export default function RenumberCuesDialog() {
|
||||||
|
'use memo';
|
||||||
|
const { data } = useRundown();
|
||||||
|
const { flatOrder } = data;
|
||||||
|
const { onClose, isOpen } = useRenumberCuesDialogStore();
|
||||||
|
const { renumberCues } = useEntryActionsContext();
|
||||||
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<RenumberCueData>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: RenumberCueData) => {
|
||||||
|
clearErrors();
|
||||||
|
try {
|
||||||
|
const { prefix, start, increment } = data;
|
||||||
|
const orderedEvents = orderEntries(Array.from(selectedEvents), flatOrder);
|
||||||
|
await renumberCues(orderedEvents, prefix, start, increment);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('root', { message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title='Renumber cues'
|
||||||
|
showCloseButton
|
||||||
|
showBackdrop
|
||||||
|
bodyElements={
|
||||||
|
<form id='renumber-cues-form' onSubmit={handleSubmit(onSubmit)} className={style.fields}>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Prefix
|
||||||
|
<Input
|
||||||
|
{...register('prefix')}
|
||||||
|
type='text'
|
||||||
|
maxLength={8}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='A'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Start
|
||||||
|
<Input
|
||||||
|
{...register('start')}
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
step={0.001}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='10'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Increment
|
||||||
|
<Input
|
||||||
|
{...register('increment')}
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
step={0.001}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='0.1'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.root && <p className={style.error}>{errors.root.message}</p>}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
<Button type='button' variant='subtle-white' onClick={onClose} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' variant='primary' form='renumber-cues-form' loading={isSubmitting}>
|
||||||
|
Renumber
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenumberCuesDialogState {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRenumberCuesDialogStore = create<RenumberCuesDialogState>()((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
onClose: () => {
|
||||||
|
set({ isOpen: false });
|
||||||
|
},
|
||||||
|
onOpen: () => {
|
||||||
|
set({ isOpen: true });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
IoTrash,
|
IoTrash,
|
||||||
IoUnlink,
|
IoUnlink,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||||
import RundownEventInner from './RundownEventInner';
|
import RundownEventInner from './RundownEventInner';
|
||||||
@@ -99,6 +100,7 @@ export default function RundownEvent({
|
|||||||
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
|
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
|
||||||
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
|
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
|
||||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||||
|
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||||
|
|
||||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||||
|
|
||||||
@@ -143,6 +145,13 @@ export default function RundownEvent({
|
|||||||
disabled: parent !== null,
|
disabled: parent !== null,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Renumber cues',
|
||||||
|
icon: TbListNumbers,
|
||||||
|
onClick: openRenumberDialog,
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
|
||||||
import type { Column } from '@tanstack/react-table';
|
|
||||||
|
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
|
||||||
import {
|
|
||||||
ColumnSettings,
|
|
||||||
ViewSettings,
|
|
||||||
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
|
|
||||||
import { usePersistedRundownOptions } from '../rundown.options';
|
|
||||||
|
|
||||||
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
|
|
||||||
|
|
||||||
interface EditorTableSettingsProps {
|
|
||||||
columns: Column<ExtendedEntry, unknown>[];
|
|
||||||
handleResetResizing: () => void;
|
|
||||||
handleResetReordering: () => void;
|
|
||||||
handleClearToggles: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditorTableSettings({
|
|
||||||
columns,
|
|
||||||
handleResetResizing,
|
|
||||||
handleResetReordering,
|
|
||||||
handleClearToggles,
|
|
||||||
}: EditorTableSettingsProps) {
|
|
||||||
const options = usePersistedRundownOptions();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toolbar.Root className={style.tableSettings}>
|
|
||||||
<ViewSettings optionsStore={options} />
|
|
||||||
<ColumnSettings
|
|
||||||
columns={columns}
|
|
||||||
handleResetResizing={handleResetResizing}
|
|
||||||
handleResetReordering={handleResetReordering}
|
|
||||||
handleClearToggles={handleClearToggles}
|
|
||||||
/>
|
|
||||||
</Toolbar.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { memo, useEffect, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
|
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
|
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||||
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||||
@@ -13,6 +16,8 @@ function RundownTable() {
|
|||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
|
const source = useLoadedRundownSource();
|
||||||
|
const actions = useEntryActions();
|
||||||
|
|
||||||
// Editor always has full permissions
|
// Editor always has full permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,12 +35,14 @@ function RundownTable() {
|
|||||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
<EntryActionsProvider actions={actions}>
|
||||||
{isLoading ? (
|
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||||
<EmptyPage text='Loading...' />
|
{isLoading ? (
|
||||||
) : (
|
<EmptyPage text='Loading...' />
|
||||||
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
|
) : (
|
||||||
)}
|
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||||
</CuesheetDnd>
|
)}
|
||||||
|
</CuesheetDnd>
|
||||||
|
</EntryActionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
|
|||||||
import { MouseEvent } from 'react';
|
import { MouseEvent } from 'react';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
import { isMacOS } from '../../common/utils/deviceUtils';
|
import { isMacOS } from '../../common/utils/deviceUtils';
|
||||||
|
|
||||||
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function getLoadedRundownData() {
|
function getLoadedRundownData() {
|
||||||
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||||
if (loadedRundownId) {
|
if (!rundownId) return undefined;
|
||||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId));
|
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
|
||||||
}
|
|
||||||
|
|
||||||
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectionMode(event: MouseEvent): SelectionMode {
|
export function getSelectionMode(event: MouseEvent): SelectionMode {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ html,
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
background-color: $ui-black; // prevent the safari from showing white background
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset button styles
|
// reset button styles
|
||||||
|
|||||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,16 +24,18 @@ export default function ViewLoader({ children }: PropsWithChildren) {
|
|||||||
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<>
|
||||||
fallback={
|
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||||
<>
|
<Suspense
|
||||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
fallback={
|
||||||
<Loader />
|
<>
|
||||||
</>
|
<Loader />
|
||||||
}
|
</>
|
||||||
>
|
}
|
||||||
<OverrideStyles />
|
>
|
||||||
{children}
|
<OverrideStyles />
|
||||||
</Suspense>
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function BackstageLoader() {
|
|||||||
|
|
||||||
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
|
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const { mainSource, secondarySource, extraInfo } = useBackstageOptions();
|
const { mainSource, secondarySource, extraInfo, timeformat } = useBackstageOptions();
|
||||||
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
|
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
const { height: screenHeight } = useViewportSize();
|
const { height: screenHeight } = useViewportSize();
|
||||||
@@ -71,18 +71,20 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
|||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||||
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
const startedAt = isPendingStart
|
||||||
|
? formatTime(time.secondaryTimer, { override: timeformat })
|
||||||
|
: formatTime(time.startedAt, { override: timeformat });
|
||||||
|
|
||||||
const scheduledStart = (() => {
|
const scheduledStart = (() => {
|
||||||
if (showNow) return undefined;
|
if (showNow) return undefined;
|
||||||
if (!hasEvents) return undefined;
|
if (!hasEvents) return undefined;
|
||||||
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
|
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const scheduledEnd = (() => {
|
const scheduledEnd = (() => {
|
||||||
if (showNow) return undefined;
|
if (showNow) return undefined;
|
||||||
if (!hasEvents) return undefined;
|
if (!hasEvents) return undefined;
|
||||||
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
|
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||||
@@ -107,7 +109,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
|||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||||
<div className='title'>{projectData.title}</div>
|
<div className='title'>{projectData.title}</div>
|
||||||
<BackstageClock />
|
<BackstageClock timeformat={timeformat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
|
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
|
||||||
@@ -131,7 +133,10 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
|||||||
{isOvertime(time.current) ? (
|
{isOvertime(time.current) ? (
|
||||||
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
|
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
|
||||||
) : (
|
) : (
|
||||||
<SuperscriptTime time={formatTime(time.expectedFinish)} className='time-entry__value' />
|
<SuperscriptTime
|
||||||
|
time={formatTime(time.expectedFinish, { override: timeformat })}
|
||||||
|
className='time-entry__value'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='timer-gap' />
|
<div className='timer-gap' />
|
||||||
@@ -213,12 +218,12 @@ function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackstageClock() {
|
function BackstageClock({ timeformat }: { timeformat: string | null }) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const clock = useAutoTickingClock();
|
const clock = useAutoTickingClock();
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = formatTime(clock, { override: timeformat });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
|
|||||||
import { use, useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
getTimeOptionsFromParams,
|
||||||
|
TimeOptions,
|
||||||
|
} from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||||
import {
|
import {
|
||||||
@@ -71,7 +75,7 @@ type BackstageOptions = {
|
|||||||
mainSource: keyof OntimeEvent | null;
|
mainSource: keyof OntimeEvent | null;
|
||||||
secondarySource: keyof OntimeEvent | null;
|
secondarySource: keyof OntimeEvent | null;
|
||||||
extraInfo: string | null;
|
extraInfo: string | null;
|
||||||
};
|
} & TimeOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
@@ -85,6 +89,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
mainSource: getValue('main') as keyof OntimeEvent | null,
|
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||||
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||||
extraInfo: getValue('extra-info'),
|
extraInfo: getValue('extra-info'),
|
||||||
|
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function getPropertyValue(
|
|||||||
type FormattingOptions = {
|
type FormattingOptions = {
|
||||||
removeSeconds: boolean;
|
removeSeconds: boolean;
|
||||||
removeLeadingZero: boolean;
|
removeLeadingZero: boolean;
|
||||||
|
clockFormat?: MaybeString;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFormattedTimer(
|
export function getFormattedTimer(
|
||||||
@@ -114,7 +115,7 @@ export function getFormattedTimer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (timerType === TimerType.Clock) {
|
if (timerType === TimerType.Clock) {
|
||||||
return formatTime(timer);
|
return formatTime(timer, { override: options?.clockFormat });
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeToParse = timer;
|
let timeToParse = timer;
|
||||||
|
|||||||
@@ -139,11 +139,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CountdownClock() {
|
function CountdownClock() {
|
||||||
|
const { timeformat } = useCountdownOptions();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const clock = useAutoTickingClock();
|
const clock = useAutoTickingClock();
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = formatTime(clock, { override: timeformat });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
|
|||||||
import { use, useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
getTimeOptionsFromParams,
|
||||||
|
TimeOptions,
|
||||||
|
} from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
@@ -91,7 +95,7 @@ type CountdownOptions = {
|
|||||||
secondarySource: keyof OntimeEvent | null;
|
secondarySource: keyof OntimeEvent | null;
|
||||||
showExpected: boolean;
|
showExpected: boolean;
|
||||||
hidePast: boolean;
|
hidePast: boolean;
|
||||||
};
|
} & TimeOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
@@ -115,6 +119,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||||
showExpected: isStringBoolean(getValue('showExpected')),
|
showExpected: isStringBoolean(getValue('showExpected')),
|
||||||
hidePast: isStringBoolean(getValue('hidePast')),
|
hidePast: isStringBoolean(getValue('hidePast')),
|
||||||
|
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,7 @@
|
|||||||
'table';
|
'table';
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rundownSelect {
|
||||||
|
min-width: min(20rem, calc(100vw - 6rem));
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
|
|||||||
import IconButton from '../../common/components/buttons/IconButton';
|
import IconButton from '../../common/components/buttons/IconButton';
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
||||||
|
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { getIsNavigationLocked } from '../../externals';
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||||
|
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
||||||
|
|
||||||
import styles from './CuesheetPage.module.scss';
|
import styles from './CuesheetPage.module.scss';
|
||||||
|
|
||||||
export default function CuesheetPage() {
|
export default function CuesheetPage() {
|
||||||
|
'use memo';
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const entryActions = useEntryActions();
|
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
||||||
|
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
||||||
|
|
||||||
|
const actions = useScopedEntryActions(source.rundownId);
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
const isLocked = getIsNavigationLocked();
|
const isLocked = getIsNavigationLocked();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={entryActions}>
|
<EntryActionsProvider actions={actions}>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
<EntryEditModal />
|
<EntryEditModal rundown={source.rundown} />
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<CuesheetOverview>
|
<CuesheetOverview>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
|
|||||||
)}
|
)}
|
||||||
</CuesheetOverview>
|
</CuesheetOverview>
|
||||||
<CuesheetProgress />
|
<CuesheetProgress />
|
||||||
<CuesheetTableWrapper />
|
<CuesheetTableWrapper
|
||||||
|
source={source}
|
||||||
|
selectedRundownId={selectedRundownId}
|
||||||
|
loadedRundownId={loadedRundownId}
|
||||||
|
setSelectedRundownId={setSelectedRundownId}
|
||||||
|
projectRundowns={projectRundowns}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EntryActionsProvider>
|
</EntryActionsProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
|
import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||||
import { memo, use, useMemo } from 'react';
|
import { memo, use, useMemo } from 'react';
|
||||||
|
|
||||||
|
import Select from '../../common/components/select/Select';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
|
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||||
|
import { AppMode } from '../../ontimeConfig';
|
||||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||||
|
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
||||||
|
|
||||||
|
import styles from './CuesheetPage.module.scss';
|
||||||
|
|
||||||
|
interface CuesheetTableWrapperProps {
|
||||||
|
source: RundownSource;
|
||||||
|
selectedRundownId: MaybeString;
|
||||||
|
loadedRundownId: string;
|
||||||
|
setSelectedRundownId: (rundownId: string) => void;
|
||||||
|
projectRundowns: ProjectRundown[];
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(CuesheetTableWrapper);
|
export default memo(CuesheetTableWrapper);
|
||||||
function CuesheetTableWrapper() {
|
function CuesheetTableWrapper({
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
source,
|
||||||
|
selectedRundownId,
|
||||||
|
setSelectedRundownId,
|
||||||
|
loadedRundownId,
|
||||||
|
projectRundowns,
|
||||||
|
}: CuesheetTableWrapperProps) {
|
||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||||
|
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||||
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||||
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
|
|||||||
) : (
|
) : (
|
||||||
<CuesheetTable
|
<CuesheetTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
source={source}
|
||||||
cuesheetMode={cuesheetMode}
|
cuesheetMode={cuesheetMode}
|
||||||
tableRoot='cuesheet'
|
tableRoot='cuesheet'
|
||||||
setCuesheetMode={setCuesheetMode}
|
setCuesheetMode={setCuesheetMode}
|
||||||
|
isCurrentRundown={isCurrentRundown}
|
||||||
|
insertElement={
|
||||||
|
<>
|
||||||
|
<RundownSelect
|
||||||
|
cuesheetMode={cuesheetMode}
|
||||||
|
selectedRundownId={selectedRundownId}
|
||||||
|
loadedRundownId={loadedRundownId}
|
||||||
|
setSelectedRundownId={setSelectedRundownId}
|
||||||
|
projectRundowns={projectRundowns}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RundownSelectProps {
|
||||||
|
cuesheetMode: AppMode;
|
||||||
|
selectedRundownId: MaybeString;
|
||||||
|
loadedRundownId: string;
|
||||||
|
setSelectedRundownId: (rundownId: string) => void;
|
||||||
|
projectRundowns: ProjectRundown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function RundownSelect({
|
||||||
|
cuesheetMode,
|
||||||
|
projectRundowns,
|
||||||
|
loadedRundownId,
|
||||||
|
selectedRundownId,
|
||||||
|
setSelectedRundownId,
|
||||||
|
}: RundownSelectProps) {
|
||||||
|
'use memo';
|
||||||
|
const options = projectRundowns.map(({ id, title }) => ({
|
||||||
|
value: id,
|
||||||
|
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||||
|
}));
|
||||||
|
options.unshift({
|
||||||
|
value: FOLLOW_LOADED_RUNDOWN_ID,
|
||||||
|
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.rundownSelect}>
|
||||||
|
<Select
|
||||||
|
value={selectedRundownId ?? undefined}
|
||||||
|
options={options}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setSelectedRundownId(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={cuesheetMode === AppMode.Run}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
|
||||||
|
|
||||||
|
describe('getEffectiveCuesheetMode()', () => {
|
||||||
|
it('honors the stored mode when the user can change mode on the current rundown', () => {
|
||||||
|
const permissions = { canChangeMode: true };
|
||||||
|
|
||||||
|
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
|
||||||
|
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces Run mode when the preset forbids mode changes', () => {
|
||||||
|
const permissions = { canChangeMode: false };
|
||||||
|
|
||||||
|
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces Edit mode for background rundowns without changing the permission policy', () => {
|
||||||
|
const permissions = { canChangeMode: true };
|
||||||
|
|
||||||
|
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
FOLLOW_LOADED_RUNDOWN_ID,
|
||||||
|
getCuesheetRundownStorageKey,
|
||||||
|
resolveSelectedRundownId,
|
||||||
|
} from '../useCuesheetRundownSelection';
|
||||||
|
|
||||||
|
describe('useCuesheetRundownSelection helpers', () => {
|
||||||
|
it('builds a project-scoped storage key', () => {
|
||||||
|
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
|
||||||
|
'cuesheet-selected-rundown:http://localhost:4001:My Project',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
|
||||||
|
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stored selection when it still exists in the current project', () => {
|
||||||
|
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { Rundown } from 'ontime-types';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||||
import { useEditModal } from './useEditModal';
|
import { useEditModal } from './useEditModal';
|
||||||
|
|
||||||
|
interface EntryEditModalProps {
|
||||||
|
rundown: Rundown;
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(EntryEditModal);
|
export default memo(EntryEditModal);
|
||||||
function EntryEditModal() {
|
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
||||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||||
const closeModal = useEditModal((state) => state.clearSelection);
|
const closeModal = useEditModal((state) => state.clearSelection);
|
||||||
|
|
||||||
@@ -19,7 +24,7 @@ function EntryEditModal() {
|
|||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title='Edit entry'
|
title='Edit entry'
|
||||||
showCloseButton
|
showCloseButton
|
||||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ContextProp,
|
ContextProp,
|
||||||
ItemProps,
|
ItemProps,
|
||||||
@@ -14,21 +14,20 @@ import {
|
|||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
|
|
||||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||||
|
|
||||||
import style from './CuesheetTable.module.scss';
|
import style from './CuesheetTable.module.scss';
|
||||||
@@ -36,30 +35,41 @@ import style from './CuesheetTable.module.scss';
|
|||||||
type CuesheetTableBaseProps = {
|
type CuesheetTableBaseProps = {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
|
source: RundownSource;
|
||||||
|
insertElement?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
tableRoot: 'editor';
|
tableRoot: 'editor';
|
||||||
setCuesheetMode?: undefined;
|
setCuesheetMode?: undefined;
|
||||||
|
isCurrentRundown?: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
tableRoot: 'cuesheet';
|
tableRoot: 'cuesheet';
|
||||||
setCuesheetMode: (mode: AppMode) => void;
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
|
isCurrentRundown?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||||
|
|
||||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
export default function CuesheetTable({
|
||||||
const { data, status } = useFlatRundownWithMetadata();
|
columns,
|
||||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
cuesheetMode,
|
||||||
|
source,
|
||||||
|
tableRoot,
|
||||||
|
setCuesheetMode,
|
||||||
|
isCurrentRundown,
|
||||||
|
insertElement,
|
||||||
|
}: CuesheetTableProps) {
|
||||||
|
const { flatRundown, status, selectedEventId } = source;
|
||||||
|
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||||
|
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||||
|
|
||||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||||
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
|
const optionsStore = useOptions();
|
||||||
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
|
const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
|
||||||
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
|
|
||||||
|
|
||||||
const selectedEventId = useSelectedEventId();
|
|
||||||
const cursor = useEventSelection((state) => state.cursor);
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||||
|
|
||||||
@@ -70,7 +80,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
() => ({
|
() => ({
|
||||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||||
// check if value is the same
|
// check if value is the same
|
||||||
const event = data[rowIndex];
|
const event = flatRundown[rowIndex];
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return;
|
return;
|
||||||
@@ -101,7 +111,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
hideIndexColumn,
|
hideIndexColumn,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
[cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||||
@@ -109,7 +119,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: flatRundown,
|
||||||
columns,
|
columns,
|
||||||
columnResizeMode: 'onChange',
|
columnResizeMode: 'onChange',
|
||||||
state: {
|
state: {
|
||||||
@@ -137,13 +147,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
||||||
if (eventIndex === -1) {
|
if (eventIndex === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
}, [cuesheetMode, data, selectedEventId]);
|
}, [cuesheetMode, flatRundown, selectedEventId]);
|
||||||
|
|
||||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,7 +162,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = data.findIndex((event) => event.id === entryId);
|
const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
|
||||||
if (eventIndex === -1) {
|
if (eventIndex === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,7 +175,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
return () => {
|
return () => {
|
||||||
setScrollHandler(null);
|
setScrollHandler(null);
|
||||||
};
|
};
|
||||||
}, [data, setScrollHandler]);
|
}, [flatRundown, setScrollHandler]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To improve performance on resizing, we memoise the column sizes
|
* To improve performance on resizing, we memoise the column sizes
|
||||||
@@ -194,8 +204,9 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
listeners,
|
listeners,
|
||||||
rows,
|
rows,
|
||||||
table,
|
table,
|
||||||
|
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
|
||||||
}),
|
}),
|
||||||
[columnSizeVars, cursor, listeners, rows, table],
|
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||||
@@ -217,7 +228,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
});
|
});
|
||||||
}, [cuesheetMode, hideIndexColumn, table]);
|
}, [cuesheetMode, hideIndexColumn, table]);
|
||||||
|
|
||||||
const isLoading = !data || status === 'pending';
|
const isLoading = !flatRundown || status === 'pending';
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <EmptyPage text='Loading...' />;
|
return <EmptyPage text='Loading...' />;
|
||||||
@@ -225,26 +236,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tableRoot === 'editor' ? (
|
<CuesheetTableHeaderToolbar
|
||||||
<EditorTableSettings
|
columns={allLeafColumns}
|
||||||
columns={allLeafColumns}
|
optionsStore={optionsStore}
|
||||||
handleResetResizing={resetColumnResizing}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
/>
|
insertElement={insertElement}
|
||||||
) : (
|
modeControls={
|
||||||
<CuesheetTableSettings
|
tableRoot === 'cuesheet'
|
||||||
columns={allLeafColumns}
|
? {
|
||||||
cuesheetMode={cuesheetMode}
|
cuesheetMode,
|
||||||
setCuesheetMode={setCuesheetMode}
|
setCuesheetMode,
|
||||||
handleResetResizing={resetColumnResizing}
|
isCurrentRundown,
|
||||||
handleResetReordering={resetColumnOrder}
|
}
|
||||||
handleClearToggles={setAllVisible}
|
: undefined
|
||||||
/>
|
}
|
||||||
)}
|
showShare={tableRoot === 'cuesheet'}
|
||||||
|
/>
|
||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
data={data}
|
data={flatRundown}
|
||||||
context={virtuosoContext}
|
context={virtuosoContext}
|
||||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||||
computeItemKey={computeItemKey}
|
computeItemKey={computeItemKey}
|
||||||
@@ -264,10 +276,13 @@ interface CuesheetVirtuosoContext {
|
|||||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||||
table: Table<ExtendedEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
|
handleAddNew?: (type: SupportedEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
|
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
||||||
return <EmptyTableBody text='No data in rundown' />;
|
context,
|
||||||
|
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
||||||
|
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
const CuesheetTableElement = memo(function CuesheetTableElement({
|
const CuesheetTableElement = memo(function CuesheetTableElement({
|
||||||
|
|||||||
+6
@@ -49,6 +49,10 @@ function DurationInput({
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||||
};
|
};
|
||||||
|
const handleTabOut = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// Tab moves focus naturally; do not steal it back to parent
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
@@ -95,8 +99,10 @@ function DurationInput({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
initialValue={timeString}
|
initialValue={timeString}
|
||||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||||
|
submitOnTab={false}
|
||||||
handleUpdate={handleUpdate}
|
handleUpdate={handleUpdate}
|
||||||
handleCancelUpdate={handleFakeBlur}
|
handleCancelUpdate={handleFakeBlur}
|
||||||
|
handleTabCancel={handleTabOut}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextLikeInput
|
<TextLikeInput
|
||||||
|
|||||||
+4
-3
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!initialValue && readOnly) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
variant='ghosted'
|
variant='ghosted'
|
||||||
className={style.imageInput}
|
className={style.imageInput}
|
||||||
fluid
|
fluid
|
||||||
readOnly={readOnly}
|
|
||||||
// we disable the field to prevent receiving focus
|
|
||||||
disabled={readOnly}
|
|
||||||
placeholder='Paste image URL'
|
placeholder='Paste image URL'
|
||||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
|||||||
+17
-15
@@ -1,7 +1,7 @@
|
|||||||
import { Table, flexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
import { CSSProperties, useMemo } from 'react';
|
import { CSSProperties, memo, useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
@@ -30,7 +30,8 @@ interface EventRowProps {
|
|||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRow({
|
export default memo(EventRow);
|
||||||
|
function EventRow({
|
||||||
rowId,
|
rowId,
|
||||||
id,
|
id,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
@@ -55,24 +56,25 @@ export default function EventRow({
|
|||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
|
||||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
const accessible = getAccessibleColour(colour);
|
||||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
|
||||||
|
|
||||||
const rowBgColour: string | undefined = useMemo(() => {
|
let rowBgColour: string | undefined;
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
return '#087A27'; // $active-green
|
rowBgColour = '#087A27'; // $active-green
|
||||||
} else if (colour) {
|
} else if (colour) {
|
||||||
// the colour is user defined and might be invalid
|
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
|
||||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
if (accessibleBg !== null) {
|
||||||
if (accessibleBackgroundColor !== null) {
|
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||||
return colourToHex({
|
|
||||||
...accessibleBackgroundColor,
|
|
||||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
|
return {
|
||||||
|
rowBgColour,
|
||||||
|
backgroundColor: accessible.backgroundColor,
|
||||||
|
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
|
||||||
|
};
|
||||||
}, [colour, isLoaded]);
|
}, [colour, isLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Table, flexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
import { CSSProperties } from 'react';
|
import { CSSProperties, memo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
@@ -20,7 +20,8 @@ interface GroupRowProps {
|
|||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GroupRow({
|
export default memo(GroupRow);
|
||||||
|
function GroupRow({
|
||||||
groupId,
|
groupId,
|
||||||
colour,
|
colour,
|
||||||
rowId,
|
rowId,
|
||||||
|
|||||||
+9
-13
@@ -1,7 +1,7 @@
|
|||||||
import { Table, flexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
import { CSSProperties } from 'react';
|
import { CSSProperties, memo, useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
@@ -25,7 +25,8 @@ interface MilestoneRowProps {
|
|||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MilestoneRow({
|
export default memo(MilestoneRow);
|
||||||
|
function MilestoneRow({
|
||||||
entryId,
|
entryId,
|
||||||
isPast,
|
isPast,
|
||||||
parentBgColour,
|
parentBgColour,
|
||||||
@@ -45,17 +46,12 @@ export default function MilestoneRow({
|
|||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
let rowBgColour: string | undefined;
|
const rowBgColour = useMemo(() => {
|
||||||
if (colour) {
|
if (!colour) return undefined;
|
||||||
// the colour is user defined and might be invalid
|
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
if (accessibleBg === null) return undefined;
|
||||||
if (accessibleBackgroundColor !== null) {
|
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||||
rowBgColour = colourToHex({
|
}, [colour]);
|
||||||
...accessibleBackgroundColor,
|
|
||||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|||||||
+14
-1
@@ -8,13 +8,24 @@ interface SingleLineCellProps {
|
|||||||
fieldId?: string;
|
fieldId?: string;
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
allowSubmitSameValue?: boolean;
|
allowSubmitSameValue?: boolean;
|
||||||
|
submitOnTab?: boolean;
|
||||||
handleUpdate: (newValue: string) => void;
|
handleUpdate: (newValue: string) => void;
|
||||||
handleCancelUpdate?: () => void;
|
handleCancelUpdate?: () => void;
|
||||||
|
handleTabCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleLineCell = forwardRef(
|
const SingleLineCell = forwardRef(
|
||||||
(
|
(
|
||||||
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
|
{
|
||||||
|
initialValue,
|
||||||
|
fieldId,
|
||||||
|
fieldLabel,
|
||||||
|
allowSubmitSameValue,
|
||||||
|
submitOnTab,
|
||||||
|
handleUpdate,
|
||||||
|
handleCancelUpdate,
|
||||||
|
handleTabCancel,
|
||||||
|
}: SingleLineCellProps,
|
||||||
inputRef,
|
inputRef,
|
||||||
) => {
|
) => {
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -25,7 +36,9 @@ const SingleLineCell = forwardRef(
|
|||||||
allowKeyboardNavigation: true,
|
allowKeyboardNavigation: true,
|
||||||
submitOnEnter: true, // single line should submit on enter
|
submitOnEnter: true, // single line should submit on enter
|
||||||
submitOnCtrlEnter: true,
|
submitOnCtrlEnter: true,
|
||||||
|
submitOnTab,
|
||||||
onCancelUpdate: handleCancelUpdate,
|
onCancelUpdate: handleCancelUpdate,
|
||||||
|
onTabCancel: handleTabCancel,
|
||||||
});
|
});
|
||||||
|
|
||||||
// expose a subset of the methods to the parent
|
// expose a subset of the methods to the parent
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ function TimeInputDuration({
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||||
};
|
};
|
||||||
|
const handleTabOut = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// Tab moves focus naturally; do not steal it back to parent
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
@@ -96,8 +100,10 @@ function TimeInputDuration({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
initialValue={timeString}
|
initialValue={timeString}
|
||||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||||
|
submitOnTab={false}
|
||||||
handleUpdate={handleUpdate}
|
handleUpdate={handleUpdate}
|
||||||
handleCancelUpdate={handleFakeBlur}
|
handleCancelUpdate={handleFakeBlur}
|
||||||
|
handleTabCancel={handleTabOut}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextLikeInput
|
<TextLikeInput
|
||||||
|
|||||||
+72
-48
@@ -11,78 +11,92 @@ import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
|||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
|
||||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||||
import CuesheetShareModal from './CuesheetShareModal';
|
import CuesheetShareModal from './CuesheetShareModal';
|
||||||
|
|
||||||
import style from './CuesheetTableSettings.module.scss';
|
import style from './CuesheetTableSettings.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableSettingsProps {
|
type TableHeaderOptionsStore = {
|
||||||
columns: Column<ExtendedEntry, unknown>[];
|
hideTableSeconds: boolean;
|
||||||
|
hideIndexColumn: boolean;
|
||||||
|
showDelayedTimes: boolean;
|
||||||
|
hideDelays: boolean;
|
||||||
|
setOption: <K extends keyof TableHeaderOptionValues>(key: K, value: TableHeaderOptionValues[K]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableHeaderOptionValues = Pick<
|
||||||
|
TableHeaderOptionsStore,
|
||||||
|
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type TableModeControls = {
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
setCuesheetMode: (mode: AppMode) => void;
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
handleResetResizing: () => void;
|
isCurrentRundown?: boolean;
|
||||||
handleResetReordering: () => void;
|
};
|
||||||
handleClearToggles: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewSettingsProps {
|
interface CuesheetTableHeaderToolbarProps {
|
||||||
optionsStore: CuesheetOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColumnSettingsProps {
|
|
||||||
columns: Column<ExtendedEntry, unknown>[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
|
optionsStore: TableHeaderOptionsStore;
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
|
insertElement?: ReactNode;
|
||||||
|
modeControls?: TableModeControls;
|
||||||
|
showShare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTableSettings({
|
export default function CuesheetTableHeaderToolbar({
|
||||||
columns,
|
columns,
|
||||||
cuesheetMode,
|
optionsStore,
|
||||||
setCuesheetMode,
|
|
||||||
handleResetResizing,
|
handleResetResizing,
|
||||||
handleResetReordering,
|
handleResetReordering,
|
||||||
handleClearToggles,
|
handleClearToggles,
|
||||||
}: CuesheetTableSettingsProps) {
|
insertElement,
|
||||||
|
modeControls,
|
||||||
|
showShare = false,
|
||||||
|
}: CuesheetTableHeaderToolbarProps) {
|
||||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||||
const options = usePersistedCuesheetOptions();
|
|
||||||
|
|
||||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
|
||||||
const newValue = mode.at(0);
|
const newValue = mode.at(0);
|
||||||
if (!newValue) return;
|
if (!newValue || !modeControls) return;
|
||||||
setCuesheetMode(newValue);
|
modeControls.setCuesheetMode(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isBackground = !(modeControls?.isCurrentRundown ?? true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar.Root className={style.tableSettings}>
|
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
|
||||||
<ViewSettings optionsStore={options} />
|
<ViewSettings optionsStore={optionsStore} />
|
||||||
<ColumnSettings
|
<ColumnSettings
|
||||||
columns={columns}
|
columns={columns}
|
||||||
handleResetResizing={handleResetResizing}
|
handleResetResizing={handleResetResizing}
|
||||||
handleResetReordering={handleResetReordering}
|
handleResetReordering={handleResetReordering}
|
||||||
handleClearToggles={handleClearToggles}
|
handleClearToggles={handleClearToggles}
|
||||||
/>
|
/>
|
||||||
{canChangeMode && (
|
{modeControls && canChangeMode && (
|
||||||
<ToggleGroup
|
<div className={style.apart}>
|
||||||
value={[cuesheetMode]}
|
{insertElement}
|
||||||
onValueChange={toggleCuesheetMode}
|
<ToggleGroup
|
||||||
className={cx([style.group, style.apart])}
|
value={[modeControls.cuesheetMode]}
|
||||||
>
|
onValueChange={toggleCuesheetMode}
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
className={style.group}
|
||||||
Run
|
disabled={!modeControls.isCurrentRundown}
|
||||||
</Toolbar.Button>
|
>
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||||
Edit
|
Run
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
</ToggleGroup>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||||
|
Edit
|
||||||
|
</Toolbar.Button>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canShare && (
|
{showShare && canShare && (
|
||||||
<>
|
<>
|
||||||
<Editor.Separator orientation='vertical' />
|
<Editor.Separator orientation='vertical' />
|
||||||
<CuesheetShareModal />
|
<CuesheetShareModal />
|
||||||
@@ -92,9 +106,18 @@ export default function CuesheetTableSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
interface ViewSettingsProps {
|
||||||
const options = optionsStore;
|
optionsStore: TableHeaderOptionsStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnSettingsProps {
|
||||||
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
|
handleResetResizing: () => void;
|
||||||
|
handleResetReordering: () => void;
|
||||||
|
handleClearToggles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||||
return (
|
return (
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
@@ -115,15 +138,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
|||||||
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
||||||
<Editor.Label className={style.option}>
|
<Editor.Label className={style.option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={options.hideTableSeconds}
|
defaultChecked={optionsStore.hideTableSeconds}
|
||||||
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
|
onCheckedChange={(checked) => optionsStore.setOption('hideTableSeconds', checked)}
|
||||||
/>
|
/>
|
||||||
Hide seconds in table
|
Hide seconds in table
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
<Editor.Label className={style.option}>
|
<Editor.Label className={style.option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={options.hideIndexColumn}
|
defaultChecked={optionsStore.hideIndexColumn}
|
||||||
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
|
onCheckedChange={(checked) => optionsStore.setOption('hideIndexColumn', checked)}
|
||||||
/>
|
/>
|
||||||
Hide index column
|
Hide index column
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
@@ -133,15 +156,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
|||||||
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
||||||
<Editor.Label className={style.option}>
|
<Editor.Label className={style.option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={options.showDelayedTimes}
|
defaultChecked={optionsStore.showDelayedTimes}
|
||||||
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
|
onCheckedChange={(checked) => optionsStore.setOption('showDelayedTimes', checked)}
|
||||||
/>
|
/>
|
||||||
Show delayed times
|
Show delayed times
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
<Editor.Label className={style.option}>
|
<Editor.Label className={style.option}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={options.hideDelays}
|
defaultChecked={optionsStore.hideDelays}
|
||||||
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
|
onCheckedChange={(checked) => optionsStore.setOption('hideDelays', checked)}
|
||||||
/>
|
/>
|
||||||
Hide delay entries
|
Hide delay entries
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
@@ -151,7 +174,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColumnSettings({
|
function ColumnSettings({
|
||||||
columns,
|
columns,
|
||||||
handleResetResizing,
|
handleResetResizing,
|
||||||
handleResetReordering,
|
handleResetReordering,
|
||||||
@@ -177,6 +200,7 @@ export function ColumnSettings({
|
|||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnHeader = column.columnDef.header;
|
const columnHeader = column.columnDef.header;
|
||||||
const visible = column.getIsVisible();
|
const visible = column.getIsVisible();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||||
+28
-2
@@ -8,6 +8,24 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 3px 3px 0 0;
|
||||||
|
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&[data-background-rundown='true'] {
|
||||||
|
background-color: rgba($ontime-color, 0.5);
|
||||||
|
|
||||||
|
.apart::before {
|
||||||
|
content: 'BACKGROUND EDIT';
|
||||||
|
color: $ui-white;
|
||||||
|
animation: blinker 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blinker {
|
||||||
|
50% {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
@@ -43,6 +61,9 @@
|
|||||||
|
|
||||||
.apart {
|
.apart {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** styles should match RundownHeader.module.scss */
|
/** styles should match RundownHeader.module.scss */
|
||||||
@@ -70,12 +91,17 @@
|
|||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: 1px solid $blue-500;
|
outline: 1px solid $blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
&:hover:not([data-disabled]):not(:active) {
|
||||||
background: $gray-1000;
|
background: $gray-1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +113,7 @@
|
|||||||
background: $blue-700;
|
background: $blue-700;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
&:hover:not([data-disabled]):not(:active) {
|
||||||
background: $blue-600;
|
background: $blue-600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,33 @@ import { useCallback, useEffect, useMemo } from 'react';
|
|||||||
import { sessionScope } from '../../externals';
|
import { sessionScope } from '../../externals';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
||||||
|
import type { CuesheetPermissions } from './useTablePermissions';
|
||||||
import { useCuesheetPermissions } from './useTablePermissions';
|
import { useCuesheetPermissions } from './useTablePermissions';
|
||||||
|
|
||||||
|
type CuesheetPolicyOptions = {
|
||||||
|
// Run mode is only meaningful for the loaded rundown — background rundowns force Edit.
|
||||||
|
canRunMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEffectiveCuesheetMode(
|
||||||
|
permissions: Pick<CuesheetPermissions, 'canChangeMode'>,
|
||||||
|
storedMode: AppMode,
|
||||||
|
{ canRunMode }: CuesheetPolicyOptions,
|
||||||
|
) {
|
||||||
|
if (!permissions.canChangeMode) {
|
||||||
|
return AppMode.Run;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canRunMode ? storedMode : AppMode.Edit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
||||||
*/
|
*/
|
||||||
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
export function useApplyCuesheetPolicy(
|
||||||
|
preset: URLPreset | undefined,
|
||||||
|
{ canRunMode }: CuesheetPolicyOptions,
|
||||||
|
): {
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
setCuesheetMode: (mode: AppMode) => void;
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
} {
|
} {
|
||||||
@@ -26,16 +47,14 @@ export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
|||||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
|
const cuesheetMode = getEffectiveCuesheetMode(permissions, storedCuesheetMode, { canRunMode });
|
||||||
|
|
||||||
const setCuesheetMode = useCallback(
|
const setCuesheetMode = useCallback(
|
||||||
(mode: AppMode) => {
|
(mode: AppMode) => {
|
||||||
if (!permissions.canChangeMode) {
|
if (!permissions.canChangeMode || !canRunMode) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStoredCuesheetMode(mode);
|
setStoredCuesheetMode(mode);
|
||||||
},
|
},
|
||||||
[permissions.canChangeMode, setStoredCuesheetMode],
|
[canRunMode, permissions.canChangeMode, setStoredCuesheetMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the shared permissions store aligned with the active preset policy.
|
// Keep the shared permissions store aligned with the active preset policy.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
|
import { startTransition, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
|
||||||
|
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
|
||||||
|
import { serverURL } from '../../externals';
|
||||||
|
|
||||||
|
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
|
||||||
|
|
||||||
|
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
|
||||||
|
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
|
||||||
|
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
|
||||||
|
return FOLLOW_LOADED_RUNDOWN_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCuesheetRundownSelection() {
|
||||||
|
'use memo';
|
||||||
|
|
||||||
|
const { data: projectRundowns } = useProjectRundowns();
|
||||||
|
const {
|
||||||
|
data: { lastLoadedProject },
|
||||||
|
} = useOrderedProjectList();
|
||||||
|
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
|
||||||
|
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
|
||||||
|
key: storageKey,
|
||||||
|
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
|
||||||
|
const { loaded: loadedRundownId } = projectRundowns;
|
||||||
|
|
||||||
|
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadedRundownId,
|
||||||
|
selectedRundownId,
|
||||||
|
projectRundowns: projectRundowns.rundowns,
|
||||||
|
setSelectedRundownId: (rundownId: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setStoredSelectedRundownId(rundownId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirectLinkToBackgroundEdit() {
|
||||||
|
const {
|
||||||
|
data: { lastLoadedProject },
|
||||||
|
} = useOrderedProjectList();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
|
||||||
|
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (rundownId: string) => {
|
||||||
|
await navigate('/cuesheet');
|
||||||
|
startTransition(() => setStoredSelectedRundownId(rundownId));
|
||||||
|
},
|
||||||
|
[setStoredSelectedRundownId, navigate],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
|||||||
{projectData.custom.map((info, idx) => {
|
{projectData.custom.map((info, idx) => {
|
||||||
const hasUrl = Boolean(info.url);
|
const hasUrl = Boolean(info.url);
|
||||||
return (
|
return (
|
||||||
|
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
|
||||||
<div key={`${info.title}-${idx}`} className='info__custom'>
|
<div key={`${info.title}-${idx}`} className='info__custom'>
|
||||||
{hasUrl && (
|
{hasUrl && (
|
||||||
<div className='info__image-container'>
|
<div className='info__image-container'>
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ $studio-idle: $red-1300;
|
|||||||
top: 0;
|
top: 0;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
margin-left: -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tick--active {
|
.tick--active {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useStudioClockSocket } from '../../common/hooks/useSocket';
|
|||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../common/utils/time';
|
import { formatTime } from '../../common/utils/time';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
|
import { useStudioOptions } from './studio.options';
|
||||||
import { getLargeClockData } from './studioClock.utils';
|
import { getLargeClockData } from './studioClock.utils';
|
||||||
|
|
||||||
import './StudioClock.scss';
|
import './StudioClock.scss';
|
||||||
@@ -18,6 +19,7 @@ interface StudioClockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock({ hideCards }: StudioClockProps) {
|
export default function StudioClock({ hideCards }: StudioClockProps) {
|
||||||
|
const { timeformat } = useStudioOptions();
|
||||||
const isSmallScreen = useIsSmallScreen();
|
const isSmallScreen = useIsSmallScreen();
|
||||||
const clock = useAutoTickingClock();
|
const clock = useAutoTickingClock();
|
||||||
const { playback } = useStudioClockSocket();
|
const { playback } = useStudioClockSocket();
|
||||||
@@ -25,10 +27,10 @@ export default function StudioClock({ hideCards }: StudioClockProps) {
|
|||||||
|
|
||||||
// if we are on mobile and have to show the cards
|
// if we are on mobile and have to show the cards
|
||||||
if (isSmallScreen && !hideCards) {
|
if (isSmallScreen && !hideCards) {
|
||||||
return <StudioClockMobile clock={clock} onAir={onAir} />;
|
return <StudioClockMobile clock={clock} onAir={onAir} timeformat={timeformat} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { seconds, display, meridian } = getLargeClockData(clock);
|
const { seconds, display, meridian } = getLargeClockData(clock, timeformat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='studio__clock'>
|
<div className='studio__clock'>
|
||||||
@@ -62,10 +64,11 @@ export default function StudioClock({ hideCards }: StudioClockProps) {
|
|||||||
interface StudioClockMobileProps {
|
interface StudioClockMobileProps {
|
||||||
clock: number;
|
clock: number;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
|
timeformat: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StudioClockMobile({ clock, onAir }: StudioClockMobileProps) {
|
function StudioClockMobile({ clock, onAir, timeformat }: StudioClockMobileProps) {
|
||||||
const displayClock = formatTime(clock);
|
const displayClock = formatTime(clock, { override: timeformat });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='studio__clock studio__clock--small'>
|
<div className='studio__clock studio__clock--small'>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent } from 'ontime-types';
|
|||||||
import { use, useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
getTimeOptionsFromParams,
|
||||||
|
TimeOptions,
|
||||||
|
} from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
@@ -47,7 +51,7 @@ export const getStudioOptions = (timeFormat: string, customFields: CustomFields)
|
|||||||
type StudioOptions = {
|
type StudioOptions = {
|
||||||
mainSource: keyof OntimeEvent | null;
|
mainSource: keyof OntimeEvent | null;
|
||||||
hideCards: boolean;
|
hideCards: boolean;
|
||||||
};
|
} & TimeOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
@@ -60,6 +64,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
return {
|
return {
|
||||||
mainSource: getValue('main') as keyof OntimeEvent | null,
|
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||||
hideCards: isStringBoolean(getValue('hideCards')),
|
hideCards: isStringBoolean(getValue('hideCards')),
|
||||||
|
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { formatTime } from '../../common/utils/time';
|
|||||||
/**
|
/**
|
||||||
* Gathers display elements for the large studio clock
|
* Gathers display elements for the large studio clock
|
||||||
*/
|
*/
|
||||||
export function getLargeClockData(clock: number) {
|
export function getLargeClockData(clock: number, timeformat: string | null) {
|
||||||
const [display, meridian] = (() => {
|
const [display, meridian] = (() => {
|
||||||
const formatted = formatTime(clock);
|
const formatted = formatTime(clock, { override: timeformat });
|
||||||
if (formatted.endsWith('AM')) {
|
if (formatted.endsWith('AM')) {
|
||||||
return [formatted.slice(0, -2), 'AM'];
|
return [formatted.slice(0, -2), 'AM'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function TimelinePageLoader() {
|
|||||||
|
|
||||||
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
|
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const { mainSource } = useTimelineOptions();
|
const { mainSource, timeformat } = useTimelineOptions();
|
||||||
// holds copy of the rundown with only relevant events
|
// holds copy of the rundown with only relevant events
|
||||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
|
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||||
<div className='title'>{projectData.title}</div>
|
<div className='title'>{projectData.title}</div>
|
||||||
<TimelineClock />
|
<TimelineClock timeformat={timeformat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
|
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
|
||||||
@@ -71,12 +71,12 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineClock() {
|
function TimelineClock({ timeformat }: { timeformat: string | null }) {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const clock = useAutoTickingClock();
|
const clock = useAutoTickingClock();
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = formatTime(clock, { override: timeformat });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent } from 'ontime-types';
|
|||||||
import { use, useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
getTimeOptionsFromParams,
|
||||||
|
TimeOptions,
|
||||||
|
} from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
@@ -55,7 +59,7 @@ type TimelineOptions = {
|
|||||||
mainSource: keyof OntimeEvent | null;
|
mainSource: keyof OntimeEvent | null;
|
||||||
hidePast: boolean;
|
hidePast: boolean;
|
||||||
fixedSize: boolean;
|
fixedSize: boolean;
|
||||||
};
|
} & TimeOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
@@ -69,6 +73,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
mainSource: getValue('main') as keyof OntimeEvent | null,
|
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||||
hidePast: isStringBoolean(getValue('hidePast')),
|
hidePast: isStringBoolean(getValue('hidePast')),
|
||||||
fixedSize: isStringBoolean(getValue('fixedSize')),
|
fixedSize: isStringBoolean(getValue('fixedSize')),
|
||||||
|
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OntimeView, TimerType } from 'ontime-types';
|
import { MaybeString, OntimeView, TimerType } from 'ontime-types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { FitText } from '../../common/components/fit-text/FitText';
|
import { FitText } from '../../common/components/fit-text/FitText';
|
||||||
@@ -69,6 +69,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
font,
|
font,
|
||||||
keyColour,
|
keyColour,
|
||||||
timerColour,
|
timerColour,
|
||||||
|
timeformat,
|
||||||
} = useTimerOptions();
|
} = useTimerOptions();
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -106,6 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||||
removeSeconds: hideTimerSeconds,
|
removeSeconds: hideTimerSeconds,
|
||||||
removeLeadingZero: removeLeadingZeros,
|
removeLeadingZero: removeLeadingZeros,
|
||||||
|
clockFormat: timeformat,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentAux = (() => {
|
const currentAux = (() => {
|
||||||
@@ -164,7 +166,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showClock && <TimerAutoTickingClock />}
|
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
|
||||||
|
|
||||||
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
@@ -212,9 +214,9 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimerAutoTickingClock() {
|
function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
||||||
const autoTickingClock = useAutoTickingClock();
|
const autoTickingClock = useAutoTickingClock();
|
||||||
const formattedClock = formatTime(autoTickingClock);
|
const formattedClock = formatTime(autoTickingClock, { override: clockFormat });
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useSearchParams } from 'react-router';
|
|||||||
import type { SelectOption } from '../../common/components/select/Select';
|
import type { SelectOption } from '../../common/components/select/Select';
|
||||||
import {
|
import {
|
||||||
getTimeOption,
|
getTimeOption,
|
||||||
|
getTimeOptionsFromParams,
|
||||||
hideTimerSeconds,
|
hideTimerSeconds,
|
||||||
showLeadingZeros,
|
showLeadingZeros,
|
||||||
|
TimeOptions,
|
||||||
} from '../../common/components/view-params-editor/common.options';
|
} from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||||
@@ -194,7 +196,7 @@ type TimerOptions = {
|
|||||||
font?: string;
|
font?: string;
|
||||||
keyColour?: string;
|
keyColour?: string;
|
||||||
timerColour?: string;
|
timerColour?: string;
|
||||||
};
|
} & TimeOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
@@ -229,6 +231,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
font: getValue('font') ?? undefined,
|
font: getValue('font') ?? undefined,
|
||||||
keyColour: makeColourString(getValue('keyColour')),
|
keyColour: makeColourString(getValue('keyColour')),
|
||||||
timerColour: makeColourString(getValue('timerColour')),
|
timerColour: makeColourString(getValue('timerColour')),
|
||||||
|
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
import legacy from '@vitejs/plugin-legacy';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { compression } from 'vite-plugin-compression2';
|
import { compression } from 'vite-plugin-compression2';
|
||||||
@@ -21,6 +22,9 @@ export default defineConfig({
|
|||||||
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
|
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
legacy({
|
||||||
|
targets: ['safari >= 14.1'],
|
||||||
|
}),
|
||||||
react({
|
react({
|
||||||
babel: {
|
babel: {
|
||||||
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
|
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
|
||||||
@@ -83,7 +87,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
dom: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: './build',
|
outDir: './build',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "38.2.1",
|
"electron": "38.2.1",
|
||||||
"electron-builder": "26.0.18",
|
"electron-builder": "26.9.1",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,8 +29,7 @@
|
|||||||
"appId": "no.lightdev.ontime",
|
"appId": "no.lightdev.ontime",
|
||||||
"asar": true,
|
"asar": true,
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"artifactName": "ontime-macOS-${arch}.dmg",
|
"artifactName": "ontime-macOS-${arch}.dmg"
|
||||||
"icon": "icon.icns"
|
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"notarize": true,
|
"notarize": true,
|
||||||
@@ -46,7 +45,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "public.app-category.productivity",
|
"category": "public.app-category.productivity",
|
||||||
"icon": "icon.icns"
|
"icon": "ontime.icon"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis"
|
"target": "nsis"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : "system-dark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : "system-dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"blend-mode-specializations" : [
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : "normal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden" : false,
|
||||||
|
"image-name" : "ontime-icon.png",
|
||||||
|
"name" : "ontime-icon",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1,
|
||||||
|
"translation-in-points" : [
|
||||||
|
-0.5,
|
||||||
|
-113
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.6",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "5.1.0",
|
"express": "5.2.1",
|
||||||
"express-static-gzip": "3.0.0",
|
"express-static-gzip": "3.0.1",
|
||||||
"express-validator": "7.2.1",
|
"express-validator": "7.3.2",
|
||||||
"fast-equals": "^5.0.1",
|
"fast-equals": "^6.0.0",
|
||||||
"google-auth-library": "^9.4.2",
|
"google-auth-library": "^9.4.2",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"multer": "2.1.0",
|
"multer": "2.2.0",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"osc-min": "2.1.2",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
@@ -24,17 +24,15 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "1.4.9",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.6",
|
||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/websocket": "^1.0.5",
|
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.24.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"server-timing": "^3.3.3",
|
"server-timing": "^3.3.3",
|
||||||
"shx": "^0.3.4",
|
|
||||||
"ts-essentials": "catalog:",
|
"ts-essentials": "catalog:",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user