mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20ea88021 | |||
| ce2b2b9606 | |||
| f223766445 | |||
| 3dfb49e63f | |||
| a13d3dad93 | |||
| fa9695873a | |||
| 70ce1d5553 | |||
| 9974ce24cd | |||
| 93e9c19e0d | |||
| 1694cca92c | |||
| c1d9276dd5 | |||
| f5753a0f5a | |||
| 843f5a968d | |||
| e09b6bbc16 | |||
| 19d6b40bdb | |||
| 1f2706a063 | |||
| aaeadd13cf | |||
| 72e3eb11d7 | |||
| 6f55d8a6c9 | |||
| 70846aefbe | |||
| 47ba90f191 | |||
| cee3c9c070 | |||
| 15f3b9625c | |||
| d34a1f0fd8 | |||
| 0e3b3bcf9a | |||
| 50c91f976b | |||
| 50599eccd2 | |||
| 9f2db10548 | |||
| 5dce1a40bc | |||
| b75b69c3b1 | |||
| e6070e6efd | |||
| 3918664945 | |||
| b65b3fa6d2 | |||
| 162693e19e | |||
| becd734ea8 | |||
| 9dfc8083f0 | |||
| 7bd98c23a4 | |||
| 6f19d78e53 | |||
| 163baa752f | |||
| 6f708df6f6 | |||
| eb5c62cca1 | |||
| 99d2debca8 | |||
| 74e59dccdb | |||
| 812b17a221 | |||
| 32bf0f53f6 | |||
| 17f80baf48 | |||
| e0377a7244 | |||
| b7e5723fb2 | |||
| 897210d5e3 | |||
| ee3d38fce8 | |||
| 37abd3fe47 | |||
| b509ee5b1f | |||
| dd2b59efe5 | |||
| 5788785b3e | |||
| e488ad19b3 | |||
| cbc03c898c | |||
| 155bfbdc42 | |||
| 5d3714a4fc | |||
| a8a1d210d8 | |||
| 49de8b63a7 | |||
| 03c9f51b47 | |||
| 8d00fd3966 | |||
| 80fd21354e | |||
| a87b15825a | |||
| 6a8f98c469 | |||
| 8685298430 | |||
| d43af2db57 | |||
| ef718c48e4 | |||
| 45e7373279 | |||
| 6af2d6e021 | |||
| 9b36f36f73 | |||
| 3a0dbf057e | |||
| dae84ab48a | |||
| aa927597f3 | |||
| ef7f0fb54a | |||
| 324320fdc8 | |||
| 4b27d080b6 | |||
| 7918063753 | |||
| e2202c2417 | |||
| 9817c8f8c0 | |||
| 1d8c04d0b4 | |||
| 10762f10bf | |||
| da7c0d4ff2 | |||
| 4a27973de1 | |||
| bdeb087e28 | |||
| 3662cd58f5 | |||
| 62c52bca07 | |||
| 1fc4a06133 | |||
| 147862e647 | |||
| 12bc323e57 |
@@ -17,7 +17,6 @@
|
|||||||
# Ignore build folders
|
# Ignore build folders
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
|
||||||
|
|
||||||
# Ignore default volumes created by running docker compose up
|
# Ignore default volumes created by running docker compose up
|
||||||
ontime-db
|
ontime-db
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"jest": true
|
"jest": true
|
||||||
},
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||||
"plugins": ["@typescript-eslint", "prettier"],
|
"plugins": ["@typescript-eslint", "prettier"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 259 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 149 KiB |
@@ -13,13 +13,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- 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@v4
|
||||||
with:
|
with:
|
||||||
@@ -38,16 +43,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy server
|
- name: Copy server
|
||||||
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||||
|
|
||||||
- name: Copy client
|
- name: Copy client
|
||||||
run: cp -R apps/client/build apps/cli/client
|
run: cp -R apps/client/build apps/cli/client
|
||||||
|
|
||||||
- name: Copy external
|
- name: Copy external
|
||||||
run: cp -R apps/server/src/external apps/cli/external
|
run: cp -R apps/server/src/external apps/cli/external
|
||||||
|
|
||||||
|
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
|
||||||
|
- name: Install newer version of npm
|
||||||
|
run: npm install -g npm@11
|
||||||
|
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
run: pnpm publish --access public --no-git-checks
|
run: pnpm publish --access public --no-git-checks
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
working-directory: ./apps/cli
|
working-directory: ./apps/cli
|
||||||
|
|||||||
@@ -6,43 +6,63 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
publish_docker:
|
publish_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI: ''
|
CI: ''
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker Login
|
- name: Setup pnpm
|
||||||
uses: docker/login-action@v2.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker Setup Buildx
|
- name: Get pnpm store directory
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push stable release
|
- name: Setup pnpm cache
|
||||||
if: github.event.release.prerelease == false
|
uses: actions/cache@v4
|
||||||
uses: docker/build-push-action@v4.0.0
|
with:
|
||||||
with:
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
context: .
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
file: ./Dockerfile
|
restore-keys: |
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
${{ runner.os }}-pnpm-store-
|
||||||
# Push is a shorthand for --output=type=registry
|
|
||||||
push: true
|
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
|
||||||
|
|
||||||
- name: Build and push pre-release
|
- name: Install dependencies
|
||||||
if: github.event.release.prerelease == true
|
run: pnpm install --frozen-lockfile
|
||||||
uses: docker/build-push-action@v4.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
|
||||||
# Push is a shorthand for --output=type=registry
|
|
||||||
push: true
|
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
|
||||||
|
|
||||||
|
- name: Build project packages
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
run: pnpm build:docker
|
||||||
|
|
||||||
|
- name: Docker Login
|
||||||
|
uses: docker/login-action@v2.1.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Setup Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
|
- name: Build and push stable release
|
||||||
|
if: github.event.release.prerelease == false
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||||
|
|
||||||
|
- name: Build and push pre-release
|
||||||
|
if: github.event.release.prerelease == true
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Ontime build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [ "*" ]
|
tags: ['*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -11,13 +11,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- 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@v4
|
||||||
with:
|
with:
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Electron - Build app
|
- name: Electron - Build app
|
||||||
env:
|
env:
|
||||||
APPLE_ID: ${{ secrets.APPLEID }}
|
APPLE_ID: ${{ secrets.APPLEID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
name: Ontime Resolver build
|
name: Ontime Resolver build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -13,13 +11,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- 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@v4
|
||||||
with:
|
with:
|
||||||
@@ -34,9 +37,12 @@ jobs:
|
|||||||
- name: Build project packages
|
- name: Build project packages
|
||||||
run: pnpm build:resolver
|
run: pnpm build:resolver
|
||||||
|
|
||||||
|
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
|
||||||
|
- name: Install newer version of npm
|
||||||
|
run: npm install -g npm@11
|
||||||
|
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
run: pnpm publish --access public --no-git-checks
|
run: pnpm publish --access public --no-git-checks
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
working-directory: ./apps/resolver
|
working-directory: ./apps/resolver
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
|
|
||||||
- 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@v4
|
||||||
with:
|
with:
|
||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Run code quality
|
# Run code quality
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
- name: Run TypeScript checks
|
- name: Run TypeScript checks
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
- 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@v4
|
||||||
with:
|
with:
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
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-
|
||||||
|
|||||||
+2
-1
@@ -5,8 +5,9 @@ node_modules
|
|||||||
|
|
||||||
playwright-report
|
playwright-report
|
||||||
|
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
**/*.toml
|
**/*.toml
|
||||||
**/*.yml
|
|
||||||
**/*.json
|
**/*.json
|
||||||
!tsconfig.common.json
|
!tsconfig.common.json
|
||||||
!turbo.json
|
!turbo.json
|
||||||
|
|||||||
+1
-3
@@ -21,6 +21,7 @@ Locally, we would need to run both the React client and the node.js server in de
|
|||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
|
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
|
||||||
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
|
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
|
||||||
|
|
||||||
|
|
||||||
@@ -82,9 +83,6 @@ From the project root, run the following commands
|
|||||||
|
|
||||||
The build distribution assets will be at `.apps/electron/dist`
|
The build distribution assets will be at `.apps/electron/dist`
|
||||||
|
|
||||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
|
||||||
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
|
|
||||||
|
|
||||||
## DOCKER
|
## DOCKER
|
||||||
|
|
||||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||||
|
|||||||
+9
-15
@@ -1,13 +1,5 @@
|
|||||||
FROM node:22-bullseye AS builder
|
ARG NODE_VERSION=22.15.1
|
||||||
ENV PNPM_HOME="/pnpm"
|
FROM node:${NODE_VERSION}-alpine
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN npm install -g pnpm@10.11.0
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
|
|
||||||
RUN pnpm run build:docker
|
|
||||||
|
|
||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
# Environment Variable to signal that we are running production
|
# Environment Variable to signal that we are running production
|
||||||
@@ -15,16 +7,17 @@ ENV NODE_ENV=docker
|
|||||||
# Ontime Data path
|
# Ontime Data path
|
||||||
ENV ONTIME_DATA=/data/
|
ENV ONTIME_DATA=/data/
|
||||||
|
|
||||||
|
RUN mkdir /app
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
# Prepare UI
|
# Prepare UI
|
||||||
COPY --from=builder /app/apps/client/build ./client/
|
COPY apps/client/build/ ./client/
|
||||||
|
|
||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY apps/server/src/external/ ./external/
|
||||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
COPY apps/server/src/user/ ./user/
|
||||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
COPY apps/server/src/html/ ./html/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
@@ -32,5 +25,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
|
|||||||
CMD ["node", "server/docker.cjs"]
|
CMD ["node", "server/docker.cjs"]
|
||||||
|
|
||||||
# Build and run commands
|
# Build and run commands
|
||||||
|
# pnpm build:docker
|
||||||
# docker buildx build . -t getontime/ontime
|
# docker buildx build . -t getontime/ontime
|
||||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
|
|||||||
Let us know!
|
Let us know!
|
||||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||||
|
|
||||||
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
|
|
||||||
|
|
||||||
# Ontime
|
# Ontime
|
||||||
|
|
||||||
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
|
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.2.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.6",
|
"@fontsource/open-sans": "^5.2.6",
|
||||||
"@mantine/hooks": "^8.2.8",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.2.0",
|
"@sentry/react": "^10.2.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default function AppRouter() {
|
|||||||
path='op'
|
path='op'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
<Operator />
|
<Operator />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
@@ -207,13 +208,13 @@ function PresetView() {
|
|||||||
/**
|
/**
|
||||||
* Locked presets do not allow configuration changes
|
* Locked presets do not allow configuration changes
|
||||||
* Whether the user can navigate is determined by the locked param
|
* Whether the user can navigate is determined by the locked param
|
||||||
|
*
|
||||||
|
* We inject the preset to the context value for the view to consume
|
||||||
*/
|
*/
|
||||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||||
return (
|
return (
|
||||||
<PresetContext value={preset}>
|
<PresetContext value={preset}>
|
||||||
{preset.target !== OntimeView.Cuesheet && (
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
|
||||||
)}
|
|
||||||
{Component ? <Component /> : <NotFound />}
|
{Component ? <Component /> : <NotFound />}
|
||||||
</PresetContext>
|
</PresetContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { CustomFields, Rundown } from 'ontime-types';
|
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
const excelPath = `${apiEntryUrl}/excel`;
|
const excelPath = `${apiEntryUrl}/excel`;
|
||||||
|
|
||||||
@@ -10,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
|
|||||||
* upload Excel file to server
|
* upload Excel file to server
|
||||||
* @return string - file ID op the uploaded file
|
* @return string - file ID op the uploaded file
|
||||||
*/
|
*/
|
||||||
export async function upload(file: File) {
|
export async function upload(file: File): Promise<string[]> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('excel', file);
|
formData.append('excel', file);
|
||||||
await axios.post(`${excelPath}/upload`, formData, {
|
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Worksheet names
|
|
||||||
* @return string[] - array of available worksheets
|
|
||||||
*/
|
|
||||||
export async function getWorksheetNames(): Promise<string[]> {
|
|
||||||
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewSpreadsheetResponse = {
|
type PreviewSpreadsheetResponse = {
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
};
|
};
|
||||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||||
@@ -39,3 +33,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a xlsx representation of the rundown from the server
|
||||||
|
*/
|
||||||
|
export async function downloadAsExcel(rundownId: string, fileName?: string) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
|
|||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
|
type RundownId = string;
|
||||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||||
|
|
||||||
// #region operations on project rundowns =========================
|
// #region operations on project rundowns =========================
|
||||||
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to switch the currently loaded rundown
|
* HTTP request to switch the currently loaded rundown
|
||||||
*/
|
*/
|
||||||
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
return axios.post(`${rundownPath}/${id}/load`);
|
return axios.post(`${rundownPath}/${rundownId}/load`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
|
|||||||
return axios.post(rundownPath, { title });
|
return axios.post(rundownPath, { title });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to duplicate an existing rundown
|
||||||
|
*/
|
||||||
|
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to rename an existing rundown
|
||||||
|
*/
|
||||||
|
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.patch(`${rundownPath}/${rundownId}`, { title });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete a rundown
|
* HTTP request to delete a rundown
|
||||||
*/
|
*/
|
||||||
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
return axios.delete(`${rundownPath}/${id}`);
|
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
|
|||||||
* HTTP request to post new entry
|
* HTTP request to post new entry
|
||||||
*/
|
*/
|
||||||
export async function postAddEntry(
|
export async function postAddEntry(
|
||||||
rundownId: string,
|
rundownId: RundownId,
|
||||||
data: TransientEventPayload,
|
data: TransientEventPayload,
|
||||||
): Promise<AxiosResponse<OntimeEntry>> {
|
): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
@@ -60,7 +75,7 @@ export async function postAddEntry(
|
|||||||
/**
|
/**
|
||||||
* HTTP request to edit an entry
|
* HTTP request to edit an entry
|
||||||
*/
|
*/
|
||||||
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||||
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +87,7 @@ export type BatchEditEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to edit multiple events
|
* HTTP request to edit multiple events
|
||||||
*/
|
*/
|
||||||
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,56 +100,56 @@ export type ReorderEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to reorder an entry
|
* HTTP request to reorder an entry
|
||||||
*/
|
*/
|
||||||
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to swap two events
|
* HTTP request to swap two events
|
||||||
*/
|
*/
|
||||||
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestEventSwap(rundownId: RundownId, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
|
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to request application of delay
|
* HTTP request to request application of delay
|
||||||
*/
|
*/
|
||||||
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
|
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for cloning an entry
|
* HTTP request for cloning an entry
|
||||||
*/
|
*/
|
||||||
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a group
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for dissolving of a group
|
* HTTP request for dissolving of a group
|
||||||
*/
|
*/
|
||||||
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete entries of a given rundown
|
* HTTP request to delete entries of a given rundown
|
||||||
*/
|
*/
|
||||||
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to delete all entries of a given rundown
|
* HTTP request to delete all entries of a given rundown
|
||||||
*/
|
*/
|
||||||
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
|
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
|
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
@@ -56,6 +56,7 @@ export const previewRundown = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PropsWithChildren, useRef, useState } from 'react';
|
import { PropsWithChildren, useRef, useState } from 'react';
|
||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark, IoCopy } from 'react-icons/io5';
|
||||||
import { IoCopy } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import copyToClipboard from '../../utils/copyToClipboard';
|
import copyToClipboard from '../../utils/copyToClipboard';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -61,6 +60,13 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-type='destructive'] {
|
||||||
|
color: $red-500;
|
||||||
|
svg {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
|
|||||||
|
|
||||||
type DropdownMenuItemDivider = { type: 'divider' };
|
type DropdownMenuItemDivider = { type: 'divider' };
|
||||||
type DropdownMenuItem = {
|
type DropdownMenuItem = {
|
||||||
type: 'item';
|
type: 'item' | 'destructive';
|
||||||
label: string;
|
label: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
|
|||||||
return <BaseMenu.Separator key={index} className={style.separator} />;
|
return <BaseMenu.Separator key={index} className={style.separator} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
{item.label}
|
{item.label}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.corner {
|
.arrow {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offsetCorner {
|
||||||
|
right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,6 @@
|
|||||||
|
|
||||||
&.vertical {
|
&.vertical {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 0.75em;
|
height: 0.75em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
|
import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
|
||||||
import { IconBaseProps } from 'react-icons';
|
import { IconBaseProps } from 'react-icons';
|
||||||
import { IoArrowUp } from 'react-icons/io5';
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
|
import { TbPictureInPictureOff } from 'react-icons/tb';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './EditorUtils.module.scss';
|
import style from './EditorUtils.module.scss';
|
||||||
|
|
||||||
export function Corner({ className, ...elementProps }: IconBaseProps) {
|
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
|
||||||
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
|
||||||
|
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractAndPip extends IconBaseProps {
|
||||||
|
onExtractClick: MouseEventHandler<SVGElement>;
|
||||||
|
pipElement: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
|
||||||
|
{/* the pip element returns the icon button */}
|
||||||
|
{pipElement}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
|
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray-500;
|
color: $gray-600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
min-width: min(680px, 90vw);
|
min-width: min(880px, 90vw);
|
||||||
min-height: min(200px, 10vh);
|
min-height: min(200px, 10vh);
|
||||||
max-width: min(900px, 90vw);
|
max-width: min(1200px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
|
|||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../externals';
|
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||||
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
|
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
|
|||||||
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||||
const id = useClientStore((store) => store.id);
|
const id = useClientStore((store) => store.id);
|
||||||
const name = useClientStore((store) => store.name);
|
const name = useClientStore((store) => store.name);
|
||||||
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
@@ -56,10 +58,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.body}>
|
<div className={style.body}>
|
||||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
{supportsFullscreen && (
|
||||||
Toggle Fullscreen
|
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
Toggle Fullscreen
|
||||||
</NavigationMenuItem>
|
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
)}
|
||||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||||
Flip Screen
|
Flip Screen
|
||||||
<IoSwapVertical />
|
<IoSwapVertical />
|
||||||
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
|
|
||||||
<EditorNavigation />
|
<EditorNavigation />
|
||||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
<ClientLink
|
||||||
|
to='cuesheet'
|
||||||
|
current={location.pathname === '/cuesheet'}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
|
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
<ClientLink
|
||||||
|
key={route.url}
|
||||||
|
to={route.url}
|
||||||
|
current={location.pathname === `/${route.url}`}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
{route.label}
|
{route.label}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
|
|||||||
interface ClientLinkProps {
|
interface ClientLinkProps {
|
||||||
current: boolean;
|
current: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
|
postAction?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
|
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||||
const { isElectron } = useElectronEvent();
|
const { isElectron } = useElectronEvent();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
|
<NavigationMenuItem
|
||||||
|
active={current}
|
||||||
|
onClick={() => {
|
||||||
|
handleLinks(to);
|
||||||
|
postAction?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
|
<NavigationMenuItem
|
||||||
|
active={current}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/${to}`);
|
||||||
|
postAction?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import { IoApps } from 'react-icons/io5';
|
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
||||||
import { IoSettingsOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|||||||
@@ -32,11 +32,11 @@
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
width: 0.75em;
|
width: 0.75em;
|
||||||
height: 0.75em;
|
height: 0.75em;
|
||||||
background: var(--user-bg);
|
background: var(--user-bg, $gray-900);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
width: 40rem;
|
width: 40rem;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
import { OntimeView } from 'ontime-types';
|
import { OntimeView } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const [_, setSearchParams] = useSearchParams();
|
const [_, setSearchParams] = useSearchParams();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
const { isOpen, close } = useViewParamsEditorStore();
|
const { isOpen, close } = useViewParamsEditorStore();
|
||||||
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
|||||||
options.push({
|
options.push({
|
||||||
value: key,
|
value: key,
|
||||||
label: value.label,
|
label: value.label,
|
||||||
colour: value.colour || 'transparent',
|
colour: value.colour,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ProjectFileListResponse } from 'ontime-types';
|
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_LIST } from '../api/constants';
|
import { PROJECT_LIST } from '../api/constants';
|
||||||
@@ -24,22 +24,33 @@ function useProjectList() {
|
|||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrderedProjectList() {
|
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
||||||
|
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
|
||||||
|
const sortComparators: Record<ProjectSortMode, SortComparator> = {
|
||||||
|
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
|
||||||
|
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
|
||||||
|
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
|
||||||
|
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
|
||||||
const response = useProjectList();
|
const response = useProjectList();
|
||||||
const { files, lastLoadedProject } = response.data;
|
const { files, lastLoadedProject } = response.data;
|
||||||
|
|
||||||
const reorderedProjectFiles = useMemo(() => {
|
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
|
||||||
if (!files.length) return [];
|
if (!files.length) return [];
|
||||||
|
|
||||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
const sorted = [...files].sort(sortComparators[sort]);
|
||||||
|
|
||||||
if (currentlyLoadedIndex === -1) return files;
|
// keep loaded always on top
|
||||||
|
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
|
||||||
|
if (currentlyLoadedIndex > 0) {
|
||||||
|
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
|
||||||
|
sorted.unshift(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
const projectFiles = [...files];
|
return sorted;
|
||||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
}, [files, lastLoadedProject, sort]);
|
||||||
|
|
||||||
return [current, ...projectFiles];
|
|
||||||
}, [files, lastLoadedProject]);
|
|
||||||
|
|
||||||
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
|
|||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||||
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
|
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project rundowns
|
* Project rundowns
|
||||||
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
|
|||||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: duplicate } = useMutation({
|
||||||
|
mutationFn: duplicateRundown,
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: rename } = useMutation({
|
||||||
|
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
|
||||||
|
onMutate: () => {
|
||||||
|
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { mutateAsync: remove } = useMutation({
|
const { mutateAsync: remove } = useMutation({
|
||||||
mutationFn: deleteRundown,
|
mutationFn: deleteRundown,
|
||||||
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { create, remove, load };
|
return { create, duplicate, remove, load, rename };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function useFlatRundown() {
|
|||||||
// update data whenever the revision changes
|
// update data whenever the revision changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.revision !== -1 || data.revision !== prevRevision) {
|
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||||
const flatRundown = data.order.map((id) => data.entries[id]);
|
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
||||||
setFlatRundown(flatRundown);
|
setFlatRundown(flatRundown);
|
||||||
setPrevRevision(data.revision);
|
setPrevRevision(data.revision);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { throttle } from '../utils/throttle';
|
||||||
|
|
||||||
|
export interface UseLongPressOptions {
|
||||||
|
/** Time in milliseconds to trigger the long press, default is 400ms */
|
||||||
|
threshold?: number;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press starts */
|
||||||
|
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press finishes */
|
||||||
|
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
|
||||||
|
/** Callback triggered when the long press is canceled */
|
||||||
|
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLongPressReturnValue {
|
||||||
|
onMouseDown: (event: React.MouseEvent) => void;
|
||||||
|
onMouseUp: (event: React.MouseEvent) => void;
|
||||||
|
onMouseLeave: (event: React.MouseEvent) => void;
|
||||||
|
onTouchStart: (event: React.TouchEvent) => void;
|
||||||
|
onTouchEnd: (event: React.TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLongPress(
|
||||||
|
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
|
||||||
|
options: UseLongPressOptions = {},
|
||||||
|
): UseLongPressReturnValue {
|
||||||
|
const { threshold = 700, onStart, onFinish, onCancel } = options;
|
||||||
|
const isLongPressActive = useRef(false);
|
||||||
|
const isPressed = useRef(false);
|
||||||
|
const timeout = useRef<number>(-1);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timeout.current), []);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (typeof onLongPress !== 'function') {
|
||||||
|
return {} as UseLongPressReturnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (event: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if (!isMouseEvent(event) && !isTouchEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStart) {
|
||||||
|
onStart(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPressed.current = true;
|
||||||
|
timeout.current = window.setTimeout(() => {
|
||||||
|
onLongPress(event);
|
||||||
|
isLongPressActive.current = true;
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if (!isMouseEvent(event) && !isTouchEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLongPressActive.current) {
|
||||||
|
onFinish?.(event);
|
||||||
|
} else if (isPressed.current) {
|
||||||
|
onCancel?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLongPressActive.current = false;
|
||||||
|
isPressed.current = false;
|
||||||
|
|
||||||
|
if (timeout.current) {
|
||||||
|
window.clearTimeout(timeout.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMouseDown: start,
|
||||||
|
onMouseUp: cancel,
|
||||||
|
onMouseLeave: cancel,
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
onTouchMove: throttle(cancel, 150),
|
||||||
|
};
|
||||||
|
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
|
||||||
|
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
|
||||||
|
return event.nativeEvent instanceof MouseEvent;
|
||||||
|
}
|
||||||
@@ -202,6 +202,9 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
|
|||||||
plannedStart: state.rundown.plannedStart,
|
plannedStart: state.rundown.plannedStart,
|
||||||
actualStart: state.rundown.actualStart,
|
actualStart: state.rundown.actualStart,
|
||||||
plannedEnd: state.rundown.plannedEnd,
|
plannedEnd: state.rundown.plannedEnd,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
|
||||||
expectedEnd: state.offset.expectedRundownEnd,
|
expectedEnd: state.offset.expectedRundownEnd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
|
|||||||
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
||||||
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle ontime app links', () => {
|
||||||
|
const serverUrl = 'https://app.getontime.no/user-hash';
|
||||||
|
const baseUri = 'user-hash';
|
||||||
|
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
|
||||||
|
expect(destination).toBe('https://app.getontime.no/user-hash/path');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 3,
|
eventIndex: 3,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 4,
|
eventIndex: 4,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 10,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: 'group',
|
groupId: 'group',
|
||||||
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 5,
|
eventIndex: 5,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 17,
|
totalGap: 7,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { formatTime, nowInMillis } from '../time';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { formatDuration, formatTime, nowInMillis } from '../time';
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
it('should return the current time in milliseconds', () => {
|
it('should return the current time in milliseconds', () => {
|
||||||
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
|
|||||||
expect(time).toStrictEqual('-01:00');
|
expect(time).toStrictEqual('-01:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatDuration()', () => {
|
||||||
|
it('formats durations correctly', () => {
|
||||||
|
expect(formatDuration(0)).toBe('0m');
|
||||||
|
expect(formatDuration(-5000)).toBe('0m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
|
||||||
|
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
|
||||||
|
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
|
||||||
|
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
|
||||||
|
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
|
||||||
|
expect(formatDuration(599702, false)).toBe('9m59s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on on invalid route', () => {
|
it('throws on invalid route', () => {
|
||||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { enDash } from './styleUtils';
|
import { enDash } from './styleUtils';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
|
|||||||
let offsetText = '';
|
let offsetText = '';
|
||||||
if (offset < 0) offsetText += '-';
|
if (offset < 0) offsetText += '-';
|
||||||
if (offset > 0) offsetText += '+';
|
if (offset > 0) offsetText += '+';
|
||||||
offsetText += millisToString(Math.abs(offset));
|
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
|
||||||
return offsetText;
|
return offsetText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ function processEntry(
|
|||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||||
processedData.totalGap += entry.gap;
|
|
||||||
|
|
||||||
if (!processedData.isPast && !processedData.isLoaded) {
|
if (!processedData.isPast && !processedData.isLoaded) {
|
||||||
/**
|
/**
|
||||||
* isLinkToLoaded is a chain value that we maintain until we
|
* isLinkToLoaded is a chain value that we maintain until we
|
||||||
* a) find an unlinked event
|
* a) find an unlinked event
|
||||||
* b) find a countToEnd event
|
* b) find a countToEnd event
|
||||||
*/
|
*/
|
||||||
|
processedData.totalGap += entry.gap;
|
||||||
processedData.isLinkedToLoaded =
|
processedData.isLinkedToLoaded =
|
||||||
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,11 +112,12 @@ export const formatTime = (
|
|||||||
export function formatDuration(duration: number, hideSeconds = true): string {
|
export function formatDuration(duration: number, hideSeconds = true): string {
|
||||||
// durations should never be negative, we handle it here to flag if there is an issue in future
|
// durations should never be negative, we handle it here to flag if there is an issue in future
|
||||||
if (duration <= 0) {
|
if (duration <= 0) {
|
||||||
return '0h 0m';
|
return '0m';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
||||||
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
||||||
|
|
||||||
let result = '';
|
let result = '';
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
result += `${hours}h`;
|
result += `${hours}h`;
|
||||||
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hideSeconds) {
|
if (!hideSeconds) {
|
||||||
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
const remainingMs = duration % MILLIS_PER_MINUTE;
|
||||||
|
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
|
||||||
|
// cap at 59 to avoid showing 60s
|
||||||
|
const seconds = Math.min(59, Math.ceil(exactSeconds));
|
||||||
|
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
result += `${seconds}s`;
|
result += `${seconds}s`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ declare global {
|
|||||||
process: {
|
process: {
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
// Experimental browser feature
|
||||||
|
documentPictureInPicture: {
|
||||||
|
requestWindow: () => Promise<Window>;
|
||||||
|
window: Window;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
|||||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
|
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
@@ -22,7 +23,10 @@ export const isProduction = import.meta.env.PROD;
|
|||||||
export const isDev = import.meta.env.DEV;
|
export const isDev = import.meta.env.DEV;
|
||||||
export const currentHostName = window.location.hostname;
|
export const currentHostName = window.location.hostname;
|
||||||
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||||
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
|
||||||
|
|
||||||
|
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
export const supportsFullscreen = document.fullscreenEnabled;
|
||||||
|
|
||||||
// resolve entrypoint URLs
|
// resolve entrypoint URLs
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
documentationUrl,
|
documentationUrl,
|
||||||
githubSponsorUrl,
|
githubSponsorUrl,
|
||||||
githubUrl,
|
githubUrl,
|
||||||
|
subredditUrl,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
} from '../../../../externals';
|
} from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
@@ -38,6 +39,7 @@ export default function AboutPanel() {
|
|||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
|
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function AppVersion() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`You are currently using Ontime version ${appVersion}`}
|
{`You are currently using Ontime version ${appVersion}`}
|
||||||
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
<Panel.Error>Could not fetch version information</Panel.Error>
|
||||||
</Panel.Paragraph>
|
</Panel.Paragraph>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
|
|||||||
New time
|
New time
|
||||||
<Input
|
<Input
|
||||||
{...register(`outputs.${index}.time`, {
|
{...register(`outputs.${index}.time`, {
|
||||||
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
|
required: { value: true, message: 'Required field' },
|
||||||
})}
|
})}
|
||||||
fluid
|
fluid
|
||||||
placeholder='eg: 10m5s'
|
placeholder='eg: 10m5s'
|
||||||
|
|||||||
+16
-2
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
|||||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm<URLPreset>({
|
} = useForm<URLPreset>({
|
||||||
defaultValues: urlPreset ?? defaultValues,
|
defaultValues: urlPreset ?? defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Alias</Panel.Description>
|
<Panel.Description>Alias</Panel.Description>
|
||||||
<Input {...register('alias', { required: 'Alias is required' })} />
|
<Input
|
||||||
|
{...register('alias', {
|
||||||
|
required: 'Alias is required',
|
||||||
|
pattern: {
|
||||||
|
value: isUrlSafe,
|
||||||
|
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.expand}>
|
<div className={style.expand}>
|
||||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<div> - or -</div>
|
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||||
|
<div>
|
||||||
|
{enDash} or {enDash}
|
||||||
|
</div>
|
||||||
<div>2. Choose a view and its parameters</div>
|
<div>2. Choose a view and its parameters</div>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Target</Panel.Description>
|
<Panel.Description>Target</Panel.Description>
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current {
|
.current {
|
||||||
background-color: $blue-1100;
|
background-color: $blue-1100 !important; // fighting zebra styles
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import {
|
||||||
|
IoAdd,
|
||||||
|
IoDocumentOutline,
|
||||||
|
IoDownloadOutline,
|
||||||
|
IoDuplicateOutline,
|
||||||
|
IoEllipsisHorizontal,
|
||||||
|
IoPencilOutline,
|
||||||
|
IoTrash,
|
||||||
|
} from 'react-icons/io5';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import RundownRenameForm from './composite/RundownRenameForm';
|
||||||
import { ManageRundownForm } from './ManageRundownForm';
|
import { ManageRundownForm } from './ManageRundownForm';
|
||||||
|
|
||||||
import style from './ManagePanel.module.scss';
|
import style from './ManagePanel.module.scss';
|
||||||
|
|
||||||
export default function ManageRundowns() {
|
export default function ManageRundowns() {
|
||||||
const { data } = useProjectRundowns();
|
const { data } = useProjectRundowns();
|
||||||
const { remove, load } = useMutateProjectRundowns();
|
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||||
const [isOpenLoad, loadHandlers] = useDisclosure();
|
const [isOpenLoad, loadHandlers] = useDisclosure();
|
||||||
const [isNewLoad, newHandlers] = useDisclosure();
|
const [isNewLoad, newHandlers] = useDisclosure();
|
||||||
const [targetRundown, setTargetRundown] = useState('');
|
const [targetRundown, setTargetRundown] = useState('');
|
||||||
|
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const openLoad = (id: string) => {
|
const openLoad = (id: string) => {
|
||||||
@@ -35,6 +48,11 @@ export default function ManageRundowns() {
|
|||||||
deleteHandlers.open();
|
deleteHandlers.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRename = (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setRenamingRundown(id);
|
||||||
|
};
|
||||||
|
|
||||||
const submitRundownLoad = async () => {
|
const submitRundownLoad = async () => {
|
||||||
try {
|
try {
|
||||||
await load(targetRundown);
|
await load(targetRundown);
|
||||||
@@ -45,6 +63,27 @@ export default function ManageRundowns() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitRundownDuplicate = async (id: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
setRenamingRundown(null);
|
||||||
|
setTargetRundown('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await duplicate(id);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRundownRename = async (id: string, newTitle: string) => {
|
||||||
|
try {
|
||||||
|
await rename([id, newTitle]);
|
||||||
|
setRenamingRundown(null);
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitRundownDelete = async () => {
|
const submitRundownDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await remove(targetRundown);
|
await remove(targetRundown);
|
||||||
@@ -55,6 +94,10 @@ export default function ManageRundowns() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadXlsx = async (rundownId: string, title: string) => {
|
||||||
|
await downloadAsExcel(rundownId, title);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -87,25 +130,70 @@ export default function ManageRundowns() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||||
const isLoaded = data.loaded === id;
|
const isLoaded = data.loaded === id;
|
||||||
|
const isRenaming = renamingRundown === id;
|
||||||
|
|
||||||
|
if (isRenaming) {
|
||||||
|
return (
|
||||||
|
<tr key={id}>
|
||||||
|
<td colSpan={3}>
|
||||||
|
<RundownRenameForm
|
||||||
|
onCancel={() => setRenamingRundown(null)}
|
||||||
|
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
|
||||||
|
initialTitle={title}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||||
<td>{numEntries}</td>
|
<td>{numEntries}</td>
|
||||||
<td>
|
<td>
|
||||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements as='td'>
|
<td>
|
||||||
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
|
<DropdownMenu
|
||||||
Load
|
render={<IconButton variant='ghosted-white' />}
|
||||||
</Button>
|
items={[
|
||||||
<Button
|
{
|
||||||
size='small'
|
type: 'item',
|
||||||
variant='subtle-destructive'
|
icon: IoPencilOutline,
|
||||||
onClick={() => openDelete(id)}
|
label: 'Rename',
|
||||||
disabled={isLoaded}
|
onClick: () => openRename(id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDownloadOutline,
|
||||||
|
label: 'Load',
|
||||||
|
onClick: () => openLoad(id),
|
||||||
|
disabled: isLoaded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDocumentOutline,
|
||||||
|
label: 'Download .xlsx',
|
||||||
|
onClick: () => handleDownloadXlsx(id, title),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
icon: IoDuplicateOutline,
|
||||||
|
label: 'Duplicate',
|
||||||
|
onClick: () => submitRundownDuplicate(id),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'destructive',
|
||||||
|
icon: IoTrash,
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: () => openDelete(id),
|
||||||
|
disabled: isLoaded,
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
Delete
|
<IoEllipsisHorizontal />
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
</Panel.InlineElements>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -117,7 +205,7 @@ export default function ManageRundowns() {
|
|||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isOpenDelete}
|
isOpen={isOpenDelete}
|
||||||
onClose={deleteHandlers.close}
|
onClose={deleteHandlers.close}
|
||||||
title='Load rundown'
|
title='Delete rundown'
|
||||||
showBackdrop
|
showBackdrop
|
||||||
showCloseButton
|
showCloseButton
|
||||||
bodyElements={
|
bodyElements={
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { checkRegex } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
|
import Input from '../../../../../common/components/input/input/Input';
|
||||||
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
interface RundownRenameFormProps {
|
||||||
|
onSubmit: (newTitle: string) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
setFocus,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
defaultValues: { title: initialTitle },
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSubmit = async (values: FormData) => {
|
||||||
|
try {
|
||||||
|
await onSubmit(values.title);
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { type: 'custom', message: maybeAxiosError(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Give initial focus to the title input
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus('title');
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
|
const canSubmit = isDirty && isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||||
|
<label>
|
||||||
|
<Panel.Description>Rundown title</Panel.Description>
|
||||||
|
<Input
|
||||||
|
{...register('title', {
|
||||||
|
required: { value: true, message: 'Title is required' },
|
||||||
|
validate: (value) => {
|
||||||
|
if (value.trim().length === 0) return 'Title cannot be empty';
|
||||||
|
if (checkRegex.isAlphanumericWithSpace(value) === false)
|
||||||
|
return 'Title can only contain alphanumeric characters, spaces and underscores';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
|
||||||
|
</label>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
|
<Button variant='ghosted' onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.Indent>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-3
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CustomFields, Rundown } from 'ontime-types';
|
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||||
|
import { formatDuration } from '../../../../../common/utils/time';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||||
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
|
|||||||
interface ImportReviewProps {
|
interface ImportReviewProps {
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
summary: RundownSummary;
|
||||||
onFinished: () => void;
|
onFinished: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportReview(props: ImportReviewProps) {
|
export default function ImportReview({
|
||||||
const { rundown, customFields, onFinished, onCancel } = props;
|
rundown,
|
||||||
|
customFields,
|
||||||
|
summary,
|
||||||
|
onFinished,
|
||||||
|
onCancel,
|
||||||
|
onBack,
|
||||||
|
}: ImportReviewProps) {
|
||||||
const { data: currentRundown } = useRundown();
|
const { data: currentRundown } = useRundown();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { importRundown } = useGoogleSheet();
|
const { importRundown } = useGoogleSheet();
|
||||||
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
|
|||||||
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={onBack} variant='subtle' disabled={loading}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
<Button onClick={applyImport} variant='primary' loading={loading}>
|
<Button onClick={applyImport} variant='primary' loading={loading}>
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Title</b> {rundown.title}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Number of entries</b> {rundown.flatOrder.length}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Start time</b> {millisToString(summary.start)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>End time</b> {millisToString(summary.end)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<b>Total duration</b> {formatDuration(summary.duration)}
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
|
|||||||
+9
-4
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
|||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorksheetNames as getWorksheetNamesExcel,
|
|
||||||
importRundownPreview as importRundownPreviewExcel,
|
importRundownPreview as importRundownPreviewExcel,
|
||||||
upload as uploadExcel,
|
upload as uploadExcel,
|
||||||
} from '../../../../../common/api/excel';
|
} from '../../../../../common/api/excel';
|
||||||
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
|
|||||||
const setRundown = useSheetStore((state) => state.setRundown);
|
const setRundown = useSheetStore((state) => state.setRundown);
|
||||||
const customFields = useSheetStore((state) => state.customFields);
|
const customFields = useSheetStore((state) => state.customFields);
|
||||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||||
|
const summary = useSheetStore((state) => state.summary);
|
||||||
|
const setSummary = useSheetStore((state) => state.setSummary);
|
||||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||||
const sheetId = useSheetStore((state) => state.sheetId);
|
const sheetId = useSheetStore((state) => state.sheetId);
|
||||||
|
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
|
|||||||
try {
|
try {
|
||||||
setHasFile('loading');
|
setHasFile('loading');
|
||||||
validateExcelImport(fileToUpload);
|
validateExcelImport(fileToUpload);
|
||||||
await uploadExcel(fileToUpload);
|
const names = await uploadExcel(fileToUpload);
|
||||||
const names = await getWorksheetNamesExcel();
|
|
||||||
setWorksheets(names);
|
setWorksheets(names);
|
||||||
setImportFlow('excel');
|
setImportFlow('excel');
|
||||||
setHasFile('done');
|
setHasFile('done');
|
||||||
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
|
|||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setWorksheets(null);
|
setWorksheets(null);
|
||||||
setCustomFields(null);
|
setCustomFields(null);
|
||||||
|
setSummary(null);
|
||||||
setError('');
|
setError('');
|
||||||
setSheetId(null);
|
setSheetId(null);
|
||||||
};
|
};
|
||||||
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
|
|||||||
const previewData = await importRundownPreviewExcel(importMap);
|
const previewData = await importRundownPreviewExcel(importMap);
|
||||||
setRundown(previewData.rundown);
|
setRundown(previewData.rundown);
|
||||||
setCustomFields(previewData.customFields);
|
setCustomFields(previewData.customFields);
|
||||||
|
setSummary(previewData.summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(maybeAxiosError(error));
|
setError(maybeAxiosError(error));
|
||||||
}
|
}
|
||||||
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
|
|||||||
const showCompleted = importFlow === 'finished';
|
const showCompleted = importFlow === 'finished';
|
||||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||||
const showReview = rundown !== null && customFields !== null;
|
const showReview = rundown !== null && customFields !== null && summary !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
|
|||||||
<ImportReview
|
<ImportReview
|
||||||
rundown={rundown}
|
rundown={rundown}
|
||||||
customFields={customFields}
|
customFields={customFields}
|
||||||
|
summary={summary}
|
||||||
onFinished={handleFinished}
|
onFinished={handleFinished}
|
||||||
onCancel={cancelImportMap}
|
onCancel={cancelImportMap}
|
||||||
|
onBack={resetPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
|
|||||||
+5
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
|
|||||||
|
|
||||||
import Button from '../../../../../../common/components/buttons/Button';
|
import Button from '../../../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||||
|
import Info from '../../../../../../common/components/info/Info';
|
||||||
import Input from '../../../../../../common/components/input/input/Input';
|
import Input from '../../../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../../../common/components/select/Select';
|
import Select from '../../../../../../common/components/select/Select';
|
||||||
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
|
||||||
@@ -136,6 +137,10 @@ export default function ImportMapForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
|
<Info>
|
||||||
|
Match your spreadsheet columns to Ontime fields. <br />
|
||||||
|
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
|
||||||
|
</Info>
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
timeWarning: namedImportMap['Time warning'],
|
timeWarning: namedImportMap['Time warning'],
|
||||||
timeDanger: namedImportMap['Time danger'],
|
timeDanger: namedImportMap['Time danger'],
|
||||||
custom,
|
custom,
|
||||||
entryId: namedImportMap.ID,
|
id: namedImportMap.ID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
|
|||||||
return value ? 'Yes' : undefined;
|
return value ? 'Yes' : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PreviewRundown(props: PreviewRundownProps) {
|
export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
|
||||||
const { rundown, customFields } = props;
|
|
||||||
|
|
||||||
// we only count Ontime Events which are 1 based in client
|
// we only count Ontime Events which are 1 based in client
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
|
|
||||||
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td className={style.center}>
|
<td /> {/** Index */}
|
||||||
<Tag>-</Tag>
|
|
||||||
</td>
|
|
||||||
<td className={style.center}>
|
<td className={style.center}>
|
||||||
<Tag>{entry.type}</Tag>
|
<Tag>{entry.type}</Tag>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+2
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
|
|||||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||||
const setRundown = useSheetStore((state) => state.setRundown);
|
const setRundown = useSheetStore((state) => state.setRundown);
|
||||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||||
|
const setSummary = useSheetStore((state) => state.setSummary);
|
||||||
|
|
||||||
/** whether the current session has been authenticated */
|
/** whether the current session has been authenticated */
|
||||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
||||||
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
|
|||||||
const data = await previewRundown(sheetId, fileOptions);
|
const data = await previewRundown(sheetId, fileOptions);
|
||||||
setRundown(data.rundown);
|
setRundown(data.rundown);
|
||||||
setCustomFields(data.customFields);
|
setCustomFields(data.customFields);
|
||||||
|
setSummary(data.summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -1,4 +1,4 @@
|
|||||||
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
|
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||||
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ type SheetStore = {
|
|||||||
// we get this from a preview response
|
// we get this from a preview response
|
||||||
rundown: Rundown | null;
|
rundown: Rundown | null;
|
||||||
setRundown: (rundown: Rundown | null) => void;
|
setRundown: (rundown: Rundown | null) => void;
|
||||||
|
|
||||||
// we get this from a preview response
|
|
||||||
customFields: CustomFields | null;
|
customFields: CustomFields | null;
|
||||||
setCustomFields: (customFields: CustomFields | null) => void;
|
setCustomFields: (customFields: CustomFields | null) => void;
|
||||||
|
summary: RundownSummary | null;
|
||||||
|
setSummary: (metadata: RundownSummary | null) => void;
|
||||||
|
|
||||||
spreadsheetImportMap: ImportMap;
|
spreadsheetImportMap: ImportMap;
|
||||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
||||||
@@ -43,6 +43,7 @@ const initialState = {
|
|||||||
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||||
rundown: null,
|
rundown: null,
|
||||||
customFields: null,
|
customFields: null,
|
||||||
|
summary: null,
|
||||||
spreadsheetImportMap: defaultImportMap,
|
spreadsheetImportMap: defaultImportMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
|||||||
|
|
||||||
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
||||||
|
|
||||||
|
setSummary: (summary: RundownSummary | null) => set({ summary }),
|
||||||
|
|
||||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
||||||
const currentImportMap = get().spreadsheetImportMap;
|
const currentImportMap = get().spreadsheetImportMap;
|
||||||
if (currentImportMap[field] !== value) {
|
if (currentImportMap[field] !== value) {
|
||||||
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
resetPreview: () => set({ rundown: null, customFields: null }),
|
resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||||
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { ProjectSortMode, useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ProjectList() {
|
type SortParameter = 'alphabetical' | 'modified';
|
||||||
const { data, refetch, status } = useOrderedProjectList();
|
|
||||||
|
|
||||||
|
export default function ProjectList() {
|
||||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
|
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
||||||
|
|
||||||
|
const { data, refetch, status } = useOrderedProjectList(sortMode);
|
||||||
|
|
||||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||||
@@ -28,6 +32,13 @@ export default function ProjectList() {
|
|||||||
await refetch();
|
await refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (sortParameter: SortParameter) => {
|
||||||
|
setSortMode((current) => {
|
||||||
|
const isAscending = current === `${sortParameter}-asc`;
|
||||||
|
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className={style.empty}>
|
<div className={style.empty}>
|
||||||
@@ -48,8 +59,18 @@ export default function ProjectList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.containCell}>File Name</th>
|
<th className={style.containCell} onClick={() => handleSort('alphabetical')}>
|
||||||
<th>Last Used</th>
|
<span className={style.sortableHeader}>
|
||||||
|
File Name
|
||||||
|
<SortIcon sortMode={sortMode} type='alphabetical' />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th onClick={() => handleSort('modified')}>
|
||||||
|
<span className={style.sortableHeader}>
|
||||||
|
Last Used
|
||||||
|
<SortIcon sortMode={sortMode} type='modified' />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -72,3 +93,10 @@ export default function ProjectList() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortIcon({ sortMode, type }: { sortMode: ProjectSortMode; type: SortParameter }) {
|
||||||
|
const prefix = `${type}-`;
|
||||||
|
if (sortMode === `${prefix}asc`) return <IoArrowDown />;
|
||||||
|
if (sortMode === `${prefix}desc`) return <IoArrowUp />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.containCell {
|
.containCell {
|
||||||
max-width: 400px;
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortableHeader {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
|
|||||||
import { postSettings } from '../../../../common/api/settings';
|
import { postSettings } from '../../../../common/api/settings';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||||
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
|
|||||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<Panel.Loader isLoading={isLoading} />
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Time format'
|
title='Time format'
|
||||||
description='Default time format to show in views 12 /24 hours'
|
description='Default time format to show in views 12 / 24 hours'
|
||||||
error={errors.timeFormat?.message}
|
error={errors.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Time format'
|
title='Time format'
|
||||||
description='Default time format to show in views 12 /24 hours'
|
description='Default time format to show in views 12 / 24 hours (does not affect editor)'
|
||||||
error={errors.settings?.timeFormat?.message}
|
error={errors.settings?.timeFormat?.message}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { getIsNavigationLocked } from '../../../externals';
|
||||||
|
|
||||||
import MessageControl from './MessageControl';
|
import MessageControl from './MessageControl';
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ function MessageControlExport() {
|
|||||||
return (
|
return (
|
||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<div className={style.messages} data-testid='panel-messages-control'>
|
<div className={style.messages} data-testid='panel-messages-control'>
|
||||||
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
|
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
|
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
|||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
|
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||||
|
|
||||||
import style from './MessageControl.module.scss';
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export default function TimerPreview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.preview}>
|
<div className={style.preview}>
|
||||||
<Corner onClick={(event) => handleLinks('timer', event)} />
|
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||||
<div className={contentClasses}>
|
<div className={contentClasses}>
|
||||||
<div
|
<div
|
||||||
className={style.mainContent}
|
className={style.mainContent}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Corner } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
|
import { getIsNavigationLocked } from '../../../externals';
|
||||||
|
|
||||||
import PlaybackControl from './PlaybackControl';
|
import PlaybackControl from './PlaybackControl';
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ function TimerControlExport() {
|
|||||||
return (
|
return (
|
||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<div className={style.playback} data-testid='panel-timer-control'>
|
<div className={style.playback} data-testid='panel-timer-control'>
|
||||||
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
|
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
|
|
||||||
<div className={style.content}>
|
<div className={style.content}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@@ -70,6 +69,7 @@
|
|||||||
.time {
|
.time {
|
||||||
color: $section-white;
|
color: $section-white;
|
||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rolltag {
|
.rolltag {
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
|
|||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
|
||||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
|
||||||
import useSettings from '../../common/hooks-query/useSettings';
|
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../../common/utils/throttle';
|
||||||
import { getDefaultFormat } from '../../common/utils/time';
|
import { getDefaultFormat } from '../../common/utils/time';
|
||||||
|
import { isTouchDevice } from '../../externals';
|
||||||
|
import Loader from '../../views/common/loader/Loader';
|
||||||
|
|
||||||
import EditModal from './edit-modal/EditModal';
|
import EditModal from './edit-modal/EditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
@@ -22,21 +20,31 @@ import StatusBar from './status-bar/StatusBar';
|
|||||||
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
||||||
import type { EditEvent } from './operator.types';
|
import type { EditEvent } from './operator.types';
|
||||||
import { getEventData } from './operator.utils';
|
import { getEventData } from './operator.utils';
|
||||||
|
import { OperatorData, useOperatorData } from './useOperatorData';
|
||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
const selectedOffset = 50;
|
const selectedOffset = 50;
|
||||||
|
|
||||||
export default function Operator() {
|
export default function OperatorLoader() {
|
||||||
const { data, rundownMetadata, status } = useRundownWithMetadata();
|
const { data, status } = useOperatorData();
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
|
||||||
|
|
||||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
useWindowTitle('Operator');
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Operator {...data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
||||||
const { selectedEventId } = useSelectedEventId();
|
const { selectedEventId } = useSelectedEventId();
|
||||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||||
const { data: settings } = useSettings();
|
|
||||||
|
|
||||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||||
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
||||||
@@ -52,7 +60,7 @@ export default function Operator() {
|
|||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useWindowTitle('Operator');
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// reset scroll if nothing is selected
|
// reset scroll if nothing is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,17 +109,10 @@ export default function Operator() {
|
|||||||
setEditEvent({ ...event });
|
setEditEvent({ ...event });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const missingData = !data || !customFields || !projectData;
|
|
||||||
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
|
|
||||||
|
|
||||||
// gather option data
|
// gather option data
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
||||||
|
|
||||||
if (missingData || isLoading) {
|
|
||||||
return <EmptyPage text='Loading...' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canEdit = shouldEdit && subscribe.length;
|
const canEdit = shouldEdit && subscribe.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,12 +123,14 @@ export default function Operator() {
|
|||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>Press and hold to edit user field</div>
|
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>
|
||||||
|
{isTouchDevice ? 'Press and hold to edit user field' : 'Right click to edit user field'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{data.order.map((entryId) => {
|
{rundown.order.map((entryId) => {
|
||||||
const entry = data.entries[entryId];
|
const entry = rundown.entries[entryId];
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||||
// hide past events (if setting) and skipped events
|
// hide past events (if setting) and skipped events
|
||||||
@@ -168,16 +171,24 @@ export default function Operator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
|
const { isPast } = rundownMetadata[entry.id];
|
||||||
|
|
||||||
|
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
|
||||||
|
|
||||||
|
if (hidePast && isPast && !isCurrentParent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<OperatorGroup key={entry.id} title={entry.title} />
|
<OperatorGroup key={entry.id} title={entry.title} />
|
||||||
{entry.entries.map((nestedEntryId) => {
|
{entry.entries.map((nestedEntryId) => {
|
||||||
const nestedEntry = data.entries[nestedEntryId];
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
if (!isOntimeEvent(nestedEntry)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId];
|
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||||
|
|
||||||
// hide past events (if setting) and skipped events
|
// hide past events (if setting) and skipped events
|
||||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
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 { getIsNavigationLocked } from '../../externals';
|
|
||||||
|
|
||||||
import Operator from './Operator';
|
import Operator from './Operator';
|
||||||
|
|
||||||
export default function OperatorExport() {
|
export default function OperatorExport() {
|
||||||
return (
|
return (
|
||||||
<ProtectRoute permission='operator'>
|
<ProtectRoute permission='operator'>
|
||||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
|
||||||
<Operator />
|
<Operator />
|
||||||
</ProtectRoute>
|
</ProtectRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
||||||
max-height: min(80vh, 600px);
|
max-height: min(60vh, 600px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
background-color: var(--user-bg);
|
background-color: var(--user-bg, $gray-900);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
column-gap: 0.5rem;
|
column-gap: 0.5rem;
|
||||||
row-gap: 0.5rem;
|
row-gap: 0.5rem;
|
||||||
grid-template-rows: auto auto auto;
|
grid-template-rows: auto auto auto;
|
||||||
grid-template-columns: 1.25rem 1fr auto;
|
grid-template-columns: 1.5rem 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'binder main schedule'
|
'binder main schedule'
|
||||||
'binder secondary running'
|
'binder secondary running'
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
place-content: center;
|
place-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: $gray-1050; // to override inline
|
background-color: $gray-1050; // to override inline
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
.cue {
|
.cue {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -71,47 +72,41 @@
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plannedStart, .timeUntil {
|
.plannedStart,
|
||||||
line-height: 1em;
|
.timeUntil,
|
||||||
background-color: $gray-1000;
|
.runningTime {
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 1px;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plannedStart {
|
.plannedStart {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
display: inline;
|
||||||
|
background-color: $white-20;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeUntil {
|
.timeUntil {
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
line-height: 1em;
|
|
||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
background-color: $white-20;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runningTime {
|
.runningTime {
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1em;
|
|
||||||
grid-area: running;
|
grid-area: running;
|
||||||
|
font-size: 1.25rem;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
align-self: flex-start;
|
background-color: $white-7;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
grid-area: fields;
|
grid-area: fields;
|
||||||
font-size: var(--operator-customfield-font-size-override, 1.25rem);
|
margin-top: 0;
|
||||||
font-weight: 400;
|
border-top: 0;
|
||||||
color: $ui-black;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5em;
|
|
||||||
row-gap: 0.25em;
|
|
||||||
line-height: 1.25em;
|
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -133,8 +128,18 @@
|
|||||||
// allow multi-line text but trim before
|
// allow multi-line text but trim before
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.fields::after {
|
&.fieldsWithContent {
|
||||||
content: '\200b';
|
margin-top: 0.25rem;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
border-top: 1px solid $white-13;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: 0.25rem 0.75rem;
|
||||||
|
line-height: 1.25em;
|
||||||
|
font-size: var(--operator-customfield-font-size-override, 1.25rem);
|
||||||
|
font-weight: 400;
|
||||||
|
color: $ui-black;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { memo, RefObject, SyntheticEvent } from 'react';
|
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
||||||
import { useLongPress } from '@mantine/hooks';
|
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
|
||||||
|
|
||||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import { useLongPress } from '../../../common/hooks/useLongPress';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time';
|
import { formatDuration, formatTime, useTimeUntilExpectedStart } from '../../../common/utils/time';
|
||||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||||
|
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
|
||||||
import type { EditEvent, Subscribed } from '../operator.types';
|
import type { EditEvent, Subscribed } from '../operator.types';
|
||||||
|
|
||||||
import style from './OperatorEvent.module.scss';
|
import style from './OperatorEvent.module.scss';
|
||||||
@@ -54,8 +55,10 @@ function OperatorEvent({
|
|||||||
* gather behaviour for long press and context menu
|
* gather behaviour for long press and context menu
|
||||||
*/
|
*/
|
||||||
const handleLongPress = (event?: SyntheticEvent) => {
|
const handleLongPress = (event?: SyntheticEvent) => {
|
||||||
// we dont have an event out of useLongPress
|
// prevent default if the event is cancelable to avoid browser intervention warnings
|
||||||
event?.preventDefault();
|
if (event && event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
onLongPress({ id, cue, subscriptions: subscribed });
|
onLongPress({ id, cue, subscriptions: subscribed });
|
||||||
}
|
}
|
||||||
@@ -64,22 +67,33 @@ function OperatorEvent({
|
|||||||
const mouseHandlers = useLongPress(handleLongPress);
|
const mouseHandlers = useLongPress(handleLongPress);
|
||||||
const cueColours = colour && getAccessibleColour(colour);
|
const cueColours = colour && getAccessibleColour(colour);
|
||||||
|
|
||||||
const operatorClasses = cx([
|
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
|
||||||
style.event,
|
|
||||||
isSelected && style.running,
|
const hasFields = subscribed.some((field) => field.value);
|
||||||
isPast && style.past,
|
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
||||||
]);
|
const fieldGridStyle =
|
||||||
|
columnCount > 0
|
||||||
|
? ({
|
||||||
|
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
|
||||||
|
} satisfies CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
|
<div
|
||||||
|
className={operatorClasses}
|
||||||
|
data-testid={cue}
|
||||||
|
ref={selectedRef}
|
||||||
|
onContextMenu={handleLongPress}
|
||||||
|
{...mouseHandlers}
|
||||||
|
>
|
||||||
<div className={style.binder} style={{ ...cueColours }}>
|
<div className={style.binder} style={{ ...cueColours }}>
|
||||||
<span className={style.cue}>{cue}</span>
|
<span className={style.cue}>{cue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={style.mainField}>
|
<span className={style.mainField}>
|
||||||
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>}
|
{showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
|
||||||
{main}
|
{main}
|
||||||
</span>
|
</span>
|
||||||
<span className={style.secondaryField}>{secondary}</span>
|
<span className={style.secondaryField}>{secondary}</span>
|
||||||
<OperatorEventSchedule
|
<OperatorEventSchedule
|
||||||
timeStart={timeStart}
|
timeStart={timeStart}
|
||||||
@@ -95,22 +109,25 @@ function OperatorEvent({
|
|||||||
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
|
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className={style.fields}>
|
<div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
|
||||||
{subscribed
|
{subscribed.map((field) => {
|
||||||
.filter((field) => field.value)
|
if (!field.value) {
|
||||||
.map((field) => {
|
return <div key={field.id} />;
|
||||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
}
|
||||||
return (
|
return (
|
||||||
<div key={field.id}>
|
<div key={field.id}>
|
||||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
<span
|
||||||
{field.label}
|
className={cx([style.field, !field.colour && style.noColour])}
|
||||||
</span>
|
style={{ backgroundColor: field.colour }}
|
||||||
<span className={style.value} style={{ color: field.colour }}>
|
>
|
||||||
{field.value}
|
{field.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className={style.value} style={{ color: field.colour }}>
|
||||||
);
|
{field.value}
|
||||||
})}
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
|||||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||||
|
|
||||||
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>;
|
return (
|
||||||
|
<span className={style.timeUntil} data-testid='time-until'>
|
||||||
|
{timeUntilString}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
import { getTimeOption } 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';
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
makeCustomFieldSelectOptions,
|
makeCustomFieldSelectOptions,
|
||||||
makeOptionsFromCustomFields,
|
makeOptionsFromCustomFields,
|
||||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import { isStringBoolean } from '../viewers/common/viewUtils';
|
import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||||
|
|
||||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||||
@@ -93,15 +94,26 @@ type OperatorOptions = {
|
|||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
* the names and fallback are manually matched with timerOptions
|
* the names and fallback are manually matched with timerOptions
|
||||||
*/
|
*/
|
||||||
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
|
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): OperatorOptions {
|
||||||
|
// Helper to get value from either source, prioritizing defaultValues
|
||||||
|
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
|
||||||
|
|
||||||
|
// Helper to get array values from either source
|
||||||
|
const getArrayValues = (key: string): EntryId[] => {
|
||||||
|
if (defaultValues?.has(key)) {
|
||||||
|
return defaultValues.getAll(key) as EntryId[];
|
||||||
|
}
|
||||||
|
return searchParams.getAll(key) as EntryId[];
|
||||||
|
};
|
||||||
|
|
||||||
// we manually make an object that matches the key above
|
// we manually make an object that matches the key above
|
||||||
return {
|
return {
|
||||||
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
|
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||||
subscribe: searchParams.getAll('subscribe'),
|
subscribe: getArrayValues('subscribe'),
|
||||||
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
|
shouldEdit: isStringBoolean(getValue('shouldEdit')),
|
||||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
hidePast: isStringBoolean(getValue('hidePast')),
|
||||||
showStart: isStringBoolean(searchParams.get('showStart')),
|
showStart: isStringBoolean(getValue('showStart')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
|
|||||||
*/
|
*/
|
||||||
export function useOperatorOptions(): OperatorOptions {
|
export function useOperatorOptions(): OperatorOptions {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
const maybePreset = use(PresetContext);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
|
||||||
|
return getOptionsFromParams(searchParams, defaultValues);
|
||||||
|
}, [maybePreset, searchParams]);
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,3 +40,10 @@
|
|||||||
height: 1rem;
|
height: 1rem;
|
||||||
--progress-bar-br: 0;
|
--progress-bar-br: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $min-tablet) {
|
||||||
|
.timers {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas: 'timers clock';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function StatusBarTimers() {
|
|||||||
return (
|
return (
|
||||||
<div className={style.timers}>
|
<div className={style.timers}>
|
||||||
<TimerOverview className={style.runningTimer} />
|
<TimerOverview className={style.runningTimer} />
|
||||||
<ClockOverview className={style.timeNow} />
|
<ClockOverview className={style.timeNow} shouldFormat />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { CustomFields, Rundown, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
|
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||||
|
import useSettings from '../../common/hooks-query/useSettings';
|
||||||
|
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||||
|
import { aggregateQueryStatus, ViewData } from '../../views/utils/viewLoader.utils';
|
||||||
|
|
||||||
|
export interface OperatorData {
|
||||||
|
rundown: Rundown;
|
||||||
|
rundownMetadata: RundownMetadataObject;
|
||||||
|
customFields: CustomFields;
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOperatorData(): ViewData<OperatorData> {
|
||||||
|
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
|
||||||
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
rundown,
|
||||||
|
rundownMetadata,
|
||||||
|
customFields,
|
||||||
|
settings,
|
||||||
|
},
|
||||||
|
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<OverviewWrapper navElements={children}>
|
<OverviewWrapper navElements={children}>
|
||||||
<TitleOverview />
|
<TitleOverview />
|
||||||
<StartTimes />
|
<StartTimes shouldFormat />
|
||||||
<TimerOverview />
|
<TimerOverview />
|
||||||
<OffsetOverview />
|
<OffsetOverview />
|
||||||
<MetadataTimes />
|
<MetadataTimes />
|
||||||
<ClockOverview />
|
<ClockOverview shouldFormat />
|
||||||
</OverviewWrapper>
|
</OverviewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3rem 10rem 8rem;
|
grid-template-columns: 3rem 9rem 9rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $label-gray;
|
color: $secondary-text-gray;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -40,18 +41,24 @@
|
|||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.25px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daySpan {
|
.daySpan {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "+"attr(data-day-offset);
|
content: "+" attr(data-day-offset);
|
||||||
vertical-align: super;
|
font-size: 0.75rem;
|
||||||
font-size: 0.6em;
|
font-weight: 400;
|
||||||
letter-spacing: 0;
|
color: $ui-white;
|
||||||
color: $info-blue;
|
background: $gray-900; // same as the tag component
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: $component-border-radius-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,4 +81,7 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: $playback-over;
|
color: $playback-over;
|
||||||
|
border-left: 3px solid $playback-over;
|
||||||
|
padding-left: 0.375rem;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useNextFlag,
|
useNextFlag,
|
||||||
useOffsetOverview,
|
useOffsetOverview,
|
||||||
useProgressOverview,
|
useProgressOverview,
|
||||||
|
useRundownExpectedEnd,
|
||||||
useStartTimesOverview,
|
useStartTimesOverview,
|
||||||
useTimer,
|
useTimer,
|
||||||
} from '../../../common/hooks/useSocket';
|
} from '../../../common/hooks/useSocket';
|
||||||
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
|
|||||||
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
||||||
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils';
|
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
|
||||||
|
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
|
||||||
|
|
||||||
import { OverUnder, TimeColumn } from './TimeLayout';
|
import { OverUnder, TimeColumn, WrappedInTimeColumn } from './TimeLayout';
|
||||||
|
|
||||||
import style from './TimeElements.module.scss';
|
import style from './TimeElements.module.scss';
|
||||||
|
|
||||||
export function StartTimes() {
|
interface OverviewTimeElementsProps {
|
||||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview();
|
shouldFormat?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart);
|
export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
|
||||||
|
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
|
||||||
|
|
||||||
|
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
|
||||||
|
|
||||||
|
const plannedStartText = (() => {
|
||||||
|
if (plannedStart === null) return timerPlaceholder;
|
||||||
|
if (shouldFormat) return formatTime(plannedStart, formatOptions);
|
||||||
|
return millisToString(plannedStart, { fallback: timerPlaceholder });
|
||||||
|
})();
|
||||||
|
|
||||||
|
const actualStartText = (() => {
|
||||||
|
if (actualStart === null) return timerPlaceholder;
|
||||||
|
if (shouldFormat) return formatTime(actualStart, formatOptions);
|
||||||
|
return millisToString(actualStart, { fallback: timerPlaceholder });
|
||||||
|
})();
|
||||||
|
|
||||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||||
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
|
||||||
const plannedEndText = maybePlannedEnd === null ? timerPlaceholder : formatTime(maybePlannedEnd);
|
const plannedEndText = (() => {
|
||||||
|
if (maybePlannedEnd === null) return timerPlaceholder;
|
||||||
|
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
|
||||||
|
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
|
||||||
|
})();
|
||||||
|
|
||||||
|
const multipleDays = maybePlannedDaySpan > 0;
|
||||||
|
const plannedEndTooltip = multipleDays
|
||||||
|
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
|
||||||
|
: 'Planned end time';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div className={style.row}>
|
<div className={style.row}>
|
||||||
<span className={style.label}>Start</span>
|
<span className={style.label}>Start</span>
|
||||||
<div className={style.labelledElement}>
|
<Tooltip
|
||||||
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
|
text='Planned start time'
|
||||||
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
|
render={
|
||||||
</div>
|
<div className={style.labelledElement}>
|
||||||
<div className={style.labelledElement} data-testid='actual-start-time'>
|
<TbCalendarPin className={style.icon} />
|
||||||
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
|
<SuperscriptPeriod
|
||||||
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span>
|
className={cx([style.time, plannedStart === null && style.muted])}
|
||||||
</div>
|
time={plannedStartText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
text='Actual start time'
|
||||||
|
render={
|
||||||
|
<div className={style.labelledElement} data-testid='actual-start-time'>
|
||||||
|
<TbCalendarClock className={style.icon} />
|
||||||
|
<SuperscriptPeriod
|
||||||
|
className={cx([style.time, actualStart === null && style.muted])}
|
||||||
|
time={actualStartText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.row}>
|
<div className={style.row}>
|
||||||
<span className={style.label}>End</span>
|
<span className={style.label}>End</span>
|
||||||
<div className={style.labelledElement}>
|
<Tooltip
|
||||||
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
|
text={plannedEndTooltip}
|
||||||
{maybePlannedDaySpan > 0 ? (
|
render={
|
||||||
<Tooltip
|
<div className={style.labelledElement}>
|
||||||
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
|
<TbCalendarPin className={style.icon} />
|
||||||
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
|
<SuperscriptPeriod
|
||||||
>
|
className={cx([style.time, plannedEnd === null && style.muted])}
|
||||||
{plannedEndText}
|
time={plannedEndText}
|
||||||
</Tooltip>
|
/>
|
||||||
) : (
|
{multipleDays && (
|
||||||
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span>
|
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.labelledElement}>
|
}
|
||||||
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
|
/>
|
||||||
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
|
<RundownExpectedEnd shouldFormat={shouldFormat} />
|
||||||
<Tooltip
|
|
||||||
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
|
|
||||||
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
|
|
||||||
>
|
|
||||||
{formattedTime(maybeExpectedEnd)}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<span className={cx([style.time, maybeExpectedEnd === null && style.muted])}>
|
|
||||||
{formattedTime(maybeExpectedEnd)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the expected end for the rundown
|
||||||
|
* Extracted to improve performance as this is a ticking value
|
||||||
|
*/
|
||||||
|
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
|
||||||
|
const { expectedEnd } = useRundownExpectedEnd();
|
||||||
|
|
||||||
|
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
||||||
|
const maybeExpectedEndText = (() => {
|
||||||
|
if (maybeExpectedEnd === null) return timerPlaceholder;
|
||||||
|
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
|
||||||
|
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
|
||||||
|
})();
|
||||||
|
|
||||||
|
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
|
||||||
|
const tooltip = multipleDays
|
||||||
|
? `Expected end time (rundown spans over ${maybeExpectedDaySpan + 1} days)`
|
||||||
|
: 'Expected end time';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
text={tooltip}
|
||||||
|
render={
|
||||||
|
<div className={style.labelledElement}>
|
||||||
|
<TbCalendarStar className={style.icon} />
|
||||||
|
<SuperscriptPeriod
|
||||||
|
className={cx([style.time, maybeExpectedEnd === null && style.muted])}
|
||||||
|
time={maybeExpectedEndText}
|
||||||
|
/>
|
||||||
|
{multipleDays && <span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MetadataTimes() {
|
export function MetadataTimes() {
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
@@ -229,11 +297,17 @@ export function OffsetOverview() {
|
|||||||
return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
|
return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClockOverview({ className }: { className?: string }) {
|
export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
|
||||||
const { clock } = useClock();
|
const { clock } = useClock();
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
|
||||||
|
|
||||||
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
|
return (
|
||||||
|
<WrappedInTimeColumn
|
||||||
|
label='Time now'
|
||||||
|
className={className}
|
||||||
|
render={(clockClasses) => <SuperscriptPeriod className={clockClasses} time={formattedClock} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimerOverview({ className }: { className?: string }) {
|
export function TimerOverview({ className }: { className?: string }) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.label {
|
.label {
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
@@ -29,14 +29,21 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.over,
|
||||||
|
.under {
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-state='waiting'] {
|
&[data-state='waiting'] {
|
||||||
.label {
|
.label {
|
||||||
color: $ontime-roll;
|
color: $ontime-roll;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-state='over'] {
|
&[data-state='over'] {
|
||||||
.label .over {
|
.label .over {
|
||||||
color: $playback-over;
|
color: $playback-over;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.clock {
|
.clock {
|
||||||
color: $playback-over;
|
color: $playback-over;
|
||||||
@@ -46,6 +53,7 @@
|
|||||||
&[data-state='under'] {
|
&[data-state='under'] {
|
||||||
.label .under {
|
.label .under {
|
||||||
color: $playback-under;
|
color: $playback-under;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.clock {
|
.clock {
|
||||||
color: $playback-under;
|
color: $playback-under;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import style from './TimeLayout.module.scss';
|
import style from './TimeLayout.module.scss';
|
||||||
@@ -22,6 +24,21 @@ export function TimeColumn({ label, value, state = 'active', className, testId }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WrappedInTimeColumnProps {
|
||||||
|
label: string;
|
||||||
|
state?: 'muted' | 'waiting' | 'active';
|
||||||
|
className?: string;
|
||||||
|
render: (className: string) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WrappedInTimeColumn({ label, state = 'active', className, render }: WrappedInTimeColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className={cx([style.column, className])} data-state={state}>
|
||||||
|
<span className={style.label}>{label}</span>
|
||||||
|
{render(style.clock)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
interface OverUnderProps {
|
interface OverUnderProps {
|
||||||
state: 'over' | 'under' | 'muted' | null;
|
state: 'over' | 'under' | 'muted' | null;
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
.title {
|
.title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $ui-white;
|
||||||
|
line-height: 1;
|
||||||
@include ellipsis-overflow;
|
@include ellipsis-overflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $label-gray;
|
color: $secondary-text-gray;
|
||||||
|
font-weight: 400;
|
||||||
@include ellipsis-overflow;
|
@include ellipsis-overflow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
|
|
||||||
import { Corner } from '../../common/components/editor-utils/EditorUtils';
|
import { CornerExtract } from '../../common/components/editor-utils/EditorUtils';
|
||||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||||
import { handleLinks } from '../../common/utils/linkUtils';
|
import { handleLinks } from '../../common/utils/linkUtils';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
|
||||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||||
@@ -55,11 +56,11 @@ function RundownExport() {
|
|||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||||
<FinderPlacement />
|
<FinderPlacement />
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
<div className={style.rundown}>
|
<div className={style.rundown}>
|
||||||
<div className={style.list}>
|
<div className={style.list}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />}
|
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||||
<RundownContextMenu>
|
<RundownContextMenu>
|
||||||
<RundownWrapper />
|
<RundownWrapper />
|
||||||
</RundownContextMenu>
|
</RundownContextMenu>
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
|||||||
<Editor.Title>Group schedule</Editor.Title>
|
<Editor.Title>Group schedule</Editor.Title>
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<div>
|
<div>
|
||||||
{
|
|
||||||
// TODO: format with user time settings
|
|
||||||
}
|
|
||||||
<Editor.Label>First event start</Editor.Label>
|
<Editor.Label>First event start</Editor.Label>
|
||||||
<TextLikeInput className={style.textLikeInput} disabled>
|
<TextLikeInput className={style.textLikeInput} disabled>
|
||||||
{millisToString(group.timeStart, { fallback: timerPlaceholder })}
|
{millisToString(group.timeStart, { fallback: timerPlaceholder })}
|
||||||
|
|||||||
+12
-7
@@ -2,16 +2,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
height: 1px;
|
|
||||||
background: $blue-500;
|
|
||||||
z-index: $zindex-floating;
|
z-index: $zindex-floating;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: $blue-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.addButton {
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eventTimers.editMode:hover {
|
||||||
|
[class*="hoverLabel"] {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s 0.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chipSection {
|
.chipSection {
|
||||||
grid-area: chip;
|
grid-area: chip;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
IoTime,
|
IoTime,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
||||||
import TitleEditor from '../common/TitleEditor';
|
import TitleEditor from '../common/TitleEditor';
|
||||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||||
|
|
||||||
@@ -76,6 +78,11 @@ function RundownEventInner({
|
|||||||
}: RundownEventInnerProps) {
|
}: RundownEventInnerProps) {
|
||||||
const [renderInner, setRenderInner] = useState(false);
|
const [renderInner, setRenderInner] = useState(false);
|
||||||
|
|
||||||
|
const [editorMode] = useSessionStorage({
|
||||||
|
key: sessionKeys.editorMode,
|
||||||
|
defaultValue: AppMode.Edit,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRenderInner(true);
|
setRenderInner(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -92,7 +99,7 @@ function RundownEventInner({
|
|||||||
|
|
||||||
return !renderInner ? null : (
|
return !renderInner ? null : (
|
||||||
<>
|
<>
|
||||||
<div className={style.eventTimers}>
|
<div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
|
||||||
<TimeInputFlow
|
<TimeInputFlow
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
timeStart={timeStart}
|
timeStart={timeStart}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
import TitleEditor from '../common/TitleEditor';
|
import TitleEditor from '../common/TitleEditor';
|
||||||
import { canDrop } from '../rundown.utils';
|
import { canDrop } from '../rundown.utils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
<div className={style.metaRow}>
|
<div className={style.metaRow}>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>Start</div>
|
<div>Start</div>
|
||||||
<div>{formatTime(data.timeStart)}</div>
|
<div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>End</div>
|
<div>End</div>
|
||||||
<div>{formatTime(data.timeEnd)}</div>
|
<div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.metaEntry}>
|
<div className={style.metaEntry}>
|
||||||
<div>Duration</div>
|
<div>Duration</div>
|
||||||
|
|||||||
@@ -15,3 +15,26 @@
|
|||||||
.active {
|
.active {
|
||||||
color: $active-indicator;
|
color: $active-indicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hoverLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.5rem;
|
||||||
|
background-color: $ui-white;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
color: $ui-black;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
transition: opacity 0.15s ease; // no delay on fade out
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: $zindex-floating;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s 0.5s ease; // small delay before fade in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ function TimeInputFlow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className={style.inputWrapper}>
|
||||||
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
|
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
|
||||||
|
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
|
||||||
<TimeInputGroup hasDelay={hasDelay}>
|
<TimeInputGroup hasDelay={hasDelay}>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
name='timeStart'
|
name='timeStart'
|
||||||
@@ -88,8 +89,9 @@ function TimeInputFlow({
|
|||||||
</TimeInputGroup>
|
</TimeInputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className={style.inputWrapper}>
|
||||||
{showLabels && <Editor.Label>End time</Editor.Label>}
|
{showLabels && <Editor.Label>End time</Editor.Label>}
|
||||||
|
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
|
||||||
<TimeInputGroup hasDelay={hasDelay}>
|
<TimeInputGroup hasDelay={hasDelay}>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
name='timeEnd'
|
name='timeEnd'
|
||||||
@@ -110,8 +112,9 @@ function TimeInputFlow({
|
|||||||
</TimeInputGroup>
|
</TimeInputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className={style.inputWrapper}>
|
||||||
{showLabels && <Editor.Label>Duration</Editor.Label>}
|
{showLabels && <Editor.Label>Duration</Editor.Label>}
|
||||||
|
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
|
||||||
<TimeInputGroup hasDelay={hasDelay}>
|
<TimeInputGroup hasDelay={hasDelay}>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
name='duration'
|
name='duration'
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
|
|||||||
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
|
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
|
||||||
import copyToClipboard from '../../common/utils/copyToClipboard';
|
import copyToClipboard from '../../common/utils/copyToClipboard';
|
||||||
import { preventEscape } from '../../common/utils/keyEvent';
|
import { preventEscape } from '../../common/utils/keyEvent';
|
||||||
import { linkToOtherHost } from '../../common/utils/linkUtils';
|
|
||||||
import { isUrlSafe } from '../../common/utils/regex';
|
import { isUrlSafe } from '../../common/utils/regex';
|
||||||
import { isOntimeCloud, serverURL } from '../../externals';
|
import { isOntimeCloud, serverURL } from '../../externals';
|
||||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||||
@@ -136,7 +135,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = await generateUrl({
|
const url = await generateUrl({
|
||||||
baseUrl: linkToOtherHost(options.baseUrl),
|
baseUrl: options.baseUrl,
|
||||||
path,
|
path,
|
||||||
authenticate: options.authenticate,
|
authenticate: options.authenticate,
|
||||||
lockConfig: options.lockConfig,
|
lockConfig: options.lockConfig,
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
|||||||
const { data: infoData } = useInfo();
|
const { data: infoData } = useInfo();
|
||||||
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
|
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hostOptions are only used for local networks
|
||||||
|
* the NIF address is a local IP address: 192.168.x.x or 10.x.x.x
|
||||||
|
* We need to inject the port and protocol to create a valid URL
|
||||||
|
* eg: http://192.168.x.x:port
|
||||||
|
*/
|
||||||
const hostOptions = useMemo(() => {
|
const hostOptions = useMemo(() => {
|
||||||
return infoData.networkInterfaces.map((nif) => ({
|
return infoData.networkInterfaces.map((nif) => ({
|
||||||
value: nif.address,
|
value: `http://${nif.address}:${infoData.serverPort}`,
|
||||||
label: `${nif.name} - ${nif.address}`,
|
label: `${nif.name} - ${nif.address}`,
|
||||||
}));
|
}));
|
||||||
}, [infoData.networkInterfaces]);
|
}, [infoData.networkInterfaces, infoData.serverPort]);
|
||||||
|
|
||||||
const pathOptions = useMemo(() => {
|
const pathOptions = useMemo(() => {
|
||||||
if (lockedPath) {
|
if (lockedPath) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import './SuperscriptTime.scss';
|
||||||
|
|
||||||
|
interface SuperscriptPeriodProps {
|
||||||
|
time: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives a time string and formats periods (am/pm) as superscript
|
||||||
|
* @example 12:00 AM -> AM becomes a superscript
|
||||||
|
* @example 12:00:10 -> no formatting changes applied
|
||||||
|
*/
|
||||||
|
export default function SuperscriptPeriod({ time, className }: SuperscriptPeriodProps) {
|
||||||
|
// we assume anything after space is a period tag
|
||||||
|
const [timeString, period] = time.split(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{timeString}
|
||||||
|
{period && <sup className='period'>{period}</sup>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router';
|
import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
|
||||||
import { Routes } from 'react-router';
|
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// from https://www.genomecolor.space/
|
// from https://www.genomecolor.space/
|
||||||
|
|
||||||
$white-1: rgba(255, 255, 255, 0.01);
|
$white-1: rgba(255, 255, 255, 0.01);
|
||||||
|
$white-2: rgba(255, 255, 255, 0.02);
|
||||||
$white-3: rgba(255, 255, 255, 0.03);
|
$white-3: rgba(255, 255, 255, 0.03);
|
||||||
$white-7: rgba(255, 255, 255, 0.07);
|
$white-7: rgba(255, 255, 255, 0.07);
|
||||||
$white-9: rgba(255, 255, 255, 0.09);
|
$white-9: rgba(255, 255, 255, 0.09);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
|
|||||||
$ontime-stop: #E4281E;
|
$ontime-stop: #E4281E;
|
||||||
$playback-negative: $red-500;
|
$playback-negative: $red-500;
|
||||||
$playback-ahead: $green-500;
|
$playback-ahead: $green-500;
|
||||||
$active-indicator: #8bb33d;
|
$active-indicator: $green-400;
|
||||||
$text-black: $gray-1350;
|
$text-black: $gray-1350;
|
||||||
|
|
||||||
$playback-over: #F57C13;
|
$playback-over: #F57C13;
|
||||||
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
|
|||||||
// interface text
|
// interface text
|
||||||
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
||||||
$title-gray: $gray-200;
|
$title-gray: $gray-200;
|
||||||
$label-gray: $gray-400;
|
$label-gray: $gray-600;
|
||||||
$secondary-text-gray: $gray-400;
|
$secondary-text-gray: $gray-400;
|
||||||
$muted-gray: $gray-600;
|
$muted-gray: $gray-600;
|
||||||
$section-white: $ui-white;
|
$section-white: $ui-white;
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
|||||||
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: 'hh:mm a', format24: 'HH:mm' });
|
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
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: 'hh:mm a', format24: 'HH:mm' });
|
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user