diff --git a/.dockerignore b/.dockerignore index fcac172a2..f91d1539a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,8 @@ # Ignore build folders node_modules -dist +**/node_modules +**/dist # Ignore default volumes created by running docker compose up ontime-db diff --git a/.eslintignore b/.eslintignore index cfdd63c69..877ad032b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ - "ONTIME_VERSION.js" + "ONTIME_VERSION.js" +dist/ diff --git a/.github/workflows/build_cli.yml b/.github/workflows/build_cli.yml index 96d35215f..effc09658 100644 --- a/.github/workflows/build_cli.yml +++ b/.github/workflows/build_cli.yml @@ -13,20 +13,20 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - registry-url: 'https://registry.npmjs.org' - + - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 with: - version: 10 + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/build.yml b/.github/workflows/build_electron.yml similarity index 84% rename from .github/workflows/build.yml rename to .github/workflows/build_electron.yml index 1052d6cb7..29b253124 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build_electron.yml @@ -11,15 +11,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - + - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 with: - version: 10 + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile @@ -53,15 +58,9 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - name: Setup pnpm uses: pnpm/action-setup@v3 - with: - version: 10 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -86,15 +85,9 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - name: Setup pnpm uses: pnpm/action-setup@v3 - with: - version: 10 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/build_resolver.yml b/.github/workflows/build_resolver.yml new file mode 100644 index 000000000..8f763fd4d --- /dev/null +++ b/.github/workflows/build_resolver.yml @@ -0,0 +1,42 @@ +name: Ontime Resolver build + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build_resolver: + permissions: + id-token: write + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project packages + run: pnpm build:resolver + + - name: Publish to NPM + run: pnpm publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + working-directory: ./apps/resolver diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a39d94a2..66cdd12e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,55 +14,35 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 with: - version: 10 + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile - # Run code quality per package - - name: React - Run linter + TypeScript checks - if: always() - run: pnpm lint && tsc --noEmit - working-directory: ./apps/client - - - name: Server - Run linter + TypeScript checks - if: always() - run: pnpm lint && tsc --noEmit - working-directory: ./apps/server - - - name: Utils - Run linter + TypeScript checks - if: always() - run: pnpm lint && tsc --noEmit - working-directory: ./packages/utils - - - name: Types - Run linter - if: always() + # Run code quality + - name: Run linter run: pnpm lint - working-directory: ./packages/types + + - name: Run TypeScript checks + run: pnpm typecheck # We choose to run tests separately - - name: React - Run unit tests + - name: Run unit tests if: always() - run: pnpm test:pipeline - working-directory: ./apps/client - - - name: Server - Run unit tests - if: always() - run: pnpm test:pipeline - working-directory: ./apps/server - - - name: Utils - Run unit tests - if: always() - run: pnpm test:pipeline - working-directory: ./packages/utils + run: pnpm test e2e-test: runs-on: ubuntu-latest @@ -70,21 +50,36 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 with: - version: 10 + path: ${{ env.PNPM_STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build client - run: pnpm build:local + - name: Get installed Playwright version + run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV + - name: Cache playwright binaries + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + restore-keys: | + ${{ runner.os }}-playwright- + - run: npx playwright install --with-deps + if: steps.playwright-cache.outputs.cache-hit != 'true' - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.gitignore b/.gitignore index 4a5888eb0..251deeb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/* build/ dist/ +# bundled assets +translations.json +override.css + # working stuff **/TODO.md diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603fe..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 1ff6325df..000000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index a0139e551..46cb21864 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,7 @@ playwright-report **/*.toml **/*.yml -**/*.json \ No newline at end of file +**/*.json +!tsconfig.common.json +!turbo.json +!package.json diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5ddd0f382..48f93b23a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -20,19 +20,20 @@ development. Locally, we would need to run both the React client and the node.js server in development mode From the project root, run the following commands - - __Install the project dependencies__ by running `pnpm i` -- __Run dev mode__ by running `turbo dev` +- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window + ### Debugging backend -To debug backend code in Node.js: +The previous command will start the development servers for both the client, server and electron applications. +Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging. -- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. -- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server - applications. -- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by - running `pnpm dev:inspect`. +We do that by creating two terminals an running +- __Run the React UI__ by running `pnpm dev --filter=ontime-ui` +- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server` + +- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`. ## TESTING @@ -45,7 +46,7 @@ Generally we have 2 types of tests. Unit tests are contained in mostly all the apps and packages (client, server and utils) -You can run unit tests by running turbo `turbo test:pipeline` from the project root. +You can run unit tests by running `pnpm test:pipeline` from the project root. This will run all tests and close test runner. Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode @@ -65,7 +66,7 @@ start the webserver with `pnpm dev:server` Some other useful commands -- `pnpm e2e --ui` open playwright UI +- `pnpm e2e:ui` open playwright UI - `pnpm e2e --headed` run tests with a visible browser window ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) @@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps. From the project root, run the following commands - __Install the project dependencies__ by running `pnpm i` -- __Build the UI and server__ by running `turbo build:electron` -- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux` +- __Build the UI and server__ by running `pnpm build` +- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux` The build distribution assets will be at `.apps/electron/dist` Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues. -Use the `turbo dist-mac:local` command to build a MacOS distribution locally. +Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process. ## DOCKER @@ -99,9 +100,15 @@ Other useful commands - __List running processes__ by running `docker ps` - __Kill running process__ by running `docker kill ` -## General Info +## CONTRIBUTION GUIDELINES -# APP Building +If you want to propose changes to the codebase, please reach out before opening a Pull Request. -We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron. -Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present. +For new PRs, please follow the following checklist: +* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic +* [ ] You have ran code formatting and linting in all your changes +* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages +* [ ] The PR body contains description and motivation for the changes + +After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \ +We will review as soon as possible diff --git a/Dockerfile b/Dockerfile index 726130551..58480312b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,36 @@ -FROM node:22-bullseye AS builder -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN npm install -g pnpm@10.11.0 -COPY . /app -WORKDIR /app -RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile -RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker - -FROM node:22-alpine - -# Set environment variables -# Environment Variable to signal that we are running production -ENV NODE_ENV=docker -# Ontime Data path -ENV ONTIME_DATA=/data/ - -WORKDIR /app/ - -# Prepare UI -COPY --from=builder /app/apps/client/build ./client/ - -# Prepare Backend -COPY --from=builder /app/apps/server/dist/ ./server/ -COPY --from=builder /app/apps/server/src/external/ ./external/ -COPY --from=builder /app/apps/server/src/user/ ./user/ -COPY --from=builder /app/apps/server/src/html/ ./html/ - -# Export default ports -EXPOSE 4001/tcp 8888/udp 9999/udp - -CMD ["node", "server/docker.cjs"] - -# Build and run commands -# !!! Note that this command needs pre-build versions of the UI and server apps -# docker buildx build . -t getontime/ontime -# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime +FROM node:22-bullseye AS builder +ENV PNPM_HOME="/pnpm" +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 +# Environment Variable to signal that we are running production +ENV NODE_ENV=docker +# Ontime Data path +ENV ONTIME_DATA=/data/ + +WORKDIR /app/ + +# Prepare UI +COPY --from=builder /app/apps/client/build ./client/ + +# Prepare Backend +COPY --from=builder /app/apps/server/dist/ ./server/ +COPY --from=builder /app/apps/server/src/external/ ./external/ +COPY --from=builder /app/apps/server/src/user/ ./user/ +COPY --from=builder /app/apps/server/src/html/ ./html/ + +# Export default ports +EXPOSE 4001/tcp 8888/udp 9999/udp + +CMD ["node", "server/docker.cjs"] + +# Build and run commands +# 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 diff --git a/README.md b/README.md index afe691e57..375d89a6e 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by - Houses of worship ## Main features -- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image -- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers... -- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage... -- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team -- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock -- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS) +- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker. +- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers... +- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage. +- **Real-Time Updates**: Manage and communicate runtime delays effortlessly. +- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock +- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS) ... and a lot more ... @@ -79,84 +79,29 @@ Ontime is made by video engineers and entertainment technicians. ## Using Ontime -Ontime can be started by downloading the latest release for your platform. \ -Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime) +### Getting started -Once installed and running, any device in the network has access to Ontime. +The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \ +This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection. -Ontime provides different screens which allow for different types of interactions with the data. These are called -views. \ -Each view in Ontime focuses on empowering a specific role or achieving a particular task. +Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime) -You can access the different views by reaching the ontime server, in your browser, at (_default port -4001_) `http://localhost:4001` or `http://192.168.1.3:4001` - -``` -For the backstage views -------------------------------------------------------------- -IP.ADDRESS:4001/timer > Presenter / Stage timer view -IP.ADDRESS:4001/minimal > Simple timer view -IP.ADDRESS:4001/clock > Simple clock view -IP.ADDRESS:4001/backstage > Stage Manager / Backstage view -IP.ADDRESS:4001/countdown > Countdown to anything -IP.ADDRESS:4001/studio > Studio Clock -IP.ADDRESS:4001/timeline > Timeline -``` - -``` -For the public views -------------------------------------------------------------- -IP.ADDRESS:4001/public > Public / Foyer view -IP.ADDRESS:4001/lower > Lower Thirds -``` - -``` -For production views -------------------------------------------------------------- -IP.ADDRESS:4001/editor > the control interface, same as the app -IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration -IP.ADDRESS:4001/operator > automated views for operators -``` +Once installed and running, any device that shares the same network as Ontime will have access to Ontime. More information is available [in our docs](https://docs.getontime.no) -## Roadmap - -### Continued development +## Continued development Ontime is under active development. We continue adding and improving features in collaboration with users. Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new) -### Issues +## Issues We use Github's issue tracking for bug reporting and feature requests. \ Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new). -#### Unsigned App - -When installing the app you would see warning screens from the Operating System like: - -in Windows - -`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.` - -or in Linux - -`Could Not Display "ontime-linux.AppImage` - -We currently only sign MacOS releases. \ -Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover. - -You can circumvent this by allowing the execution of the app manually. - -- In Windows: click `more` -> `Run Anyway` -- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program` - -If you have tips on how to improve this or would like to sponsor the code signing, -please [open an issue](https://github.com/cpvalente/ontime/issues/new) - ## Contributing Looking to contribute? All types of help are appreciated, from coding to testing and feature specification. @@ -168,6 +113,7 @@ Information about the project setup can be found in the [development documentati ## Links - [Ontime website](https://getontime.no) - [Documentation](https://docs.getontime.no) +- [Video tutorials](https://www.youtube.com/@ontimeapp) - [Ontime discord server](https://discord.com/invite/eje3CSUEXm) ## License diff --git a/apps/cli/package.json b/apps/cli/package.json index 6f5297d63..53d05de53 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getontime/cli", - "version": "3.16.0", + "version": "4.0.0-beta.5", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index c600c987f..c1ac8d56d 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -15,10 +15,12 @@ ], "plugins": [ "react", + "react-compiler", "simple-import-sort", "@tanstack/query" ], "rules": { + "react-compiler/react-compiler": "warn", "react/jsx-no-bind": [ "error", { diff --git a/apps/client/index.html b/apps/client/index.html index 7332b74e4..c46ec4f7f 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -13,12 +13,6 @@ ontime - diff --git a/apps/client/package.json b/apps/client/package.json index 175359ec4..40258b8a4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,54 +1,48 @@ { "name": "ontime-ui", - "version": "3.16.0", + "version": "4.0.0-beta.5", "private": true, "type": "module", "dependencies": { - "@chakra-ui/react": "^2.7.0", - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/sortable": "^8.0.0", + "@base-ui-components/react": "1.0.0-beta.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@emotion/is-prop-valid": "^1.3.1", - "@emotion/react": "^11.10.6", - "@emotion/styled": "^11.10.6", - "@fontsource/open-sans": "^5.0.28", - "@mantine/hooks": "^7.13.3", - "@sentry/react": "^8.43.0", + "@fontsource/open-sans": "^5.2.6", + "@mantine/hooks": "^8.2.8", + "@sentry/react": "^10.2.0", "@table-nav/react": "^0.0.7", - "@tanstack/react-query": "^5.62.7", - "@tanstack/react-query-devtools": "^5.62.7", + "@tanstack/react-query": "^5.85.9", + "@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-table": "^8.21.3", "autosize": "^6.0.1", - "axios": "^1.2.0", - "color": "^4.2.3", - "csv-stringify": "^6.4.5", - "framer-motion": "^10.10.0", - "prismjs": "^1.29.0", - "react": "^18.3.1", + "axios": "^1.12.2", + "babel-plugin-react-compiler": "19.1.0-rc.3", + "csv-stringify": "^6.6.0", + "prismjs": "^1.30.0", + "react": "^19.1.1", "react-colorful": "^5.6.1", - "react-dom": "^18.3.1", + "react-dom": "^19.1.1", "react-fast-compare": "^3.2.2", - "react-hook-form": "^7.53.1", - "react-icons": "5.4.0", - "react-qr-code": "^2.0.12", - "react-router-dom": "^6.3.0", + "react-hook-form": "^7.62.0", + "react-icons": "5.5.0", + "react-qr-code": "^2.0.18", + "react-router": "^7.8.2", "react-simple-code-editor": "^0.14.1", - "web-vitals": "^3.1.1", - "zustand": "^5.0.3" + "react-virtuoso": "^4.14.0", + "web-vitals": "^5.1.0", + "zustand": "^5.0.8" }, "scripts": { "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "postinstall": "pnpm addversion", "dev": "cross-env BROWSER=none vite", - "build": "vite build", - "build:local": "cross-env NODE_ENV=local vite build", - "build:electron": "cross-env NODE_ENV=local vite build", - "build:docker": "cross-env VITE_IS_DOCKER=true vite build", - "build:localdocker": "cross-env NODE_ENV=local vite build", + "dev:electron": "pnpm dev", "lint": "eslint . --quiet", + "typecheck": "tsc --noEmit", + "build": "vite build", "test": "vitest", "test:pipeline": "vitest run", - "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build", "analyse": "npx vite-bundle-visualizer" }, "browserslist": { @@ -66,10 +60,9 @@ "devDependencies": { "@sentry/vite-plugin": "^2.16.1", "@tanstack/eslint-plugin-query": "^5.8.4", - "@types/color": "^3.0.3", "@types/prismjs": "^1.26.5", - "@types/react": "^18.0.26", - "@types/react-dom": "^18.0.10", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", "@vitejs/plugin-react": "4.5.1", @@ -78,16 +71,16 @@ "eslint-plugin-jest": "^28.6.0", "eslint-plugin-prettier": "catalog:", "eslint-plugin-react": "^7.32.0", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-compiler": "19.1.0-rc.2", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^8.0.0", - "happy-dom": "^16.7.2", "ontime-types": "workspace:*", "ontime-utils": "workspace:*", "prettier": "catalog:", "sass": "^1.57.1", "typescript": "catalog:", "vite": "6.3.1", - "vite-plugin-compression2": "1.4.0", + "vite-plugin-compression2": "2.2.0", "vite-plugin-svgr": "4.3.0", "vite-tsconfig-paths": "5.1.4", "vitest": "catalog:" diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c07768b94..369e9a857 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,5 +1,5 @@ -import { BrowserRouter } from 'react-router-dom'; -import { ChakraProvider } from '@chakra-ui/react'; +import { BrowserRouter } from 'react-router'; +import { Tooltip } from '@base-ui-components/react/tooltip'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -8,7 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla import { AppContextProvider } from './common/context/AppContext'; import { ontimeQueryClient } from './common/queryClient'; import { connectSocket } from './common/utils/socket'; -import theme from './theme/theme'; +import KeepAwake from './features/keep-awake/KeepAwake'; import { TranslationProvider } from './translation/TranslationProvider'; import AppRouter from './AppRouter'; import { baseURI } from './externals'; @@ -17,14 +17,15 @@ connectSocket(); function App() { return ( - - - + + +
+ @@ -34,9 +35,9 @@ function App() {
- - - + + + ); } diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 63cb9099d..711b5e13c 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -1,175 +1,114 @@ -import React from 'react'; -import { - createRoutesFromChildren, - matchRoutes, - Navigate, - Route, - Routes, - useLocation, - useNavigationType, -} from 'react-router-dom'; -import * as Sentry from '@sentry/react'; +import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react'; +import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; +import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; +import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; +import { PresetContext } from './common/context/PresetContext'; import { useClientPath } from './common/hooks/useClientPath'; +import useUrlPresets from './common/hooks-query/useUrlPresets'; +import { getRouteFromPreset } from './common/utils/urlPresets'; import Log from './features/log/Log'; -import withPreset from './features/PresetWrapper'; -import withData from './features/viewers/ViewWrapper'; +import Loader from './views/common/loader/Loader'; +import NotFound from './views/common/not-found/NotFound'; import ViewLoader from './views/ViewLoader'; -import { ONTIME_VERSION } from './ONTIME_VERSION'; -import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; +import { getIsNavigationLocked, sessionScope } from './externals'; +import { initializeSentry } from './sentry.config'; -const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); -const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); -const Operator = React.lazy(() => import('./features/operator/OperatorExport')); +const Timer = lazy(() => import('./views/timer/Timer')); +const Countdown = lazy(() => import('./views/countdown/Countdown')); +const Backstage = lazy(() => import('./views/backstage/Backstage')); +const StudioClock = lazy(() => import('./views/studio/Studio')); +const Timeline = lazy(() => import('./views/timeline/TimelinePage')); +const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo')); -const TimerView = React.lazy(() => import('./views/timer/Timer')); -const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); -const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); -const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); +const Editor = lazy(() => import('./views/editor/ProtectedEditor')); +const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet')); +const Operator = lazy(() => import('./features/operator/OperatorExport')); -const Backstage = React.lazy(() => import('./views/backstage/Backstage')); -const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); -const Public = React.lazy(() => import('./views/public/Public')); -const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); -const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); -const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); +const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); +const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); +const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); +const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); -const STimer = withPreset(withData(TimerView)); -const SMinimalTimer = withPreset(withData(MinimalTimerView)); -const SClock = withPreset(withData(ClockView)); -const SCountdown = withPreset(withData(Countdown)); -const SBackstage = withPreset(withData(Backstage)); -const SProjectInfo = withPreset(withData(ProjectInfo)); -const SPublic = withPreset(withData(Public)); -const SLowerThird = withPreset(withData(Lower)); -const SStudio = withPreset(withData(StudioClock)); -const STimeline = withPreset(withData(Timeline)); -const PCuesheet = withPreset(Cuesheet); -const POperator = withPreset(Operator); - -const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper')); -const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')); -const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport')); -const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport')); - -Sentry.init({ - dsn: sentryDsn, - integrations: [ - Sentry.reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ], - tracesSampleRate: 0.3, - release: ONTIME_VERSION, - enabled: import.meta.env.PROD, - ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i], - denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i], -}); - -const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); +// Initialize Sentry with our configuration +const SentryRouter = initializeSentry(); export default function AppRouter() { // handle client path changes useClientPath(); return ( - - + }> + } /> - + + } /> - + + } /> - + + } /> - + + } /> - + + } /> - + + } /> - - - - } - /> - {/*/!* Lower third cannot have a loading screen *!/*/} - } /> - - - - } - /> - - - - } - /> - {/*/!* Protected Routes *!/*/} - } /> - } /> + } /> + } /> - + } /> - {/*/!* Protected Routes - Elements *!/*/} @@ -177,7 +116,7 @@ export default function AppRouter() { } /> @@ -185,7 +124,7 @@ export default function AppRouter() { } /> @@ -193,16 +132,119 @@ export default function AppRouter() { } /> } /> - {/*/!* Send to default if nothing found *!/*/} - } /> - - + {/** + * If the views are prefixed with the "preset" path, we are in a locked preset + * Locked presets do not expose their parameters + */} + } /> + + {/** + * If we havent matched any views or presets, we may be in an unlocked preset + * Unlocked presets are unwrapped to expose their target and parameters + */} + } /> + + + ); +} + +const PresetViewMap: Record = { + [OntimeView.Cuesheet]: Cuesheet, + [OntimeView.Operator]: Operator, + [OntimeView.Timer]: Timer, + [OntimeView.Backstage]: Backstage, + [OntimeView.Timeline]: Timeline, + [OntimeView.StudioClock]: StudioClock, + [OntimeView.Countdown]: Countdown, + [OntimeView.ProjectInfo]: ProjectInfo, +}; + +/** + * This view will mask a configured canonical route + * and inject the preset search parameters to context + * User are not able to configure the parameters locked presets + */ +function PresetView() { + const { data, status } = useUrlPresets(); + const { alias } = useParams(); + + const preset: URLPreset | undefined = useMemo(() => { + if (status === 'pending' || !alias) return; + return data.find((p) => p.alias === alias && p.enabled); + }, [data, status, alias]); + + if (status === 'pending') { + return ; + } + + /** + * We need to check the session scope to determine if the user can navigate + * If the user has a global scope, they can navigate freely + * Otherwise, they are locked to the preset view + */ + const showNav = sessionScope === 'rw'; + + /** + * If we are in a preset path but cannot find a preset, we will need to show a not found page + * This can happen if the preset was deleted or disabled + */ + if (!preset) { + return ( + <> + + + + ); + } + + /** + * Locked presets do not allow configuration changes + * Whether the user can navigate is determined by the locked param + */ + const Component = PresetViewMap[preset.target as OntimeViewPresettable]; + return ( + + {preset.target !== OntimeView.Cuesheet && ( + + )} + {Component ? : } + + ); +} + +function RedirectPreset() { + const { data, status } = useUrlPresets(); + const navigate = useNavigate(); + const location = useLocation(); + + // checks if we are in a preset path and resolves a destination URL + const destination = useMemo(() => { + if (status === 'pending') return null; + return getRouteFromPreset(location, data); + }, [data, location, status]); + + // if we have a destination, we will navigate to it + useEffect(() => { + if (destination) { + navigate(`/${destination}`, { replace: true }); + } + }, [destination, navigate]); + + if (status === 'pending') { + return ; + } + + return ( + <> + + + ); } diff --git a/apps/client/src/common/api/assets.ts b/apps/client/src/common/api/assets.ts index 32203e66c..65be05f78 100644 --- a/apps/client/src/common/api/assets.ts +++ b/apps/client/src/common/api/assets.ts @@ -1,6 +1,9 @@ import axios from 'axios'; +import { TranslationObject } from 'ontime-types'; -import { apiEntryUrl } from './constants'; +import { ontimeQueryClient } from '../../common/queryClient'; + +import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants'; const assetsPath = `${apiEntryUrl}/assets`; @@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise { const res = await axios.post(`${assetsPath}/css/restore`); return res.data; } + +/** + * HTTP request to get user translation + */ +export async function getUserTranslation(): Promise { + const res = await axios.get(customTranslationsURL); + return res.data; +} + +/** + * HTTP request to post user translation + */ +export async function postUserTranslation(translation: TranslationObject): Promise { + await axios.post(`${assetsPath}/translations`, { + translation, + }); + await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION }); +} diff --git a/apps/client/src/common/api/axios.config.ts b/apps/client/src/common/api/axios.config.ts new file mode 100644 index 000000000..9d993bc85 --- /dev/null +++ b/apps/client/src/common/api/axios.config.ts @@ -0,0 +1,5 @@ +import axios from 'axios'; + +axios.defaults.validateStatus = (status) => { + return (status >= 200 && status < 300) || status === 304; +}; diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 52f281211..aad81975f 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -8,22 +8,22 @@ export const AUTOMATION = ['automation']; export const CUSTOM_FIELDS = ['customFields']; export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; +export const PROJECT_RUNDOWNS = ['projectRundowns']; export const RUNDOWN = ['rundown']; export const RUNTIME = ['runtimeStore']; -export const SHEET_STATE = ['sheetState']; export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; +export const TRANSLATION = ['translation']; // API URLs export const apiEntryUrl = `${serverURL}/data`; -export const projectDataURL = `${serverURL}/project`; -export const rundownURL = `${serverURL}/events`; -export const ontimeURL = `${serverURL}/ontime`; +const userAssetsPath = 'user'; +const cssOverridePath = 'styles/override.css'; +const customTranslationsPath = 'translations/translations.json'; -export const userAssetsPath = 'user'; -export const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; +export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`; diff --git a/apps/client/src/common/api/customFields.ts b/apps/client/src/common/api/customFields.ts index ff60290ed..02ce4d15e 100644 --- a/apps/client/src/common/api/customFields.ts +++ b/apps/client/src/common/api/customFields.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types'; +import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { apiEntryUrl } from './constants'; @@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise { - const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField }); +export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise { + const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField }); return res.data; } /** * Deletes single custom field */ -export async function deleteCustomField(label: CustomFieldLabel): Promise { - const res = await axios.delete(`${customFieldsPath}/${label}`); +export async function deleteCustomField(key: CustomFieldKey): Promise { + const res = await axios.delete(`${customFieldsPath}/${key}`); return res.data; } diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 9edf73eb5..28f3b9b33 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -1,9 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; -import { makeTable } from '../../views/cuesheet/cuesheet.utils'; -import { makeCSVFromArrayOfArrays } from '../utils/csv'; - import { apiEntryUrl } from './constants'; import { createBlob, downloadBlob } from './utils'; @@ -33,25 +30,6 @@ export async function downloadProject(fileName: string) { } } -/** - * Request download of the current rundown as a CSV file - * @param fileName - */ -export async function downloadCSV(fileName: string = 'rundown') { - try { - const { data, name } = await fileDownload(fileName); - const { project, rundown, customFields } = data; - - const sheetData = makeTable(project, rundown, customFields); - const fileContent = makeCSVFromArrayOfArrays(sheetData); - - const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); - downloadBlob(blob, `${name}.csv`); - } catch (error) { - console.error(error); - } -} - /** * HTTP request to upload project file */ diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts index b73aa585f..5c21c987a 100644 --- a/apps/client/src/common/api/excel.ts +++ b/apps/client/src/common/api/excel.ts @@ -1,16 +1,11 @@ import axios, { AxiosResponse } from 'axios'; -import { CustomFields, OntimeRundown } from 'ontime-types'; +import { CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; const excelPath = `${apiEntryUrl}/excel`; -type PreviewSpreadsheetResponse = { - rundown: OntimeRundown; - customFields: CustomFields; -}; - /** * upload Excel file to server * @return string - file ID op the uploaded file @@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise { return response.data; } +type PreviewSpreadsheetResponse = { + rundown: Rundown; + customFields: CustomFields; +}; export async function importRundownPreview(options: ImportMap): Promise { const response: AxiosResponse = await axios.post(`${excelPath}/preview`, { options, diff --git a/apps/client/src/common/api/external.ts b/apps/client/src/common/api/external.ts index e84e49a15..9c2c0ba53 100644 --- a/apps/client/src/common/api/external.ts +++ b/apps/client/src/common/api/external.ts @@ -11,7 +11,7 @@ export type HasUpdate = { * HTTP request to get the latest version and url from github */ export async function getLatestVersion(): Promise { - const res = await axios.get(`${apiRepoLatest}`); + const res = await axios.get(apiRepoLatest); return { url: res.data.html_url as string, version: res.data.tag_name as string, diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 2ea67e94a..619d946e6 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`; * HTTP request to fetch all reports */ export async function fetchReport(): Promise { - const res = await axios.get(`${reportUrl}/`); + const res = await axios.get(reportUrl); return res.data; } diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 9d854eee1..5d6c758ec 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,86 +1,141 @@ import axios, { AxiosResponse } from 'axios'; -import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; +import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types'; import { apiEntryUrl } from './constants'; -const rundownPath = `${apiEntryUrl}/rundown`; +const rundownPath = `${apiEntryUrl}/rundowns`; + +// #region operations on project rundowns ========================= /** - * HTTP request to fetch all events + * HTTP request to fetch a list of existing rundowns */ -export async function fetchNormalisedRundown(): Promise { - const res = await axios.get(`${rundownPath}/normalised`); +export async function fetchProjectRundownList(): Promise { + const res = await axios.get(rundownPath); return res.data; } /** - * HTTP request to post new event + * HTTP request to fetch all entries in the currently loaded rundown */ -export async function requestPostEvent(data: TransientEventPayload): Promise> { - return axios.post(rundownPath, data); +export async function fetchCurrentRundown(): Promise { + const res = await axios.get(`${rundownPath}/current`); + return res.data; } /** - * HTTP request to put new event + * HTTP request to switch the currently loaded rundown */ -export async function requestPutEvent(data: Partial): Promise> { - return axios.put(rundownPath, data); +export async function loadRundown(id: string): Promise> { + return axios.post(`${rundownPath}/${id}/load`); } -type BatchEditEntry = { +/** + * HTTP request to create a new rundown + */ +export async function createRundown(title: string): Promise> { + return axios.post(rundownPath, { title }); +} + +/** + * HTTP request to delete a rundown + */ +export async function deleteRundown(id: string): Promise> { + return axios.delete(`${rundownPath}/${id}`); +} + +// #endregion operations on project rundowns ====================== +// #region operations on rundown entries ========================== + +/** + * HTTP request to post new entry + */ +export async function postAddEntry( + rundownId: string, + data: TransientEventPayload, +): Promise> { + return axios.post(`${rundownPath}/${rundownId}/entry`, data); +} + +/** + * HTTP request to edit an entry + */ +export async function putEditEntry(rundownId: string, data: Partial): Promise> { + return axios.put(`${rundownPath}/${rundownId}/entry`, data); +} + +export type BatchEditEntry = { data: Partial; - ids: string[]; + ids: EntryId[]; }; /** - * HTTP request to put multiple events + * HTTP request to edit multiple events */ -export async function requestBatchPutEvents(data: BatchEditEntry): Promise> { - return axios.put(`${rundownPath}/batch`, data); +export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise> { + return axios.put(`${rundownPath}/${rundownId}/batch`, data); } export type ReorderEntry = { - eventId: string; - from: number; - to: number; + entryId: EntryId; + destinationId: EntryId; + order: 'before' | 'after' | 'insert'; }; /** - * HTTP request to reorder events + * HTTP request to reorder an entry */ -export async function requestReorderEvent(data: ReorderEntry): Promise> { - return axios.patch(`${rundownPath}/reorder`, data); +export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); } -export type SwapEntry = { - from: string; - to: string; -}; - /** * HTTP request to swap two events */ -export async function requestEventSwap(data: SwapEntry): Promise> { - return axios.patch(`${rundownPath}/swap`, data); +export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); } /** * HTTP request to request application of delay */ -export async function requestApplyDelay(eventId: string): Promise> { - return axios.patch(`${rundownPath}/applydelay/${eventId}`); +export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); } /** - * HTTP request to delete given event + * HTTP request for cloning an entry */ -export async function requestDelete(eventIds: string[]): Promise> { - return axios.delete(rundownPath, { data: { ids: eventIds } }); +export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise> { + return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); } /** - * HTTP request to delete all events + * HTTP request for grouping a list of entries into a group */ -export async function requestDeleteAll(): Promise> { - return axios.delete(`${rundownPath}/all`); +export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise> { + return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); } + +/** + * HTTP request for dissolving of a group + */ +export async function requestUngroup(rundownId: string, groupId: EntryId): Promise> { + return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); +} + +/** + * HTTP request to delete entries of a given rundown + */ +export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise> { + return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); +} + +/** + * HTTP request to delete all entries of a given rundown + */ +export async function requestDeleteAll(rundownId: string): Promise> { + return axios.delete(`${rundownPath}/${rundownId}/all`); +} + +// #endregion operations on rundown entries ======================= diff --git a/apps/client/src/common/api/session.ts b/apps/client/src/common/api/session.ts index 2449cf18e..ae71fa512 100644 --- a/apps/client/src/common/api/session.ts +++ b/apps/client/src/common/api/session.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { GetInfo } from 'ontime-types'; +import { GetInfo, LinkOptions } from 'ontime-types'; import { apiEntryUrl } from './constants'; @@ -16,12 +16,7 @@ export async function getInfo(): Promise { /** * HTTP request to get a pre-authenticated URL */ -export async function generateUrl( - baseUrl: string, - path: string, - lock: boolean, - authenticate: boolean, -): Promise { - const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate }); +export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise { + const res = await axios.post(`${sessionPath}/url`, options); return res.data.url; } diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts index ebeb9fdce..231f543db 100644 --- a/apps/client/src/common/api/sheets.ts +++ b/apps/client/src/common/api/sheets.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; @@ -54,7 +54,7 @@ export const previewRundown = async ( sheetId: string, options: ImportMap, ): Promise<{ - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; }> => { const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); diff --git a/apps/client/src/common/api/urlPresets.ts b/apps/client/src/common/api/urlPresets.ts index 68820a155..454a80c67 100644 --- a/apps/client/src/common/api/urlPresets.ts +++ b/apps/client/src/common/api/urlPresets.ts @@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants'; const urlPresetsPath = `${apiEntryUrl}/url-presets`; /** - * HTTP request to retrieve aliases + * HTTP request to retrieve all presets */ export async function getUrlPresets(): Promise { const res = await axios.get(urlPresetsPath); @@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise { } /** - * HTTP request to mutate aliases + * HTTP request to add a preset */ -export async function postUrlPresets(data: URLPreset[]): Promise { - return axios.post(urlPresetsPath, data); +export async function postUrlPreset(data: URLPreset): Promise { + return (await axios.post(urlPresetsPath, data)).data; +} + +/** + * HTTP request to edit a preset + */ +export async function putUrlPreset(alias: string, data: URLPreset): Promise { + return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data; +} + +/** + * HTTP request to delete a preset + */ +export async function deleteUrlPreset(alias: string): Promise { + return (await axios.delete(`${urlPresetsPath}/${alias}`)).data; } diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts index 9f2f35ce2..1f8701f24 100644 --- a/apps/client/src/common/api/utils.ts +++ b/apps/client/src/common/api/utils.ts @@ -7,7 +7,7 @@ import { addLog } from '../stores/logger'; import { nowInMillis } from '../utils/time'; /** - * Utility unrwap a potential axios error + * Utility unwrap a potential axios error * @param error * @returns */ @@ -34,7 +34,21 @@ export function maybeAxiosError(error: unknown) { } /** - * Utility unrwaps a potential axios error and sends to logger + * Utility unwrap a an instance of Error + */ +export function unwrapError(error: unknown) { + if (error instanceof Error) { + return error.message; + } else { + if (typeof error !== 'string') { + return JSON.stringify(error); + } + return error; + } +} + +/** + * Utility unwraps a potential axios error and sends to logger * @param prepend * @param error */ diff --git a/apps/client/src/common/api/viewSettings.ts b/apps/client/src/common/api/viewSettings.ts index f835f29e7..9eb87505f 100644 --- a/apps/client/src/common/api/viewSettings.ts +++ b/apps/client/src/common/api/viewSettings.ts @@ -1,21 +1,24 @@ import axios from 'axios'; -import { ViewSettings } from 'ontime-types'; +import type { ViewSettings } from 'ontime-types'; import { apiEntryUrl } from './constants'; const viewSettingsPath = `${apiEntryUrl}/view-settings`; /** - * HTTP request to retrieve view settings + * HTTP request to fetch view settings + * @returns */ -export async function getView(): Promise { +export async function getViewSettings(): Promise { const res = await axios.get(viewSettingsPath); return res.data; } /** - * HTTP request to mutate view settings + * HTTP request to update view settings + * needs to update entire objects, not just a patch */ -export async function postViewSettings(data: ViewSettings) { - return axios.post(viewSettingsPath, data); +export async function postViewSettings(data: ViewSettings): Promise { + const res = await axios.post(viewSettingsPath, data); + return res.data; } diff --git a/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss new file mode 100644 index 000000000..0466b499c --- /dev/null +++ b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss @@ -0,0 +1,140 @@ +.subtle { + background: $gray-1050; + color: $blue-400; + line-height: 1em; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $blue-500; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } +} + +.subtle-white { + background: $gray-1050; + color: $ui-white; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $ui-white; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } + + &:disabled { + background: $gray-1050; + } +} + +.primary { + background: $blue-700; + color: $ui-white; + + &:hover:not(:disabled):not(:active) { + background: $blue-600; + } + + &:active:not(:disabled) { + background: $blue-800; + border-color: $blue-900; + } + + &:disabled { + opacity: $viewer-opacity-disabled; + } +} + +.destructive { + background: $red-700; + color: $ui-white; + + &:hover:not(:disabled):not(:active) { + background: $red-600; + color: $ui-white; + } + + &:active:not(:disabled) { + background: $red-800; + border-color: $red-900; + } +} + +.subtle-destructive { + background: $gray-1050; + color: $red-400; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $red-500; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } +} + +.ghosted { + background: transparent; + color: $blue-500; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $blue-500; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } + + &:disabled { + opacity: $opacity-disabled; + } +} + +.ghosted-white { + background: transparent; + color: $ui-white; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $ui-white; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } + + &:disabled { + opacity: $opacity-disabled; + } +} + + +.ghosted-destructive { + background: transparent; + color: $red-500; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $red-500; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } + + &:disabled { + opacity: $opacity-disabled; + } +} diff --git a/apps/client/src/common/components/buttons/Button.module.scss b/apps/client/src/common/components/buttons/Button.module.scss new file mode 100644 index 000000000..cc6dd04b7 --- /dev/null +++ b/apps/client/src/common/components/buttons/Button.module.scss @@ -0,0 +1,84 @@ +@use '@/theme/viewerDefs' as *; +@import './BaseButtonStyles.module.scss'; + +.baseButton { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5em; + + width: fit-content; + padding-inline: 1em; + white-space: nowrap; + + border: 1px solid transparent; + border-radius: $component-border-radius-md; + + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid $blue-500; + outline-offset: 2px; + } + + &.loading { + cursor: wait; + .content { + opacity: 0; + } + } +} + +.content { + display: flex; + align-items: center; + gap: inherit; +} + +.loadingOverlay { + position: absolute; + display: grid; + place-content: center; +} + +.spinner { + animation: spin 1s linear infinite; + stroke-dasharray: 8; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +.small { + height: 1.5rem; + font-size: calc(1rem - 3px); +} + +.medium { + height: 2rem; + font-size: calc(1rem - 2px); +} + +.large { + height: 2.5rem; + font-weight: 600; +} + +.xlarge { + height: 3rem; + font-weight: 600; +} + +.fluid { + width: 100%; +} + diff --git a/apps/client/src/common/components/buttons/Button.tsx b/apps/client/src/common/components/buttons/Button.tsx new file mode 100644 index 000000000..9457e74f4 --- /dev/null +++ b/apps/client/src/common/components/buttons/Button.tsx @@ -0,0 +1,53 @@ +import { ButtonHTMLAttributes, forwardRef } from 'react'; +import { IoEllipseOutline } from 'react-icons/io5'; + +import { cx } from '../../utils/styleUtils'; + +import style from './Button.module.scss'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: + | 'primary' + | 'subtle' + | 'subtle-white' + | 'destructive' + | 'subtle-destructive' + | 'ghosted' + | 'ghosted-white' + | 'ghosted-destructive'; + size?: 'small' | 'medium' | 'large' | 'xlarge'; + fluid?: boolean; + loading?: boolean; +} + +const Button = forwardRef( + ({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => { + return ( + + ); + }, +); + +Button.displayName = 'Button'; + +export default Button; diff --git a/apps/client/src/common/components/buttons/IconButton.module.scss b/apps/client/src/common/components/buttons/IconButton.module.scss index 4fcf16539..a1e9b9981 100644 --- a/apps/client/src/common/components/buttons/IconButton.module.scss +++ b/apps/client/src/common/components/buttons/IconButton.module.scss @@ -1,26 +1,44 @@ -.subtle { +@use '@/theme/viewerDefs' as *; +@import './BaseButtonStyles.module.scss'; + +.baseIconButton { aspect-ratio: 1; - height: 2rem; - height: 2rem; display: grid; place-content: center; - background: $gray-1050; - color: $blue-400; border: 1px solid transparent; - border-radius: 3px; + border-radius: $component-border-radius-md; - &:hover:not(:disabled):not(:active) { - background: $gray-1000; - color: $blue-500; - } + cursor: pointer; - &:active { - background: $gray-1100; - border-color: $gray-1250; + &:focus-visible { + outline: 2px solid $blue-500; + outline-offset: 2px; } &:disabled { - background: $gray-1050; + opacity: 0.4; + cursor: not-allowed; } } + +.small { + height: 1.5rem; + font-size: calc(1rem - 3px); +} + +.medium { + height: 2rem; + width: 2rem; +} + +.large { + height: 2.5rem; + width: 2.5rem; +} + +.xlarge { + height: 3rem; + height: 3rem; + font-size: 1.5rem; +} diff --git a/apps/client/src/common/components/buttons/IconButton.tsx b/apps/client/src/common/components/buttons/IconButton.tsx index 172780f51..1160ea78f 100644 --- a/apps/client/src/common/components/buttons/IconButton.tsx +++ b/apps/client/src/common/components/buttons/IconButton.tsx @@ -1,14 +1,37 @@ -import { ButtonHTMLAttributes } from 'react'; +import { ButtonHTMLAttributes, forwardRef } from 'react'; import { cx } from '../../utils/styleUtils'; import style from './IconButton.module.scss'; -export default function IconButton(props: ButtonHTMLAttributes) { - const { className, children, ...buttonProps } = props; - return ( - - ); +interface IconButtonProps extends ButtonHTMLAttributes { + variant?: + | 'primary' + | 'subtle' + | 'subtle-white' + | 'destructive' + | 'subtle-destructive' + | 'ghosted' + | 'ghosted-white' + | 'ghosted-destructive'; + size?: 'small' | 'medium' | 'large' | 'xlarge'; } + +const IconButton = forwardRef( + ({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => { + return ( + + ); + }, +); + +IconButton.displayName = 'IconButton'; + +export default IconButton; diff --git a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx b/apps/client/src/common/components/buttons/TooltipActionBtn.tsx deleted file mode 100644 index 81f4de703..000000000 --- a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { MouseEvent } from 'react'; -import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react'; - -interface TooltipActionBtnProps extends IconButtonProps { - clickHandler: (event: MouseEvent) => void | Promise; - tooltip: string; - openDelay?: number; -} - -export default function TooltipActionBtn(props: TooltipActionBtnProps) { - const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props; - return ( - - - - ); -} diff --git a/apps/client/src/common/components/checkbox/Checkbox.module.scss b/apps/client/src/common/components/checkbox/Checkbox.module.scss new file mode 100644 index 000000000..6fce73d69 --- /dev/null +++ b/apps/client/src/common/components/checkbox/Checkbox.module.scss @@ -0,0 +1,50 @@ +.checkbox { + box-sizing: border-box; + display: flex; + width: 1rem; + height: 1rem; + align-items: center; + justify-content: center; + border-radius: $component-border-radius-sm; + + &[data-unchecked] { + background-color: $gray-1100; + + &:hover:not(:disabled) { + background: $gray-1000; + } + &:active:not(:disabled) { + background: $gray-1200; + } + } + + &[data-checked] { + background-color: $blue-500; + + &:hover:not(:disabled) { + background: $blue-400; + } + &:active:not(:disabled) { + background: $blue-600; + } + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid $blue-500; + outline-offset: 2px; + } +} + +.indicator { + display: flex; + color: $ui-white; + + &[data-unchecked] { + display: none; + } +} diff --git a/apps/client/src/common/components/checkbox/Checkbox.tsx b/apps/client/src/common/components/checkbox/Checkbox.tsx new file mode 100644 index 000000000..8345563c0 --- /dev/null +++ b/apps/client/src/common/components/checkbox/Checkbox.tsx @@ -0,0 +1,14 @@ +import { IoCheckmark } from 'react-icons/io5'; +import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox'; + +import style from './Checkbox.module.scss'; + +export default function Checkbox(checkboxProps: BaseCheckbox.Root.Props) { + return ( + + + + + + ); +} diff --git a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx index 50b67ee10..e57ff6c8f 100644 --- a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx +++ b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx @@ -1,22 +1,15 @@ import { useState } from 'react'; import { IoArrowForward } from 'react-icons/io5'; -import { - IconButton, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Select, -} from '@chakra-ui/react'; import { navigatorConstants } from '../../../viewerConfig'; import { setClientRemote } from '../../hooks/useSocket'; import useUrlPresets from '../../hooks-query/useUrlPresets'; +import Button from '../buttons/Button'; import Info from '../info/Info'; +import Input from '../input/input/Input'; import AppLink from '../link/app-link/AppLink'; +import Modal from '../modal/Modal'; +import Select from '../select/Select'; import style from './RedirectClientModal.module.scss'; @@ -29,8 +22,7 @@ interface RedirectClientModalProps { onClose: () => void; } -export function RedirectClientModal(props: RedirectClientModalProps) { - const { id, isOpen, name, currentPath, origin, onClose } = props; +export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { const { data } = useUrlPresets(); const [path, setPath] = useState(currentPath); const [selected, setSelected] = useState('/'); @@ -41,86 +33,88 @@ export function RedirectClientModal(props: RedirectClientModalProps) { if (newPath === '/' || newPath === currentPath) { return; } - setRedirect({ target: id, redirect: newPath }); + + if (newPath.startsWith('preset-')) { + setRedirect({ target: id, redirect: newPath.slice(7) }); + } else { + setRedirect({ target: id, redirect: newPath }); + } + onClose(); }; const enabledPresets = data.filter((preset) => preset.enabled); + const viewOptions = [ + ...navigatorConstants.map((view) => ({ + value: `/${view.url}`, + label: view.label, + })), + ...enabledPresets.map((preset) => ({ + value: `preset-${preset.alias}`, + label: `URL Preset: ${preset.alias}`, + })), + ]; + return ( - - - - Redirect: {name} - - + Remotely redirect the client to a different URL.
- Either by selecting a URL Preset or entering a custom path. + Either by entering a custom path or selecting a URL Preset.

- Manage URL Presets + Manage URL Presets
-
- Select View or URL Preset -
- - } - isDisabled={enabledPresets.length === 0 || selected === '/'} - onClick={() => handleRedirect(selected)} - /> -
-
Enter custom path - } onClick={() => handleRedirect(path)} - /> + > + Redirect + +
-
-
-
+
+ Select View or URL Preset +
+ ); } + +interface SeparatorProps extends HTMLAttributes { + orientation?: 'horizontal' | 'vertical'; +} + +export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { + return
; +} diff --git a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx index f0d3e9477..bacd43fff 100644 --- a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx +++ b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx @@ -3,8 +3,8 @@ import React from 'react'; // skipcq: JS-C1003 - sentry does not expose itself as an ES Module. import * as Sentry from '@sentry/react'; -import { runtimeStore } from '@/common/stores/runtime'; -import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; +import { hasConnected, reconnectAttempts } from '../../../common/utils/socket'; +import { runtimeStore } from '../../stores/runtime'; import style from './ErrorBoundary.module.scss'; @@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component { scope.setExtras({ error, store: appState, - hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, + hasSocket: { hasConnected, reconnectAttempts }, }); const eventId = Sentry.captureException(error); this.setState({ eventId, info }); @@ -49,15 +49,14 @@ class ErrorBoundary extends React.Component { return (
-

:/

-

Something went wrong

- +
{ max?: number; // inclusive } -export function FitText(props: PropsWithChildren) { - const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props; +export function FitText({ + children, + mode = 'multi', + min = 16, + max = 256, + ...elementProps +}: PropsWithChildren) { const ref = useRef(null); const isOverflown = useCallback(() => { diff --git a/apps/client/src/common/components/icons/RotatedLink.tsx b/apps/client/src/common/components/icons/RotatedLink.tsx new file mode 100644 index 000000000..da4aa4065 --- /dev/null +++ b/apps/client/src/common/components/icons/RotatedLink.tsx @@ -0,0 +1,6 @@ +import { IconBaseProps } from 'react-icons'; +import { IoLink } from 'react-icons/io5'; + +export default function RotatedLink(linkProps: IconBaseProps) { + return ; +} diff --git a/apps/client/src/common/components/identify-overlay/IdentifyOverlay.module.scss b/apps/client/src/common/components/identify-overlay/IdentifyOverlay.module.scss index ac65bc290..85f0f8938 100644 --- a/apps/client/src/common/components/identify-overlay/IdentifyOverlay.module.scss +++ b/apps/client/src/common/components/identify-overlay/IdentifyOverlay.module.scss @@ -15,7 +15,6 @@ display: grid; place-content: center; text-align: center; - z-index: 100; cursor: pointer; } diff --git a/apps/client/src/common/components/info/Info.module.scss b/apps/client/src/common/components/info/Info.module.scss index 8cbe2774d..3939cbb54 100644 --- a/apps/client/src/common/components/info/Info.module.scss +++ b/apps/client/src/common/components/info/Info.module.scss @@ -7,15 +7,29 @@ border-radius: 3px; font-size: $text-body-size; padding: 1rem; - - .content { - color: $gray-200; - } + color: $gray-200; svg { min-width: 1.5rem; align-self: start; font-size: 1.5rem; + } +} + +.info { + svg { color: $info-blue; } } + +.warning { + svg { + color: $orange-500; + } +} + +.error { + svg { + color: $red-500; + } +} diff --git a/apps/client/src/common/components/info/Info.tsx b/apps/client/src/common/components/info/Info.tsx index bb374182a..8153da6cc 100644 --- a/apps/client/src/common/components/info/Info.tsx +++ b/apps/client/src/common/components/info/Info.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren } from 'react'; -import { IoAlertCircle } from 'react-icons/io5'; +import { IoAlertCircle, IoWarning } from 'react-icons/io5'; import { cx } from '../../utils/styleUtils'; @@ -7,14 +7,15 @@ import style from './Info.module.scss'; interface InfoProps { className?: string; + type?: 'info' | 'warning' | 'error'; } -export default function Info(props: PropsWithChildren) { - const { className, children } = props; - +export default function Info({ className, type = 'info', children }: PropsWithChildren) { return ( -
- +
+ {type === 'info' && } + {type === 'warning' && } + {type === 'error' && }
{children}
); diff --git a/apps/client/src/common/components/input/auto-text-area/AutoTextArea.tsx b/apps/client/src/common/components/input/auto-text-area/AutoTextArea.tsx deleted file mode 100644 index 9fc23c8ed..000000000 --- a/apps/client/src/common/components/input/auto-text-area/AutoTextArea.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { RefObject, useEffect } from 'react'; -import { Textarea, TextareaProps } from '@chakra-ui/react'; -// @ts-expect-error no types from library -import autosize from 'autosize/dist/autosize'; - -export const AutoTextArea = (props: TextareaProps & { inputref: RefObject }) => { - const { value, inputref } = props; - - useEffect(() => { - const node = inputref.current; - autosize(inputref.current); - return () => { - autosize.destroy(node); - }; - }, [inputref, value]); - - return ( -