Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente e6e1c38355 refactor: UI indexes are 1 based 2025-03-28 22:18:22 +01:00
698 changed files with 22971 additions and 29622 deletions
+6 -6
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
@@ -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
+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
+2 -2
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
+3 -3
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
+8 -1
View File
@@ -103,12 +103,19 @@ 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": "3.15.2", "version": "3.14.3",
"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",
-2
View File
@@ -15,12 +15,10 @@
], ],
"plugins": [ "plugins": [
"react", "react",
"react-compiler",
"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",
{ {
+13 -16
View File
@@ -1,29 +1,28 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.15.2", "version": "3.14.3",
"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", "@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/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^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.13.3",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.9.0", "axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -32,7 +31,6 @@
"react-icons": "5.4.0", "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": "^5.0.3"
}, },
@@ -66,18 +64,17 @@
"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", "@types/color": "^3.0.3",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@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": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2", "happy-dom": "^16.7.2",
@@ -86,10 +83,10 @@
"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": "1.4.0", "vite-plugin-compression2": "^1.3.3",
"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": "catalog:"
} }
} }
+13 -3
View File
@@ -18,19 +18,20 @@ import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION'; import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } 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('./views/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('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./views/studio/Studio')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
@@ -39,6 +40,7 @@ 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(withData(ProjectInfo)); const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); 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));
@@ -86,6 +88,14 @@ export default function AppRouter() {
</ViewLoader> </ViewLoader>
} }
/> />
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route <Route
path='/minimal' path='/minimal'
element={ element={
-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;
}
@@ -1,5 +0,0 @@
import axios from 'axios';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
};
+6 -3
View File
@@ -8,9 +8,9 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
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'];
@@ -19,8 +19,11 @@ export const REPORT = ['report'];
// API URLs // API URLs
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 userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+10 -17
View File
@@ -1,45 +1,38 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields, sorted by order * Requests list of known custom fields
*/ */
export async function getCustomFields(): Promise<CustomFieldWithKey[]> { export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath); const res = await axios.get(customFieldsPath);
return res.data; return res.data;
} }
/** /**
* Sets list of known custom fields * Sets list of known custom fields
* Returns the updated list, sorted by order
*/ */
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> { export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField }); const res = await axios.post(customFieldsPath, { ...newField });
return res.data; return res.data;
} }
/** /**
* Edits single custom field * Edits single custom field
* Returns the updated list, sorted by order
*/ */
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> { export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
// Ensure newField can include 'order' by using Partial<CustomField> const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
* Returns the updated list, sorted by order
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> { export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`); const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data; return res.data;
} }
+3 -5
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv'; import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,11 +40,9 @@ 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, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+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,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl); const res = await axios.get(`${reportUrl}/`);
return res.data; return res.data;
} }
+19 -56
View File
@@ -1,45 +1,29 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } 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: TransientEventPayload): 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 } });
} }
/** /**
+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 -2
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
*/ */
@@ -34,7 +34,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
*/ */
+11 -5
View File
@@ -1,15 +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';
export async function getViewSettings() { const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to retrieve view settings
*/
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 mutate view settings
*/
export async function postViewSettings(data: ViewSettings) { export async function postViewSettings(data: ViewSettings) {
const res = await axios.post(viewSettingsPath, data); return axios.post(viewSettingsPath, data);
return res.data as ViewSettings;
} }
@@ -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,39 +1,26 @@
@use '../../../theme/viewerDefs' as *; .subtle {
@import './BaseButtonStyles.module.scss';
.baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
height: 2rem;
height: 2rem;
display: grid; display: grid;
place-content: center; place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $component-border-radius-md; border-radius: 3px;
cursor: pointer; &:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled { &:disabled {
opacity: 0.4; background: $gray-1050;
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;
}
@@ -4,32 +4,10 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
variant?: const { className, children, ...buttonProps } = props;
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export default function IconButton({
className,
children,
variant = 'subtle',
size = 'medium',
...buttonProps
}: IconButtonProps) {
return ( return (
<button <button className={cx([style.subtle, className])} {...buttonProps}>
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children} {children}
</button> </button>
); );
@@ -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>
);
}
@@ -1,15 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; 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 './RedirectClientModal.module.scss';
@@ -22,7 +29,8 @@ interface RedirectClientModalProps {
onClose: () => void; onClose: () => void;
} }
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets(); const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/'); const [selected, setSelected] = useState('/');
@@ -39,26 +47,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
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 maxWidth='max(480px, 35vw)'>
showCloseButton <ModalHeader>Redirect: {name}</ModalHeader>
showBackdrop <ModalCloseButton />
title={`Redirect: ${name}`} <ModalBody>
bodyElements={
<>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path. Either by selecting a URL Preset or entering a custom path.
@@ -70,21 +65,36 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<span className={style.label}>Select View or URL Preset</span> <span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}> <div className={style.textEntry}>
<Select <Select
fluid size='md'
options={viewOptions} variant='ontime'
defaultValue={viewOptions[0].value} isDisabled={enabledPresets.length === 0}
onValueChange={(value) => setSelected(value)} onChange={(event) => setSelected(event.target.value)}
disabled={enabledPresets.length === 0} >
/> <option value='/'>Select view or preset</option>
<Button {navigatorConstants.map((view) => {
variant='primary' return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset' aria-label='Redirect to preset'
className={style.redirect} className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'} icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)} onClick={() => handleRedirect(selected)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</div> </div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
@@ -92,24 +102,25 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} {origin}
<Input <Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff' placeholder='eg. /minimal?key=0000ffff'
fluid
value={path} value={path}
onChange={(event) => setPath(event.target.value)} onChange={(event) => setPath(event.target.value)}
/> />
</label> </label>
<Button <IconButton
variant='primary' variant='ontime-filled'
size='md'
aria-label='Redirect' aria-label='Redirect'
disabled={path === currentPath || path === ''} isDisabled={path === currentPath || path === ''}
className={style.redirect} className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</> </ModalBody>
} </ModalContent>
/> </Modal>
); );
} }
@@ -1,9 +1,17 @@
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';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
@@ -12,7 +20,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 +36,29 @@ 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}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button variant='primary' size='large' onClick={handleRename} disabled={!canSubmit}> <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit Submit
</Button> </Button>
</> </ModalFooter>
} </ModalContent>
/> </Modal>
); );
} }
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean; withDivider?: boolean;
}; };
type OptionWithGroup = { export type OptionWithGroup = {
label: string; label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[]; group: Omit<OptionWithoutGroup, 'isGroup'>[];
}; };
@@ -1,57 +0,0 @@
.dialog {
position: fixed;
top: 10%;
left: 50%;
z-index: $zindex-dialog;
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;
z-index: $zindex-backdrop;
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>
);
}
@@ -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';
@@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: appState,
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 });
@@ -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,7 +15,7 @@
display: grid; display: grid;
place-content: center; place-content: center;
text-align: center; text-align: center;
z-index: $zindex-modal; z-index: 100;
cursor: pointer; cursor: pointer;
} }
@@ -7,29 +7,15 @@
border-radius: 3px; border-radius: 3px;
font-size: $text-body-size; font-size: $text-body-size;
padding: 1rem; padding: 1rem;
color: $gray-200;
.content {
color: $gray-200;
}
svg { svg {
min-width: 1.5rem; min-width: 1.5rem;
align-self: start; align-self: start;
font-size: 1.5rem; font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue; color: $info-blue;
} }
} }
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle, IoWarning } from 'react-icons/io5'; import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -7,15 +7,14 @@ import style from './Info.module.scss';
interface InfoProps { interface InfoProps {
className?: string; className?: string;
type?: 'info' | 'warning' | 'error';
} }
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) { export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return ( return (
<div className={cx([style.infoLabel, style[type], className])}> <div className={cx([style.infoLabel, className])}>
{type === 'info' && <IoAlertCircle />} <IoAlertCircle />
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div> <div>{children}</div>
</div> </div>
); );
@@ -9,7 +9,7 @@ $input-font-size: 15px;
.inputField { .inputField {
font-size: $input-font-size; font-size: $input-font-size;
letter-spacing: 0.5px; letter-spacing: 1px;
max-width: 7em; max-width: 7em;
padding-left: 16px; padding-left: 16px;
color: $ontime-delay-text color: $ontime-delay-text
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/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 style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -13,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);
@@ -54,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,
}); });
@@ -1,52 +0,0 @@
.input {
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
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,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 {
@@ -21,8 +21,6 @@ export default function useReactiveTextInput(
}, },
): 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') {
@@ -101,25 +99,11 @@ export default function useReactiveTextInput(
]; ];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => { hotKeys.push(['Enter', () => handleSubmit(text)]);
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(['mod + Enter', () => { hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
} }
const hotKeyHandler = getHotkeyHandler(hotKeys); const hotKeyHandler = getHotkeyHandler(hotKeys);
@@ -142,11 +126,7 @@ export default function useReactiveTextInput(
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,47 +0,0 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
display: block;
font-size: 1rem;
font-weight: 400;
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;
}
.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,135 +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;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
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,6 +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;
} }
@@ -1,24 +1,23 @@
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; 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';
className?: string; className?: string;
} }
export default function TimeInput<T extends string>(props: TimeInputProps<T>) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props; const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false); const ignoreChange = useRef(false);
@@ -27,12 +26,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
* @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
@@ -113,27 +113,32 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
); );
useEffect(() => { useEffect(() => {
if (time == null) return;
resetValue(); resetValue();
}, [resetValue]); }, [resetValue, time]);
return ( return (
<Input <Input
id={id} 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, 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}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off' autoComplete='off'
style={{ textAlign={align}
textAlign: align,
}}
/> />
); );
} }
@@ -3,9 +3,8 @@
font-size: $text-body-size; font-size: $text-body-size;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
color: $blue-400;
&:hover { &:hover {
color: $blue-500; color: $blue-400;
} }
} }
@@ -0,0 +1,45 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -0,0 +1,9 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,56 +0,0 @@
.modal {
position: fixed;
top: 10vh;
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(680px, 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: 60vh;
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -1,53 +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 './Modal.module.scss';
interface ModalProps {
isOpen: boolean;
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
export default function Modal({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: ModalProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.modal}>
<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>
);
}
@@ -0,0 +1,38 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,68 +1,118 @@
.separator { @use '../../../theme/mixins' as *;
border-color: $border-color-ondark;
}
.backdrop { $menu-hover-bg: $gray-1350;
position: fixed; $menu-focus-bg: $gray-1300;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style], $icon-color: $ui-white;
&[data-ending-style] { $button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0; opacity: 0;
} }
} }
.drawer { .lockIcon {
box-sizing: border-box; font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed; position: fixed;
top: 0;
left: 0; left: 0;
bottom: 0; top: 0;
z-index: $zindex-dialog; z-index: 12;
}
width: 22rem;
height: 100vh;
.buttonContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem; row-gap: 1rem;
padding: 0.5em;
background-color: $gray-1250; position: fixed;
color: $ui-white; left: 0;
border-right: 1px solid $gray-1100; top: 0;
z-index: 12;
}
&[data-open] { .button {
transform: translateX(0%); font-size: 1.5rem;
transition: transform 300ms cubic-bezier(0.45, 1.005, 0, 1.005); color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size;
display: grid;
place-content: center;
border-radius: 3px;
}
.navButton {
@extend .button;
z-index: 3;
}
.link {
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
} }
&[data-starting-style], &:active {
&[data-ending-style] { background-color: $border-color-ondark;
transform: translateX(-100%);
transition: transform 500ms cubic-bezier(0.45, 1.005, 0, 1.005);
} }
// take the whole screen in mobile devices &:focus {
@media (max-width: $min-tablet) { outline: none;
width: 100vw; background-color: $menu-focus-bg;
border-left: 2px solid $action-text-color;
}
&.current {
background-color: $menu-hover-bg;
border-left: 4px solid $action-text-color;
} }
} }
.header { .linkIcon {
margin-left: auto;
@include rotate-fourty-five;
}
.separator {
border-color: $border-color-ondark;
}
.sectionHeader {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.bottom {
margin-top: auto;
margin-bottom: 1rem;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
justify-content: space-between; gap: 0.5rem;
height: 3.5rem;
padding-inline: 1.5rem;
font-weight: 600;
font-size: 1.25rem;
} }
.body { .goIcon {
flex: 1; @include rotate-fourty-five;
overflow-y: auto; margin-left: 0.25rem;
margin-bottom: 0.25rem;
} }
@@ -1,20 +1,29 @@
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom'; import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Dialog } from '@base-ui-components/react/dialog'; import { Link, useLocation } from 'react-router-dom';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -23,49 +32,74 @@ interface NavigationMenuProps {
onClose: () => void; onClose: () => void;
} }
export default memo(NavigationMenu); function NavigationMenu(props: NavigationMenuProps) {
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { const { isOpen, onClose } = props;
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const [isRenameOpen, handlers] = useDisclosure(false); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
return ( return createPortal(
<Dialog.Root <div id='navigation-menu-portal'>
open={isOpen} <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
onOpenChange={(open) => { <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
if (!open) { <DrawerOverlay />
onClose(); <DrawerContent maxWidth='22rem'>
} <DrawerHeader>
}} <DrawerCloseButton size='lg' />
> Ontime
<Dialog.Portal> </DrawerHeader>
<Dialog.Backdrop className={style.backdrop} /> <DrawerBody padding={0}>
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <div className={style.buttonsContainer}>
<Dialog.Popup className={style.drawer}> <div
<div className={style.header}> className={cx([style.link, fullscreen && style.current])}
<Dialog.Title>Ontime</Dialog.Title> tabIndex={0}
<IconButton variant='subtle-white' size='large' onClick={onClose}> role='button'
<IoClose /> onClick={toggle}
</IconButton> onKeyDown={(event) => {
</div> isKeyEnter(event) && toggle();
<div className={style.body}> }}
<NavigationMenuItem active={fullscreen} onClick={toggle}> >
Toggle Fullscreen Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem> </div>
<NavigationMenuItem active={mirror} onClick={toggleMirror}> <div
Flip Screen className={cx([style.link, mirror && style.current])}
<IoSwapVertical /> tabIndex={0}
</NavigationMenuItem> role='button'
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem> onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} /> <hr className={style.separator} />
<Link
<EditorNavigation /> to='/editor'
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
</Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
@@ -74,23 +108,86 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
</div> {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody>
{isLocalhost && ( </DrawerContent>
<div> </Drawer>
<OtherAddresses currentLocation={location.pathname} /> </div>,
</div> document.body,
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
); );
} }
interface OtherAddressesProps {
currentLocation: string;
}
function OtherAddresses(props: OtherAddressesProps) {
const { currentLocation } = props;
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.bottom}>
<div className={style.sectionHeader}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</div>
);
}
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu);
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -1,9 +1,9 @@
import { IoLockClosedOutline } from 'react-icons/io5'; import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './ViewLockedIcon.module.scss'; import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() { export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity(); const isLockIconShown = useFadeOutOnInactivity();
@@ -1,39 +1,22 @@
import { memo } from 'react'; import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor'; import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps { interface ViewNavigationMenuProps {
isLockable?: boolean; isLockable?: boolean;
supressSettings?: boolean;
} }
export default memo(ViewNavigationMenu); function ViewNavigationMenu(props: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) { const { isLockable } = props;
const [isMenuOpen, menuHandler] = useDisclosure();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable }); const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
useHotkeys([ const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
[
'Space',
() => {
if (isViewLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
[
'mod + ,',
() => {
if (isViewLocked || supressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
],
]);
if (isViewLocked) { if (isViewLocked) {
return <ViewLockedIcon />; return <ViewLockedIcon />;
@@ -41,11 +24,10 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
toggleMenu={menuHandler.toggle} <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
</> </>
); );
} }
export default memo(ViewNavigationMenu);
@@ -1,4 +0,0 @@
.linkIcon {
margin-left: auto;
@include rotate-fourty-five;
}
@@ -1,34 +0,0 @@
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import { handleLinks } from '../../../utils/linkUtils';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
interface ClientLinkProps {
current: boolean;
to: string;
}
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
);
}
return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
{children}
</NavigationMenuItem>
);
}
@@ -1,38 +0,0 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
export default function EditorNavigation() {
const navigate = useNavigate();
const isSmallDevice = useIsSmallDevice();
if (!isSmallDevice) {
return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<IoLockClosedOutline />
Editor
</NavigationMenuItem>
);
}
return (
<>
<NavigationMenuItem active={location.pathname === '/timercontrol'} onClick={() => navigate('/timercontrol')}>
<IoLockClosedOutline />
Timer Controls
</NavigationMenuItem>
<NavigationMenuItem active={location.pathname === '/messagecontrol'} onClick={() => navigate('/messagecontrol')}>
<IoLockClosedOutline />
Message Controls
</NavigationMenuItem>
<NavigationMenuItem active={location.pathname === '/rundown'} onClick={() => navigate('/rundown')}>
<IoLockClosedOutline />
Rundown
</NavigationMenuItem>
</>
);
}
@@ -1,23 +0,0 @@
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: $zindex-nav;
}
@@ -1,45 +0,0 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
import IconButton from '../../buttons/IconButton';
import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings?: () => void;
}
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true);
return (
<div
id='fadeable-navigation'
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{toggleSettings && (
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</IconButton>
)}
</div>
);
}
@@ -1,37 +0,0 @@
.link {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
border-left: 4px solid transparent;
color: $action-text-color;
white-space: nowrap;
cursor: pointer;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
background-color: $gray-1350;
}
&:active {
background-color: $border-color-ondark;
}
&:focus {
outline: none;
background-color: $gray-1300;
border: 2px solid $ui-white;
}
&.current {
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
}
}
@@ -1,33 +0,0 @@
import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuItemProps {
active?: boolean;
className?: string;
onClick: () => void;
}
export default function NavigationMenuItem({
active,
className,
children,
onClick,
}: PropsWithChildren<NavigationMenuItemProps>) {
return (
<div
className={cx([style.link, active && style.current, className])}
tabIndex={0}
role='button'
onClick={onClick}
onKeyDown={(event) => {
isKeyEnter(event) && onClick();
}}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
.header {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,46 +0,0 @@
import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag';
import style from './OtherAddresses.module.scss';
interface OtherAddressesProps {
currentLocation: string;
}
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<>
<div className={style.header}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</>
);
}
@@ -1,22 +0,0 @@
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: $zindex-nav;
}
@@ -0,0 +1,40 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} />
</Tooltip>
);
}
@@ -1,32 +0,0 @@
.popup {
box-sizing: border-box;
padding: 1rem 1.5rem;
z-index: $zindex-dialog;
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 2px solid $gray-1200;
//width: 32rem;
max-width: 90vw;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
@@ -1,26 +0,0 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import style from './Popover.module.scss';
interface PopoverContentsProps extends Popover.Positioner.Props {
title?: string;
className?: string;
}
export default function PopoverContents({
title,
className,
children,
...popoverProps
}: PropsWithChildren<PopoverContentsProps>) {
return (
<Popover.Portal>
<Popover.Positioner sideOffset={8} {...popoverProps}>
<Popover.Popup className={style.popup}>
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
<Popover.Description className={className}>{children}</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
);
}
@@ -1,132 +0,0 @@
.select {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 2rem;
background-color: $gray-1200;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
background-color: $gray-1100;
}
&:active:not([data-disabled]) {
background-color: $gray-1000;
}
&[data-popup-open] {
background-color: $gray-1000;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&.fluid {
width: 100%;
}
}
.selectIcon {
color: $gray-200;
}
.popup {
font-size: calc(1rem - 2px);
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
transition: 50%;
transform: 100%;
opacity: 1;
}
}
.item {
box-sizing: border-box;
outline: 0;
line-height: 1rem;
padding: 0.25rem 0.5rem;
min-width: var(--anchor-width);
display: grid;
gap: 0.25rem;
align-items: center;
grid-template-columns: 0.75rem 1fr;
scroll-margin-block: 1rem;
&[data-highlighted] {
z-index: 0;
position: relative;
background-color: $blue-700;
}
}
.itemIndicator {
grid-column-start: 1;
}
.itemIndicatorIcon {
display: block;
width: 0.75rem;
height: 0.75rem;
}
.itemLabel {
grid-column-start: 2;
}
.scrollArrow {
background-color: $gray-1000;
color: $ui-white;
width: 100%;
z-index: 1;
height: 1rem;
font-size: 0.5rem;
display: grid;
place-content: center;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: 0;
}
&[data-direction='up'] {
border-radius: 3px 3px 0 0;
&::before {
top: -100%;
}
}
&[data-direction='down'] {
bottom: 0;
border-radius: 0 0 3px 3px;
&::before {
bottom: -100%;
}
}
}
@@ -1,45 +0,0 @@
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
// overload items to not allow undefined values
options: {
value: T;
label: string;
}[];
fluid?: boolean;
}
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
);
}
@@ -1,52 +0,0 @@
.switch {
position: relative;
display: flex;
align-items: center;
/* Reset and base styles */
appearance: none;
border: 1px solid transparent;
border-radius: 99px;
background-color: $gray-1100;
/* Transitions */
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
/* States */
&[data-checked] {
background-color: $blue-700;
box-shadow: none;
}
&:focus {
border-color: $blue-500;
}
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
--height: 1.5rem;
width: var(--width);
height: var(--height);
}
.large {
padding: 3px;
--width: calc(2.75rem + 6px);
--height: 1.75rem;
width: var(--width);
height: var(--height);
}
.thumb {
aspect-ratio: 1 / 1;
height: 100%;
border-radius: 99px;
background-color: $ui-white;
transition: translate 150ms ease;
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -1,17 +0,0 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { cx } from '../../utils/styleUtils';
import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
}
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
}
@@ -18,21 +18,19 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker'; import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './viewParams.types'; import { ParamField } from './types';
interface ParamInputProps { interface EditFormInputProps {
paramField: ParamField; paramField: ParamField;
} }
export default function ParamInput({ paramField }: ParamInputProps) { export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') { if (type === 'persist') {
if (!paramField.values || !paramField.values.length) { return null;
return null;
}
return <input hidden name={id} readOnly value={paramField.values.join(',')} />;
} }
if (type === 'option') { if (type === 'option') {
@@ -8,82 +8,12 @@
} }
} }
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.drawer {
box-sizing: border-box;
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 40rem;
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
transition: transform 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
&[data-starting-style],
&[data-ending-style] {
transform: translateX(100%);
transition: transform 500ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
// take the whole screen in mobile devices
@media (max-width: $min-tablet) {
width: 100vw;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
font-weight: 600;
font-size: 1.25rem;
}
.body {
flex: 1;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
display: flex;
gap: 1rem;
align-items: center;
justify-content: flex-end;
}
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%;
gap: 2rem; gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
@@ -1,51 +1,117 @@
import { FormEvent, memo, useEffect } from 'react'; import { FormEvent, useEffect } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog'; import {
import { useDisclosure } from '@mantine/hooks'; Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const newSearchParams = new URLSearchParams();
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
// we dont know which values contain colours
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
handleValueString(id, valueWithoutHash);
}
}
});
/** Utility function contains logic to add a value into the searchParams object */
function handleValueString(id: string, value: string) {
const maybeMultipleValues = value.split(',');
// we need to check if the value contains comma separated list, for the case of the multi-select data
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
const added = new Set();
maybeMultipleValues.forEach((v) => {
if (!added.has(v)) {
added.add(v);
newSearchParams.append(id, v);
}
});
} else {
newSearchParams.set(id, value);
}
}
return newSearchParams;
};
interface EditFormDrawerProps { interface EditFormDrawerProps {
viewOptions: ViewOption[]; viewOptions: ViewOption[];
} }
export default memo(ViewParamsEditor); // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const [isOpen, handlers] = useDisclosure(false); const { isOpen, onClose, onOpen } = useDisclosure();
// handle opening the drawer
useEffect(() => { useEffect(() => {
const isEditing = searchParams.get('edit'); const isEditing = searchParams.get('edit');
if (isEditing === 'true') { if (isEditing === 'true') {
return handlers.open(); return onOpen();
} }
}, [searchParams, handlers]); }, [searchParams, onOpen]);
const handleClose = () => { const handleClose = () => {
searchParams.delete('edit'); searchParams.delete('edit');
setSearchParams(searchParams); setSearchParams(searchParams);
handlers.close(); onClose();
}; };
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams();
handlers.close(); onClose();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -57,48 +123,39 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
}; };
return ( return (
<Dialog.Root <Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
open={isOpen} <DrawerOverlay />
onOpenChange={(open) => { <DrawerContent>
if (!open) { <DrawerHeader>
handleClose(); <DrawerCloseButton size='lg' />
} Customise
}} </DrawerHeader>
>
<Dialog.Portal> <DrawerBody>
<Dialog.Backdrop className={style.backdrop} /> {viewSettings.overrideStyles && (
<Dialog.Popup className={style.drawer}> <Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
<div className={style.header}> )}
<Dialog.Title>Customise</Dialog.Title> <form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
<IconButton variant='subtle-white' size='large' onClick={handleClose}> {viewOptions.map((section) => (
<IoClose /> <ViewParamsSection
</IconButton> key={section.title}
</div> title={section.title}
<div className={style.body}> collapsible={section.collapsible}
{viewSettings.overrideStyles && ( options={section.options}
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info> />
)} ))}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> </form>
{viewOptions.map((section) => ( </DrawerBody>
<ViewParamsSection
key={section.title} <DrawerFooter className={style.drawerFooter}>
title={section.title} <Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
collapsible={section.collapsible} Reset to default
options={section.options} </Button>
/> <Button variant='ontime-filled' form='edit-params-form' type='submit'>
))} Save
</form> </Button>
</div> </DrawerFooter>
<div className={style.footer}> </DrawerContent>
<Button variant='subtle' size='large' onClick={resetParams} type='reset'> </Drawer>
Reset to default
</Button>
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
Save
</Button>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
); );
} }
@@ -11,6 +11,10 @@
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
} }
} }
@@ -3,9 +3,8 @@ import { useLocalStorage } from '@mantine/hooks';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss'; import style from './ViewParamsSection.module.scss';
@@ -15,7 +14,9 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
@@ -26,52 +27,28 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
<HiddenContents options={options} /> {title}
) : ( {collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<> <>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> {options.map((option) => {
{title} if (option.type === 'persist') {
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} return null;
</div> }
<SectionContents options={options} collapsed={collapsed} />
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</> </>
)} )}
</section> </section>
); );
} }
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
);
}
function HiddenContents({ options }: { options: ParamField[] }) {
return (
<>
{options.map((option, index) => {
return <ParamInput key={option.title + index} paramField={option} />;
})}
</>
);
}
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -1,204 +0,0 @@
import { CustomFields } from 'ontime-types';
import { describe, expect, it } from 'vitest';
import { OptionTitle } from '../constants';
import type { ViewOption } from '../viewParams.types';
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
describe('getURLSearchParamsFromObj', () => {
// Mock view options for testing
const mockViewOptions: ViewOption[] = [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'color',
title: 'Color',
description: 'The color value',
type: 'colour',
defaultValue: 'ff0000',
},
{
id: 'persist-field',
title: 'Persistent Field',
description: 'A field that persists',
type: 'persist',
values: ['persisted-value'],
},
{
id: 'multi-select',
title: 'Multi Select',
description: 'A multi-select field',
type: 'option',
values: { value1: 'Value 1', value2: 'Value 2' },
defaultValue: '',
},
],
},
];
it('should handle empty params object', () => {
const params = {};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Should only include persisted values
expect(result.get('persist-field')).toBe('persisted-value');
expect(result.get('color')).toBeNull();
});
it('should not include values that match defaults', () => {
const params = {
color: 'ff0000', // same as the default
'other-param': 'value',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('color')).toBeNull();
expect(result.get('other-param')).toBe('value');
});
it('should sanitize color values with #', () => {
const params = {
color: '#00ff00', // different from default and includes #
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('color')).toBe('00ff00');
});
it('should handle multi-select values', () => {
const params = {
'multi-select': 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Should have multiple entries for the same key
const values = result.getAll('multi-select');
expect(values).toEqual(['value1', 'value2', 'value3']);
});
it('should not include empty string values', () => {
const params = {
'empty-param': '',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('empty-param')).toBeNull();
});
it('should allow multiple values for persisted fields', () => {
const mockOptionsWithMultiPersist: ViewOption[] = [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
values: [],
type: 'persist',
},
],
},
];
const params = {
sub: 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithMultiPersist);
const values = result.getAll('sub');
expect(values).toStrictEqual(['value1', 'value2', 'value3']);
});
it('should allow adding multiple values for any field', () => {
const params = {
'regular-field': 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
const values = result.getAll('regular-field');
expect(values).toHaveLength(3);
expect(values).toEqual(['value1', 'value2', 'value3']);
});
it('should deduplicate repeated key-value pairs', () => {
const params = {
multiValue: 'value1,value1,value2,value2,value3',
repeatedField: 'same,same,same',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Check multiValue field has unique values
expect(result.getAll('multiValue')).toEqual(['value1', 'value2', 'value3']);
// Check repeatedField has only one instance of the value
expect(result.getAll('repeatedField')).toEqual(['same']);
});
it('should deduplicate persisted values while maintaining order', () => {
const mockOptionsWithDuplicates: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'sub',
title: 'Subscription',
description: 'Persisted subscription values',
type: 'persist',
values: ['value1', 'value1', 'value2', 'value2', 'value3', 'value1'],
},
],
},
];
const result = getURLSearchParamsFromObj({}, mockOptionsWithDuplicates);
// Should only include unique values while maintaining order
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
});
});
@@ -1,28 +0,0 @@
import type { ParamField } from './viewParams.types';
export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Time format string, taken from the Application Settings',
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
type: 'string',
placeholder,
};
};
export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
@@ -1,6 +1,61 @@
/** import { CustomFields } from 'ontime-types';
* Gathers possible titles for view options
*/ import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
filterImageType = true,
) => {
const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
}
return options;
};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Time format string, taken from the Application Settings',
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
type: 'string',
placeholder,
};
};
export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
export enum OptionTitle { export enum OptionTitle {
ClockOptions = 'Clock Options', ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options', TimerOptions = 'Timer Options',
@@ -10,7 +65,4 @@ export enum OptionTitle {
StyleOverride = 'View style override', StyleOverride = 'View style override',
Animation = 'View animation', Animation = 'View animation',
Schedule = 'Schedule options', Schedule = 'Schedule options',
/** rendered as hidden inputs */
Hidden = 'Hidden options',
} }
@@ -12,7 +12,7 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
type MultiselectOption = { value: string; label: string; colour: string }; export type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>; export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
@@ -24,7 +24,7 @@ type StringField = { type: 'string'; defaultValue?: string; prefix?: string; pla
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string }; type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] }; type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField); (OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
@@ -1,144 +0,0 @@
import type { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ViewOption } from './viewParams.types';
/**
* Creates a list of custom fields for a select
* Filters out image type custom fields
*/
export function makeOptionsFromCustomFields(
customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
): Record<string, string> {
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
}
return options;
}
/**
* Creates data for a multiselect component from custom fields
* Filters out image type custom fields
*/
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
type FieldMetadata = {
defaultValues: Record<string, string>;
colorFields: Set<string>;
isPersistedField: Set<string>;
persistedValues: Record<string, string[]>;
};
/**
* Utility collects metadata about fields from view options
*/
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
const metadata: FieldMetadata = {
defaultValues: {},
colorFields: new Set(),
isPersistedField: new Set(),
persistedValues: {},
};
paramFields.forEach((section) => {
section.options.forEach((option) => {
if (option.type === 'persist') {
metadata.isPersistedField.add(option.id);
if (option.values) {
metadata.persistedValues[option.id] = option.values;
}
} else {
metadata.defaultValues[option.id] = String(option.defaultValue);
}
if (option.type === 'colour') {
metadata.colorFields.add(option.id);
}
});
});
return metadata;
}
/**
* Makes a new URLSearchParams object from the given params object
* @param paramsObj - The object containing parameters to be converted
* @param paramFields - The view options that define the parameters
* @returns A new URLSearchParams object with the parameters
*/
export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields: ViewOption[]) {
const newSearchParams = new URLSearchParams();
const addedPairs = new Set<string>();
const metadata = collectFieldMetadata(paramFields);
// Utility function to safely add params without duplicates
const addUniqueParam = (id: string, value: string) => {
const pair = `${id}:${value}`;
if (!addedPairs.has(pair)) {
addedPairs.add(pair);
newSearchParams.append(id, value);
}
};
// First add all persisted values
Object.entries(metadata.persistedValues).forEach(([id, values]) => {
values.forEach((value) => {
if (value) {
addUniqueParam(id, value);
}
});
});
// Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
// For persisted fields, clear existing values before adding new ones
if (metadata.isPersistedField.has(id)) {
// Clear tracking of previous values for this field
Array.from(addedPairs).forEach((pair) => {
if (pair.startsWith(`${id}:`)) {
addedPairs.delete(pair);
}
});
newSearchParams.delete(id);
}
// Process and add new values
value.split(',').forEach((v) => {
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
addUniqueParam(id, processedValue);
}
});
}
});
return newSearchParams;
}
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getAutomationSettings } from '../api/automation'; import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants'; import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings'; import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
@@ -18,3 +20,15 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
} }
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
// CustomFields record type is no longer used here import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields, CustomFieldWithKey } from '../api/customFields'; // Import CustomFieldWithKey import { getCustomFields } from '../api/customFields';
const placeholder: CustomFieldWithKey[] = []; // Placeholder is now an empty array const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
// Explicitly type the useQuery hook const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '', lastLoadedProject: '',
}; };
function useProjectList() { export function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
@@ -1,22 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown';
/**
* Project rundowns
*/
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
@@ -1,30 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = { const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data * Normalised rundown data
*/ */
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
@@ -44,16 +37,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]); const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]); const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRundown(flatRundown); setFlatRunDown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.entries, data.flatOrder, data.revision, prevRevision]); }, [data.order, data.revision, data.rundown, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -64,13 +57,13 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRundown, status }; return { data: flatRunDown, status };
} }
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*/ */
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) { export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown(); const { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
@@ -4,11 +4,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants'; import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets'; import { getUrlPresets } from '../api/urlPresets';
interface FetchProps { export default function useUrlPresets() {
skip?: boolean;
}
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: getUrlPresets,
@@ -17,7 +13,6 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
enabled: !skip,
}); });
return { data: data ?? [], status, isError, refetch }; return { data: data ?? [], status, isError, refetch };
@@ -1,28 +1,20 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getViewSettings, postViewSettings } from '../../common/api/viewSettings'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ontimeQueryClient } from '../../common/queryClient';
import { VIEW_SETTINGS } from '../api/constants'; import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/viewSettings';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, isPending } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getViewSettings, queryFn: getView,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
const { mutateAsync } = useMutation({ return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
mutationFn: postViewSettings,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: VIEW_SETTINGS });
},
onSuccess: (data) => {
ontimeQueryClient.setQueryData(VIEW_SETTINGS, data);
},
});
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending };
} }
@@ -1,10 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { MessageTag } from 'ontime-types';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { sendSocket } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket'; import { useIsOnline } from './useSocket';
@@ -23,7 +22,7 @@ export const useClientPath = () => {
useEffect(() => { useEffect(() => {
if (!isOnline) return; if (!isOnline) return;
sendSocket(MessageTag.ClientSetPath, pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search, isOnline]); }, [pathname, search, isOnline]);
// navigate to new path when received from server // navigate to new path when received from server

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