Compare commits

..

9 Commits

Author SHA1 Message Date
Carlos Valente 085e862be9 refactor: show electron dev tools 2024-10-06 21:32:29 +02:00
Carlos Valente 384a6298cf refactor: arrow used in new window navigation 2024-10-06 21:32:08 +02:00
Carlos Valente 0398db9945 refactor: navigate in electron 2024-10-06 21:21:16 +02:00
Carlos Valente 2560b0ca2a refactor: remove unused db 2024-10-06 20:34:58 +02:00
Carlos Valente 2e0c86c20a feat: open asset directories 2024-10-06 20:34:58 +02:00
Carlos Valente 61742d1549 refactor: collocate view navigation 2024-10-06 20:34:58 +02:00
Carlos Valente 220319be8a refactor: improve native menu links 2024-10-06 20:34:54 +02:00
Carlos Valente ea5de337cf feat: allow download of current project 2024-10-03 22:28:47 +02:00
Carlos Valente a08ea3fee9 refactor: create project form state from params 2024-10-03 22:28:47 +02:00
1000 changed files with 32327 additions and 48029 deletions
+7 -7
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac --env-mode=loose run: pnpm dist-mac
timeout-minutes: 60 timeout-minutes: 60
- name: Release - name: Release
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
-2
View File
@@ -28,7 +28,6 @@ test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots automated-screenshots
e2e/tests/fixtures/tmp/*
# production # production
build/ build/
@@ -40,7 +39,6 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+51
View File
@@ -0,0 +1,51 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1 +1 @@
v22.15.1 v20.15.1
+7 -7
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~22) - __node__ (~20)
- __pnpm__ (~10) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
@@ -45,7 +45,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils) Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `turbo run test:pipeline` from the project root. You can run unit tests by running turbo `turbo test:pipeline` from the project root.
This will run all tests and close test runner. This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -76,13 +76,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo run build:electron` - __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux` - __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist` The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues. Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally. Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
## DOCKER ## DOCKER
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime .` - __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
+4 -6
View File
@@ -1,13 +1,13 @@
FROM node:22-bullseye AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0 RUN npm install -g pnpm@9.5.0
COPY . /app COPY . /app
WORKDIR /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 --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine FROM node:20-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
@@ -23,8 +23,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/ 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 # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -34,4 +32,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps # !!! Note that this command needs pre-build versions of the UI and server apps
# 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:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
+10 -6
View File
@@ -2,7 +2,6 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli) ![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -13,11 +12,7 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat. We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
@@ -95,18 +90,27 @@ You can access the different views by reaching the ontime server, in your browse
For the backstage views For the backstage views
------------------------------------------------------------- -------------------------------------------------------------
IP.ADDRESS:4001/timer > Presenter / Stage timer view 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/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline 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 For production views
------------------------------------------------------------- -------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More information is available [in our docs](https://docs.getontime.no) More information is available [in our docs](https://docs.getontime.no)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0-alpha.0", "version": "3.6.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 -2
View File
@@ -15,12 +15,11 @@
], ],
"plugins": [ "plugins": [
"react", "react",
"react-compiler", "testing-library",
"simple-import-sort", "simple-import-sort",
"@tanstack/query" "@tanstack/query"
], ],
"rules": { "rules": {
"react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [ "react/jsx-no-bind": [
"error", "error",
{ {
+35 -26
View File
@@ -1,28 +1,37 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang='en'>
<head> <head>
<meta charset="utf-8" /> <meta charset='utf-8' />
<base href="/" /> <link rel='icon' href='/favicon.ico' />
<link rel="icon" href="/favicon.ico" /> <meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name='theme-color' content='#121212' />
<meta name="theme-color" content="#101010" /> <meta name='ontime' content='ontime - event timer manager' />
<meta name="ontime" content="ontime - time keeping for live events" /> <link rel='apple-touch-icon' href='/logo192.png' />
<link rel="apple-touch-icon" href="/ontime-logo.png" /> <link
<link rel="icon" type="image/png" href="/ontime-logo.png" /> rel='icon'
<link rel="manifest" href="/site.webmanifest" /> type='image/png'
<link rel="manifest" href="/manifest.json" /> sizes='32x32'
<meta name="robots" content="noindex" /> href='/favicon-32x32.png'
<title>ontime</title> />
<style> <link
body, rel='icon'
html { type='image/png'
background-color: #101010 !important; sizes='16x16'
} href='/favicon-16x16.png'
</style> />
</head> <link rel='manifest' href='/site.webmanifest' />
<body> <link rel='manifest' href='/manifest.json' />
<noscript>You need to enable JavaScript to run this app.</noscript> <title>ontime</title>
<div id="root"></div> <style>
<script type="module" src="/src/index.tsx"></script> body,
</body> html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html> </html>
+36 -32
View File
@@ -1,37 +1,36 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-alpha.0", "version": "3.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.1", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@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", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2", "@mantine/hooks": "^7.6.2",
"@sentry/react": "^8.43.0", "@react-icons/all-files": "^4.1.0",
"@table-nav/react": "^0.0.7", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.9.0", "axios": "^1.2.0",
"babel-plugin-react-compiler": "19.1.0-rc.2", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"prismjs": "^1.29.0", "framer-motion": "^10.10.0",
"react": "^19.1.0", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.49.2",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^4.5.2"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -40,7 +39,7 @@
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "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:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
@@ -63,29 +62,34 @@
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5", "@testing-library/jest-dom": "^5.16.5",
"@types/react": "^19.1.8", "@testing-library/react": "^13.1.1",
"@types/react-dom": "^19.1.6", "@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:", "eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "6.3.1", "vite": "^5.2.11",
"vite-plugin-compression2": "2.2.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "4.3.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "^1.6.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+9 -2
View File
@@ -4,11 +4,18 @@
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "ontime-logo.png", "src": "logo192.png",
"type": "image/png" "type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"scope": "/", "scope": "/",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+1 -10
View File
@@ -1,10 +1 @@
{ {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+9 -9
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { Tooltip } from '@base-ui-components/react/tooltip'; import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,18 +8,18 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket'; import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
function App() { function App() {
return ( return (
<QueryClientProvider client={ontimeQueryClient}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<AppContextProvider> <QueryClientProvider client={ontimeQueryClient}>
<Tooltip.Provider> <AppContextProvider>
<BrowserRouter basename={baseURI}> <BrowserRouter>
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
@@ -33,9 +33,9 @@ function App() {
<div id='identify-portal' /> <div id='identify-portal' />
</ErrorBoundary> </ErrorBoundary>
</BrowserRouter> </BrowserRouter>
</Tooltip.Provider> </AppContextProvider>
</AppContextProvider> </QueryClientProvider>
</QueryClientProvider> </ChakraProvider>
); );
} }
+60 -75
View File
@@ -1,41 +1,71 @@
import React from 'react'; import React from 'react';
import { Navigate, Route } from 'react-router-dom'; import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader'; import { ONTIME_VERSION } from './ONTIME_VERSION';
import { initializeSentry } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const StudioClock = React.lazy(() => import('./views/studio/Studio')); const Public = React.lazy(() => import('./features/viewers/public/Public'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown)); const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage)); const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline)); const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
// Initialize Sentry with our configuration Sentry.init({
const SentryRouter = initializeSentry(); dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() { export default function AppRouter() {
// handle client path changes // handle client path changes
@@ -43,68 +73,23 @@ export default function AppRouter() {
return ( return (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<SentryRouter> <SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route <Route path='/timer' element={<STimer />} />
path='/timer' <Route path='/public' element={<SPublic />} />
element={ <Route path='/minimal' element={<SMinimalTimer />} />
<ViewLoader> <Route path='/clock' element={<SClock />} />
<STimer />
</ViewLoader> <Route path='/countdown' element={<SCountdown />} />
} <Route path='/backstage' element={<SBackstage />} />
/> <Route path='/studio' element={<SStudio />} />
<Route <Route path='/lower' element={<SLowerThird />} />
path='/countdown' <Route path='/timeline' element={<STimeline />} />
element={
<ViewLoader>
<SCountdown />
</ViewLoader>
}
/>
<Route
path='/backstage'
element={
<ViewLoader>
<SBackstage />
</ViewLoader>
}
/>
<Route
path='/studio'
element={
<ViewLoader>
<SStudio />
</ViewLoader>
}
/>
<Route
path='/timeline'
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
<Route
path='/info'
element={
<ViewLoader>
<SProjectInfo />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route <Route path='/op' element={<Operator />} />
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -141,7 +126,7 @@ export default function AppRouter() {
/> />
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</SentryRouter> </SentryRoutes>
</React.Suspense> </React.Suspense>
); );
} }
-30
View File
@@ -1,30 +0,0 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
-85
View File
@@ -1,85 +0,0 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
@@ -1,5 +0,0 @@
import axios from 'axios';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
};
+21 -11
View File
@@ -1,26 +1,36 @@
import { serverURL } from '../../externals';
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project']; export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs // resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; export const projectDataURL = `${serverURL}/project`;
const cssOverridePath = 'styles/override.css'; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const stylesPath = 'external/styles/override.css';
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const overrideStylesURL = `${serverURL}/${stylesPath}`;
+5 -5
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
/** /**
* Edits single custom field * Edits single custom field
*/ */
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> { export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField }); const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> { export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`); const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data; return res.data;
} }
+14 -25
View File
@@ -1,8 +1,7 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -13,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
/** /**
@@ -40,12 +39,10 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data; const { project, rundown, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns); const sheetData = makeTable(project, rundown, customFields);
const sheetData = makeTable(project, flatRundowns, customFields); const fileContent = makeCSV(sheetData);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`); downloadBlob(blob, `${name}.csv`);
@@ -89,14 +86,6 @@ export async function createProject(
return res.data; return res.data;
} }
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
@@ -115,14 +104,6 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data; return res.data;
} }
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/** /**
* HTTP request to duplicate a project file * HTTP request to duplicate a project file
*/ */
@@ -157,6 +138,14 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
return res.data; return res.data;
} }
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
/** /**
* Utility function gets project from db * Utility function gets project from db
* @param fileName * @param fileName
+6 -5
View File
@@ -1,11 +1,16 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* 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
@@ -29,10 +34,6 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
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`, {
options, options,
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest); const res = await axios.get(`${apiRepoLatest}`);
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+1 -16
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -19,18 +19,3 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> { export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data); return axios.post(projectPath, data);
} }
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
-26
View File
@@ -1,26 +0,0 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
+19 -56
View File
@@ -1,45 +1,29 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
return res.data;
}
/** /**
* HTTP request to fetch all events * HTTP request to fetch all events
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/normalised`);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new entry * HTTP request to post new event
*/ */
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> { export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to edit an entry * HTTP request to put new event
*/ */
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -49,22 +33,22 @@ type BatchEditEntry = {
}; };
/** /**
* HTTP request to edit multiple events * HTTP request to put multiple events
*/ */
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
entryId: EntryId; eventId: string;
destinationId: EntryId; from: number;
order: 'before' | 'after' | 'insert'; to: number;
}; };
/** /**
* HTTP request to reorder an entry * HTTP request to reorder events
*/ */
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
@@ -83,36 +67,15 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/applydelay/${eventId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request to delete given event
*/ */
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.post(`${rundownPath}/clone/${entryId}`); return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
} }
/** /**
-27
View File
@@ -1,27 +0,0 @@
import axios from 'axios';
import { GetInfo } from 'ontime-types';
import { apiEntryUrl } from './constants';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
-7
View File
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> { export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data); return axios.post(settingsPath, data);
} }
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: Rundown; rundown: OntimeRundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
+2 -4
View File
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
/** /**
* Utility unwrap a potential axios error * Utility unrwap a potential axios error
* @param error * @param error
* @returns * @returns
*/ */
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
} }
} }
return `${statusText}: ${data}`; return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else { } else {
if (typeof error !== 'string') { if (typeof error !== 'string') {
return JSON.stringify(error); return JSON.stringify(error);
@@ -34,7 +32,7 @@ export function maybeAxiosError(error: unknown) {
} }
/** /**
* Utility unwraps a potential axios error and sends to logger * Utility unrwaps a potential axios error and sends to logger
* @param prepend * @param prepend
* @param error * @param error
*/ */
+6 -9
View File
@@ -1,24 +1,21 @@
import axios from 'axios'; import axios from 'axios';
import type { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const viewSettingsPath = `${apiEntryUrl}/view-settings`; const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/** /**
* HTTP request to fetch view settings * HTTP request to retrieve view settings
* @returns
*/ */
export async function getViewSettings(): Promise<ViewSettings> { export async function getView(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath); const res = await axios.get(viewSettingsPath);
return res.data; return res.data;
} }
/** /**
* HTTP request to update view settings * HTTP request to mutate view settings
* needs to update entire objects, not just a patch
*/ */
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> { export async function postViewSettings(data: ViewSettings) {
const res = await axios.post(viewSettingsPath, data); return axios.post(viewSettingsPath, data);
return res.data;
} }
@@ -0,0 +1,12 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
@@ -0,0 +1,26 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,84 +0,0 @@
@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%;
}
@@ -1,53 +0,0 @@
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<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([
style.baseButton,
style[variant],
style[size],
fluid && style.fluid,
loading && style.loading,
className,
])}
type='button'
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
@@ -1,44 +0,0 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseIconButton {
aspect-ratio: 1;
display: grid;
place-content: center;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
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;
}
@@ -1,37 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
},
);
IconButton.displayName = 'IconButton';
export default IconButton;
@@ -0,0 +1,17 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
</Tooltip>
);
}
@@ -1,50 +0,0 @@
.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;
}
}
@@ -1,14 +0,0 @@
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 (
<BaseCheckbox.Root className={style.checkbox} {...checkboxProps}>
<BaseCheckbox.Indicator className={style.indicator}>
<IoCheckmark className={style.icon} />
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
);
}
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -1,24 +0,0 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
@@ -1,110 +1,69 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import {
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; 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'; import style from './ClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name: string; name?: string;
currentPath: string; path?: string;
origin: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { export function RedirectClientModal(props: RedirectClientModalProps) {
const { data } = useUrlPresets(); const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote; const { setRedirect } = setClientRemote;
const handleRedirect = (newPath: string) => { const handleRedirect = () => {
if (newPath === '/' || newPath === currentPath) { if (path !== currentPath && path !== '') {
return; setRedirect({ target: id, redirect: path });
} }
setRedirect({ target: id, redirect: newPath });
onClose(); onClose();
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
const viewOptions = [
...navigatorConstants.map((view) => ({
value: `/${view.url}`,
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
onClose={onClose} <ModalContent>
showCloseButton <ModalHeader>Redirect: {name}</ModalHeader>
showBackdrop <ModalCloseButton />
title={`Redirect: ${name}`} <ModalBody>
bodyElements={ <InputGroup variant='ontime-filled' size='md'>
<> <InputLeftAddon>{host}</InputLeftAddon>
<Info> <Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
Remotely redirect the client to a different URL. <br /> </InputGroup>
Either by selecting a URL Preset or entering a custom path. </ModalBody>
<br /> <ModalFooter>
<br /> <div className={style.buttonSection}>
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink> <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Info> Cancel
<div> </Button>
<span className={style.label}>Select View or URL Preset</span> <Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
<div className={style.textEntry}> Submit
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect <IoArrowForward />
</Button> </Button>
</div> </div>
</> </ModalFooter>
} </ModalContent>
/> </Modal>
); );
} }
@@ -1,9 +1,19 @@
import { useState } from 'react'; import { useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import style from './ClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
@@ -12,7 +22,8 @@ interface RenameClientModalProps {
onClose: () => void; onClose: () => void;
} }
export function RenameClientModal({ id, name: currentName = '', isOpen, onClose }: RenameClientModalProps) { export function RenameClientModal(props: RenameClientModalProps) {
const { id, name: currentName = '', isOpen, onClose } = props;
const [name, setName] = useState(currentName); const [name, setName] = useState(currentName);
const { setClientName } = setClientRemote; const { setClientName } = setClientRemote;
@@ -27,24 +38,31 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
const canSubmit = name !== currentName && name !== ''; const canSubmit = name !== currentName && name !== '';
return ( return (
<Dialog <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
title={`Rename Client: ${currentName}`} <ModalContent>
showCloseButton <ModalHeader>Rename: {currentName}</ModalHeader>
onClose={onClose} <ModalCloseButton />
bodyElements={ <ModalBody>
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} /> <Input
} variant='ontime-filled'
footerElements={ size='md'
<> placeholder='new name'
<Button variant='subtle' size='large' onClick={onClose}> value={name}
Cancel onChange={(event) => setName(event.target.value)}
</Button> />
<Button variant='primary' size='large' onClick={handleRename} disabled={!canSubmit}> </ModalBody>
Submit <ModalFooter>
</Button> <div className={style.buttonSection}>
</> <Button size='md' variant='ontime-subtle' onClick={onClose}>
} Cancel
/> </Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
); );
} }
@@ -0,0 +1,8 @@
.contextMenuButton {
position: absolute;
}
.contextMenuBackdrop {
position: fixed;
inset: 0;
}
@@ -0,0 +1,99 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
};
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</Menu>
</>
);
};
@@ -0,0 +1,12 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -1,40 +0,0 @@
.copytag {
display: flex;
align-items: center;
}
.action {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.label {
border-radius: 3px 0 0 3px;
border: 1px solid $gray-1200;
background-color: $white-3;
padding-inline: 0.5rem 1rem;
line-height: 1em;
display: flex;
align-items: center;
color: $label-gray;
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.copy {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -1,57 +1,38 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren } from 'react';
import { IoCheckmark } from 'react-icons/io5'; import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCopy } from 'react-icons/io5'; import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import style from './CopyTag.module.scss';
interface CopyTagProps { interface CopyTagProps {
copyValue: string; label: string;
className?: string;
size?: Size;
disabled?: boolean; disabled?: boolean;
onClick?: () => void;
size?: 'small' | 'medium' | 'large';
} }
export default function CopyTag({ export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
copyValue, const { label, className, size = 'xs', disabled, children } = props;
disabled,
size = 'medium',
children,
onClick,
}: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false);
const handleClick = () => { const handleClick = () => copyToClipboard(children as string);
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
};
return ( return (
<div className={style.copytag}> <Tooltip label={label} openDelay={tooltipDelayFast}>
{onClick !== undefined ? ( <ButtonGroup size={size} isAttached className={className}>
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}> <Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children} {children}
</Button> </Button>
) : ( <IconButton
<div className={cx([style.label, style[size]])}>{children}</div> aria-label={label}
)} icon={<IoCopy />}
<IconButton variant='ontime-filled'
className={style.copy} tabIndex={-1}
variant='primary' onClick={handleClick}
size={size} isDisabled={disabled}
tabIndex={-1} />
onClick={handleClick} </ButtonGroup>
disabled={disabled} </Tooltip>
>
{copied ? <IoCheckmark /> : <IoCopy />}
</IconButton>
</div>
); );
} }
@@ -1,29 +1,40 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5'; import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig'; import { millisToDelayString } from '../../utils/dateConfig';
import Tooltip from '../tooltip/Tooltip';
import style from './DelayIndicator.module.scss'; import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps { interface DelayIndicatorProps {
delayValue?: number; delayValue?: number;
tooltipPrefix?: string;
} }
export default function DelayIndicator(props: DelayIndicatorProps) { export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue, tooltipPrefix } = props; const { delayValue } = props;
if (typeof delayValue !== 'number' || delayValue === 0) { if (typeof delayValue === 'number') {
return null; if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
} }
const delayString = tooltipPrefix return null;
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip text={delayString} render={<span />} className={style.delaySymbol}>
{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}
</Tooltip>
);
} }
@@ -1,55 +0,0 @@
.dialog {
position: fixed;
top: 10%;
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(420px, 90vw);
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
}
.backdrop {
position: fixed;
inset: 0;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: min(80vh, 600px);
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -1,52 +0,0 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton';
import style from './Dialog.module.scss';
interface DialogProps {
isOpen: boolean;
title: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements: ReactNode;
onClose: () => void;
}
export default function Dialog({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: DialogProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.dialog}>
<div className={style.title}>
{title}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
</IconButton>
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
@@ -1,70 +0,0 @@
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
padding-block: 0.25rem;
color: $ui-white;
background-color: $gray-1250;
font-size: calc(1rem - 2px);
border-radius: 3px;
border: 1px solid $gray-1000;
box-shadow: $box-shadow-l1;
outline: none;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
svg {
color: $gray-500;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&[data-highlighted] {
z-index: 0;
position: relative;
}
&[data-highlighted]:not([data-disabled])::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 3px;
background-color: $gray-1000;
}
}
.separator {
margin: 0.25rem 0.75rem;
height: 1px;
background-color: $white-7;
}
@@ -1,82 +0,0 @@
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
label: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
items: DropdownMenuOption[];
}
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
return (
<BaseMenu.Root>
<BaseMenu.Trigger {...triggerProps}>{children}</BaseMenu.Trigger>
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
interface PositionedDropdownMenuProps {
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
isOpen: boolean;
onClose: () => void;
position: { x: number; y: number };
}
export function PositionedDropdownMenu({ items, isOpen, position, onClose }: PositionedDropdownMenuProps) {
return (
<BaseMenu.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
@@ -1,56 +0,0 @@
.corner {
transform: rotate(45deg);
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
border-radius: $component-border-radius-full;
// similar styles to ontime-button-subtle
background-color: $gray-1050;
outline: 2px solid $gray-1050;
&:hover {
color: $ontime-color;
background-color: $gray-1000;
outline: 2px solid $gray-1000;
}
}
.header {
font-size: 1.5rem;
}
.title {
font-size: 1rem;
color: $title-gray;
display: flex;
align-items: center;
justify-content: space-between;
}
.label {
display: block;
font-size: $aux-text-size;
color: $label-gray;
margin-bottom: $element-inner-spacing;
max-width: max-content; // prevent label from taking entire row
}
.separator {
background-color: $border-color-ondark;
&.horizontal {
width: 100%;
height: 1px;
}
&.vertical {
width: 1px;
height: 0.75em;
}
}
@@ -1,37 +0,0 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
}
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
const classes = cx([style.title, className]);
return (
<h3 className={classes} {...elementProps}>
{children}
</h3>
);
}
export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) {
const classes = cx([style.label, className]);
return (
<label className={classes} {...elementProps}>
{children}
</label>
);
}
interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical';
}
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
}
@@ -3,8 +3,8 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket'; import { runtimeStore } from '@/common/stores/runtime';
import { runtimeStore } from '../../stores/runtime'; import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -26,22 +26,17 @@ class ErrorBoundary extends React.Component {
error, error,
errorInfo: info, errorInfo: info,
}); });
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: runtimeStore.getState(),
hasSocket: { hasConnected, reconnectAttempts }, hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
this.reportContent = `${error} ${info.componentStack}`;
} }
render() { render() {
@@ -7,7 +7,6 @@
transition-property: color; transition-property: color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
width: fit-content; width: fit-content;
text-decoration: none;
&.inline { &.inline {
display: inline-flex; display: inline-flex;
@@ -16,9 +15,4 @@
&:hover { &:hover {
color: $ontime-color; color: $ontime-color;
} }
}
&:focus {
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
@@ -1,8 +1,8 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss'; import style from './ExternalLink.module.scss';
@@ -14,6 +14,7 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) { export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props; const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
@@ -21,14 +22,8 @@ export default function ExternalLink(props: ExternalLinkProps) {
}; };
return ( return (
<a <a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
href='#!' {children} <IoOpenOutline />
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a> </a>
); );
} }
@@ -13,13 +13,8 @@ interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
max?: number; // inclusive max?: number; // inclusive
} }
export function FitText({ export function FitText(props: PropsWithChildren<FitTextProps>) {
children, const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
mode = 'multi',
min = 16,
max = 256,
...elementProps
}: PropsWithChildren<FitTextProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => { const isOverflown = useCallback(() => {
@@ -1,6 +0,0 @@
import { IconBaseProps } from 'react-icons';
import { IoLink } from 'react-icons/io5';
export default function RotatedLink(linkProps: IconBaseProps) {
return <IoLink style={{ transform: 'rotate(-45deg)' }} {...linkProps} />;
}
@@ -15,6 +15,7 @@
display: grid; display: grid;
place-content: center; place-content: center;
text-align: center; text-align: center;
z-index: 100;
cursor: pointer; cursor: pointer;
} }
@@ -1,35 +0,0 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
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;
}
}
@@ -1,22 +0,0 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle, IoWarning } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
type?: 'info' | 'warning' | 'error';
}
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
return (
<div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div>
</div>
);
}
@@ -0,0 +1,28 @@
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<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
transition='height none'
variant='ontime-transparent'
{...props}
/>
);
};
@@ -1,26 +0,0 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
import Textarea, { type TextareaProps } from '../textarea/Textarea';
interface AutoTextAreaProps extends TextareaProps {
inputref: RefObject<HTMLTextAreaElement | null>;
}
/**
* A textarea that automatically resizes based on its content
*/
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
// when the value changes, we use the ref to reapply autosize
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
}
@@ -1,4 +1,4 @@
import { IoBan } from 'react-icons/io5'; import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => { const handleClick = () => {
onClick?.(color); onClick?.(color);
}; };
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]); const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) { if (!color) {
return ( return (
<div className={classes} onClick={handleClick}> <div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan /> <IoBan />
</div> </div>
); );
@@ -1,56 +0,0 @@
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
onChange(newValue);
}, 100);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,18 +1,15 @@
.list { .list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem
} }
.swatch { .swatch {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
aspect-ratio: 1;
border-radius: 99px; border-radius: 99px;
border: 2px solid $gray-1200; border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected { &.selected {
border: 2px solid $blue-500; border: 2px solid $blue-500;
@@ -20,9 +17,11 @@
&.selectable { &.selectable {
cursor: pointer; cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
} }
} }
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,7 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import Swatch from './Swatch'; import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => ( {colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} /> <Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))} ))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div> </div>
); );
} }
@@ -1,62 +0,0 @@
.radioGroup {
color: $gray-900;
font-size: calc(1rem - 3px);
color: $label-gray;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2em;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 0.75rem;
height: 0.75rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
background-color: $gray-1200;
}
&[data-checked] {
background-color: $gray-1200;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5em;
height: 0.5em;
background-color: $blue-500;
}
}
@@ -1,35 +0,0 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss';
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
}
export default function BlockRadio<T extends string | number | boolean>({
items,
onValueChange,
...elementProps
}: BlockRadioProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={style.radioGroup}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -1,12 +1,22 @@
$input-font-size: 15px;
.delayInput { .delayInput {
display: flex; display: flex;
gap: $element-spacing; gap: $element-spacing;
align-items: center; align-items: center;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
} }
.inputField { .delayOptions {
text-align: center; display: flex;
letter-spacing: 1px; flex-direction: column;
max-width: 7em;
color: $ontime-delay-text
} }
@@ -1,10 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction'; import { useEventAction } from '../../../hooks/useEventAction';
import Input from '../input/Input';
import BlockRadio from './BlockRadio';
import style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -15,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) { export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props; const { eventId, duration } = props;
const { updateEntry } = useEntryActions(); const { updateEvent } = useEventAction();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -56,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
}; };
const submitChange = (value: number) => { const submitChange = (value: number) => {
updateEntry({ updateEvent({
id: eventId, id: eventId,
duration: value, duration: value,
}); });
@@ -107,10 +105,13 @@ export default function DelayInput(props: DelayInputProps) {
return ( return (
<div className={style.delayInput}> <div className={style.delayInput}>
<Input <Input
size='sm'
ref={inputRef} ref={inputRef}
data-testid='delay-input' data-testid='delay-input'
className={style.inputField} className={style.inputField}
type='text'
placeholder='-' placeholder='-'
variant='ontime-filled'
onFocus={handleFocus} onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)} onBlur={(event) => validateAndSubmit(event.target.value)}
@@ -118,14 +119,16 @@ export default function DelayInput(props: DelayInputProps) {
value={value} value={value}
maxLength={9} maxLength={9}
/> />
<BlockRadio <RadioGroup
onValueChange={handleSlipChange} className={style.delayOptions}
onChange={handleSlipChange}
value={checkedOption} value={checkedOption}
items={[ variant='ontime-block'
{ value: 'add', label: 'Add time' }, size='sm'
{ value: 'subtract', label: 'Subtract time' }, >
]} <Radio value='add'>Add time</Radio>
/> <Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
</div> </div>
); );
} }
@@ -1,52 +0,0 @@
.input {
box-sizing: border-box;
font-size: 1rem;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
}
.ghosted {
background-color: transparent;
padding: 0;
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
@@ -1,30 +0,0 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Input.module.scss';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'subtle' | 'ghosted';
height?: 'medium' | 'large';
fluid?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, variant = 'subtle', height = 'medium', fluid, ...inputProps },
ref,
) {
return (
<input
ref={ref}
type='text'
autoCorrect='off'
autoComplete='off'
spellCheck='false'
className={cx([style.input, style[variant], style[height], fluid && style.fluid, className])}
{...inputProps}
/>
);
});
export default Input;
@@ -1,36 +1,16 @@
// styles from Input.module.scss .swatch {
.input { width: 24px;
width: 100%; height: 24px;
margin-top: 0.25rem; border-radius: 12px;
box-sizing: border-box; border: 1px solid white;
box-shadow: 0 0 0 1px $gray-300;
cursor: pointer;
font-size: 1rem; transition-property: box-shadow;
font-weight: 400; transition-duration: $transition-time-action;
color: $gray-200;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
border: 1px solid transparent;
height: 2rem;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
} }
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,24 +1,35 @@
import { PropsWithChildren } from 'react'; import { HexAlphaColorPicker } from 'react-colorful';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { useController, UseControllerProps } from 'react-hook-form';
import { Popover } from '@base-ui-components/react/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import PopoverContents from '../../popover/Popover';
import style from './PopoverPicker.module.scss'; import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps { interface PopoverPickerProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
} }
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) { export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange } = props;
return ( return (
<Popover.Root> <Popover>
<Popover.Trigger>{children}</Popover.Trigger> <PopoverTrigger>
<PopoverContents> <div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} /> <HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed /> </PopoverContent>
</PopoverContents> </Popover>
</Popover.Root>
); );
} }
@@ -1,4 +1,4 @@
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks'; import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
@@ -11,18 +11,13 @@ interface UseReactiveTextInputReturn {
export default function useReactiveTextInput( export default function useReactiveTextInput(
initialText: string, initialText: string,
submitCallback: (newValue: string) => void, submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>, ref: RefObject<HTMLInputElement>,
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -52,25 +47,16 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback( const handleSubmit = useCallback(
(valueToSubmit: string) => { (valueToSubmit: string) => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) { if (valueToSubmit !== initialText) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim(); const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal); submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) { if (cleanVal !== valueToSubmit) {
setText(cleanVal); setText(cleanVal);
} }
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
}, },
[initialText, options, ref, submitCallback], [initialText, ref, submitCallback],
); );
/** /**
@@ -80,79 +66,26 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
if (ref.current) { }, [initialText, ref]);
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [ const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push([ hotKeys.push(['Enter', () => handleSubmit(text)]);
'Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
} }
if (options?.submitOnCtrlEnter) { if (options?.submitOnCtrlEnter) {
hotKeys.push([ hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
'mod + Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
} }
return getHotkeyHandler(hotKeys);
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
value: text, value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value), onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => { onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
}
},
onKeyDown: keyHandler, onKeyDown: keyHandler,
}; };
} }
@@ -1,48 +0,0 @@
.textarea {
box-sizing: border-box;
min-height: 2rem;
display: block;
font-size: 1rem;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-top: 0.5em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.5em;
}
.fluid {
width: 100%;
}
@@ -1,31 +0,0 @@
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Textarea.module.scss';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
variant?: 'subtle' | 'ghosted';
fluid?: boolean;
resize?: 'none' | 'both' | 'horizontal' | 'vertical';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextArea(
{ className, variant = 'subtle', fluid, rows = 5, resize = 'none', style: customStyle, ...textareaProps },
ref,
) {
return (
<textarea
ref={ref}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
rows={rows}
style={{ ...customStyle, resize }}
className={cx([style.textarea, style[variant], fluid && style.fluid, className])}
{...textareaProps}
/>
);
});
export default Textarea;
@@ -1,147 +0,0 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
/**
* Similar to TimeInput, but allows clearing the time value
*/
export default function NullableTimeInput<T extends string>({
id,
name,
submitHandler,
time,
emptyDisplay,
placeholder,
disabled,
align = 'center',
className,
}: NullableTimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue(emptyDisplay);
} else {
setValue(millisToString(time));
}
}, [emptyDisplay, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue: string) => {
// skip if user deleted and time is already null
if (newValue === '' && time === null) {
return false;
}
// skip if the value evaluates to the same thing
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
resetValue();
}
},
[handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
}
if (event.key === 'Escape') {
ignoreChange.current = true;
inputRef.current?.blur();
resetValue();
}
},
[resetValue],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
if (ignoreChange.current) {
ignoreChange.current = false;
return;
}
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
);
useEffect(() => {
resetValue();
}, [resetValue]);
return (
<Input
id={id}
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
/>
);
}
@@ -1,10 +1,4 @@
.timeInput { .timeInput {
width: 100%; letter-spacing: 1px;
max-width: 7.5em; width: 6.5em;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
&.delayed {
border: 1px solid $ontime-delay-text;
}
} }
@@ -1,34 +1,22 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils'; import { useEmitLog } from '../../../stores/logger';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> { interface TimeInputProps<T extends string> {
id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: string) => void;
time?: number; time?: number;
placeholder?: string; placeholder: string;
disabled?: boolean; disabled?: boolean;
align?: 'left' | 'center'; align?: 'left' | 'center';
delayed?: boolean;
className?: string; className?: string;
} }
export default function TimeInput<T extends string>({ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
id, const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
name, const { emitError } = useEmitLog();
submitHandler,
time,
placeholder,
disabled,
align = 'center',
delayed,
className,
}: TimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false); const ignoreChange = useRef(false);
@@ -37,12 +25,13 @@ export default function TimeInput<T extends string>({
* @description Resets input value to given * @description Resets input value to given
*/ */
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) { try {
setValue('00:00:00');
} else {
setValue(millisToString(time)); setValue(millisToString(time));
} catch (error) {
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
} }
}, [time]); }, [emitError, time]);
/** /**
* @description Selects input text on focus * @description Selects input text on focus
@@ -65,7 +54,6 @@ export default function TimeInput<T extends string>({
// we dont know the values in the rundown, escalate to handler // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) { if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue); submitHandler(name, newValue);
return true;
} }
const valueInMillis = parseUserTime(newValue); const valueInMillis = parseUserTime(newValue);
@@ -123,26 +111,31 @@ export default function TimeInput<T extends string>({
); );
useEffect(() => { useEffect(() => {
if (time == null) return;
resetValue(); resetValue();
}, [resetValue]); }, [resetValue, time]);
return ( return (
<Input <Input
id={id}
disabled={disabled} disabled={disabled}
size='sm'
ref={inputRef} ref={inputRef}
data-testid={`time-input-${name}`} data-testid={`time-input-${name}`}
className={cx([style.timeInput, delayed && style.delayed, className])} className={className}
fontSize='1rem'
type='text'
placeholder={placeholder} placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus} onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler} onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
value={value} value={value}
maxLength={8} maxLength={8}
style={{ maxWidth='7.5em'
textAlign: align, letterSpacing='1px'
}} autoComplete='off'
textAlign={align}
/> />
); );
} }
@@ -0,0 +1,8 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}

Some files were not shown because too many files have changed in this diff Show More