Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente 805478f141 refactor: target chrome 89 2025-01-28 17:22:38 +01:00
768 changed files with 25640 additions and 33985 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
version: 9
- name: Install dependencies
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.
Other dependencies will be installed as part of the setup
- __node__ (~22)
- __pnpm__ (~10)
- __node__ (~20)
- __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images)
## 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 PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
RUN npm install -g pnpm@9.5.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine
FROM node:20-alpine
# Set environment variables
# 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
```
```
For the public views
-------------------------------------------------------------
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators
IP.ADDRESS:4001/operator > automated views for operators
```
More information is available [in our docs](https://docs.getontime.no)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.15.2",
"version": "3.11.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-2
View File
@@ -15,12 +15,10 @@
],
"plugins": [
"react",
"react-compiler",
"simple-import-sort",
"@tanstack/query"
],
"rules": {
"react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [
"error",
{
+17 -19
View File
@@ -1,40 +1,37 @@
{
"name": "ontime-ui",
"version": "3.15.2",
"version": "3.11.0-beta.1",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.1",
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2",
"@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^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",
"axios": "^1.9.0",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
"zustand": "^4.5.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -66,18 +63,18 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
@@ -85,11 +82,12 @@
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"terser": "^5.37.0",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vite": "^5.2.11",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:"
}
}
+16 -15
View File
@@ -18,19 +18,20 @@ import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION';
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 Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./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('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
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 STimer = withPreset(withData(TimerView));
@@ -39,11 +40,10 @@ const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -86,6 +86,14 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
@@ -147,15 +155,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
-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 -4
View File
@@ -8,19 +8,21 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
export const projectDataURL = `${serverURL}/project`;
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 projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+10 -17
View File
@@ -1,45 +1,38 @@
import axios from 'axios';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
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[]> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath);
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
* Returns the updated list, sorted by order
*/
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField });
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
* Returns the updated list, sorted by order
*/
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> {
// Ensure newField can include 'order' by using Partial<CustomField>
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
* Returns the updated list, sorted by order
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`);
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data;
}
+4 -7
View File
@@ -1,8 +1,7 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -40,12 +39,10 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') {
try {
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 fileContent = makeCSVFromArrayOfArrays(sheetData);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
+6 -5
View File
@@ -1,11 +1,16 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
@@ -29,10 +34,6 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
-26
View File
@@ -1,26 +0,0 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
+19 -56
View File
@@ -1,45 +1,29 @@
import axios, { AxiosResponse } from 'axios';
import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
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
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
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);
}
/**
* 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);
}
@@ -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);
}
export type ReorderEntry = {
entryId: EntryId;
destinationId: EntryId;
order: 'before' | 'after' | 'insert';
eventId: string;
from: number;
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);
}
@@ -83,36 +67,15 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
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>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* 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 } });
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
+2 -2
View File
@@ -1,5 +1,5 @@
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 { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: Rundown;
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
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';
/**
* Utility unwrap a potential axios error
* Utility unrwap a potential axios error
* @param error
* @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 error
*/
+11 -5
View File
@@ -1,15 +1,21 @@
import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { ViewSettings } from 'ontime-types';
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);
return res.data;
}
/**
* HTTP request to mutate view settings
*/
export async function postViewSettings(data: ViewSettings) {
const res = await axios.post(viewSettingsPath, data);
return res.data as ViewSettings;
return axios.post(viewSettingsPath, data);
}
@@ -0,0 +1,12 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
@include rotate-fourty-five;
}
@@ -0,0 +1,26 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,84 +0,0 @@
@use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
width: fit-content;
padding-inline: 1em;
white-space: nowrap;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&.loading {
cursor: wait;
.content {
opacity: 0;
}
}
}
.content {
display: flex;
align-items: center;
gap: inherit;
}
.loadingOverlay {
position: absolute;
display: grid;
place-content: center;
}
.spinner {
animation: spin 1s linear infinite;
stroke-dasharray: 8;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.xlarge {
height: 3rem;
font-weight: 600;
}
.fluid {
width: 100%;
}
@@ -1,53 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { IoEllipseOutline } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean;
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([
style.baseButton,
style[variant],
style[size],
fluid && style.fluid,
loading && style.loading,
className,
])}
type='button'
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
@@ -1,39 +0,0 @@
@use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseIconButton {
aspect-ratio: 1;
display: grid;
place-content: center;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
width: 2rem;
}
.large {
height: 2.5rem;
width: 2.5rem;
}
.xlarge {
height: 3rem;
height: 3rem;
font-size: 1.5rem;
}
@@ -1,36 +0,0 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export default function IconButton({
className,
children,
variant = 'subtle',
size = 'medium',
...buttonProps
}: IconButtonProps) {
return (
<button
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</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,24 +0,0 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
@@ -1,115 +1,65 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import {
Button,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name: string;
currentPath: string;
origin: string;
name?: string;
path?: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote;
const handleRedirect = (newPath: string) => {
if (newPath === '/' || newPath === currentPath) {
return;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
}
setRedirect({ target: id, redirect: newPath });
onClose();
};
const enabledPresets = data.filter((preset) => preset.enabled);
const viewOptions = [
...navigatorConstants.map((view) => ({
value: `/${view.url}`,
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
return (
<Modal
isOpen={isOpen}
onClose={onClose}
showCloseButton
showBackdrop
title={`Redirect: ${name}`}
bodyElements={
<>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input
placeholder='eg. /minimal?key=0000ffff'
fluid
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<Button
variant='primary'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect <IoArrowForward />
</Button>
</div>
</>
}
/>
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,9 +1,17 @@
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 Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
interface RenameClientModalProps {
id: string;
@@ -12,7 +20,8 @@ interface RenameClientModalProps {
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 { setClientName } = setClientRemote;
@@ -27,24 +36,29 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
const canSubmit = name !== currentName && name !== '';
return (
<Dialog
isOpen={isOpen}
title={`Rename Client: ${currentName}`}
showCloseButton
onClose={onClose}
bodyElements={
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
}
footerElements={
<>
<Button variant='subtle' size='large' onClick={onClose}>
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename: {currentName}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Input
variant='ontime-filled'
size='md'
placeholder='new name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button variant='primary' size='large' onClick={handleRename} disabled={!canSubmit}>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</>
}
/>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -2,8 +2,8 @@
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean;
};
type OptionWithGroup = {
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
@@ -1,5 +1,6 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
@@ -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.
import * as Sentry from '@sentry/react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { runtimeStore } from '../../stores/runtime';
import { runtimeStore } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -26,22 +26,17 @@ class ErrorBoundary extends React.Component {
error,
errorInfo: info,
});
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => {
scope.setExtras({
error,
store: appState,
hasSocket: { hasConnected, reconnectAttempts },
store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
this.reportContent = `${error} ${info.componentStack}`;
}
render() {
@@ -1,8 +1,8 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss';
@@ -14,6 +14,7 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
@@ -21,14 +22,8 @@ export default function ExternalLink(props: ExternalLinkProps) {
};
return (
<a
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -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;
place-content: center;
text-align: center;
z-index: $zindex-modal;
z-index: 100;
cursor: pointer;
}
@@ -2,34 +2,15 @@
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
color: $gray-200;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue;
}
}
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,22 +1,13 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle, IoWarning } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
type?: 'info' | 'warning' | 'error';
}
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
export default function Info({ children }: PropsWithChildren) {
return (
<div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div>
<div className={style.infoLabel}>
<IoAlertCircle />
{children}
</div>
);
}
@@ -1,4 +1,4 @@
import { IoBan } from 'react-icons/io5';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { cx } from '../../../utils/styleUtils';
@@ -1,11 +1,11 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
@@ -9,7 +9,7 @@ $input-font-size: 15px;
.inputField {
font-size: $input-font-size;
letter-spacing: 0.5px;
letter-spacing: 1px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction';
import { useEventAction } from '../../../hooks/useEventAction';
import style from './DelayInput.module.scss';
@@ -13,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props;
const { updateEntry } = useEntryActions();
const { updateEvent } = useEventAction();
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
};
const submitChange = (value: number) => {
updateEntry({
updateEvent({
id: eventId,
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';
interface UseReactiveTextInputReturn {
@@ -17,12 +17,9 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
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(() => {
if (typeof initialText === 'undefined') {
@@ -61,14 +58,7 @@ export default function useReactiveTextInput(
setText(cleanVal);
}
}
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, options, ref, submitCallback],
);
@@ -80,73 +70,27 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
hotKeys.push(['Enter', () => handleSubmit(text)]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => {
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
}
},
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
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 {
width: 100%;
max-width: 7.5em;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
width: 6.5em;
}
@@ -1,24 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder?: string;
placeholder: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
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 [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -27,12 +26,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
} else {
try {
setValue(millisToString(time));
} catch (error) {
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
}
}, [time]);
}, [emitError, time]);
/**
* @description Selects input text on focus
@@ -113,27 +113,32 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
);
useEffect(() => {
if (time == null) return;
resetValue();
}, [resetValue]);
}, [resetValue, time]);
return (
<Input
id={id}
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
className={className}
fontSize='1rem'
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off'
style={{
textAlign: align,
}}
textAlign={align}
/>
);
}
@@ -1,11 +0,0 @@
.link {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
color: $blue-400;
&:hover {
color: $blue-500;
}
}
@@ -1,25 +0,0 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import style from './AppLink.module.scss';
interface AppLinkProps {
search: string;
}
/**
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={style.link}>
{children}
</button>
);
}
@@ -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,37 @@
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
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={`${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,114 @@
.separator {
border-color: $border-color-ondark;
}
@use '../../../theme/mixins' as *;
.backdrop {
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
left: 0;
top: 0;
z-index: 12;
&[data-starting-style],
&[data-ending-style] {
&.hidden {
opacity: 0;
}
}
.drawer {
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: $zindex-dialog;
.button {
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size;
display: grid;
place-content: center;
border-radius: 3px;
}
width: 22rem;
height: 100vh;
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.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;
}
&:active {
background-color: $border-color-ondark;
}
&:focus {
outline: none;
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;
}
}
.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;
flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 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;
}
flex-wrap: wrap;
gap: 0.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 1.5rem;
font-weight: 600;
font-size: 1.25rem;
}
.body {
flex: 1;
overflow-y: auto;
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,20 +1,33 @@
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { useLocation } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
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 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 CopyTag from '../copy-tag/CopyTag';
import style from './NavigationMenu.module.scss';
@@ -23,49 +36,74 @@ interface NavigationMenuProps {
onClose: () => void;
}
export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const [isRenameOpen, handlers] = useDisclosure(false);
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Dialog.Portal>
<Dialog.Backdrop className={style.backdrop} />
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose}>
<IoClose />
</IconButton>
</div>
<div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
Flip Screen
<IoSwapVertical />
</NavigationMenuItem>
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
return createPortal(
<div id='navigation-menu-portal'>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent maxWidth='22rem'>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={cx([style.link, fullscreen && style.current])}
tabIndex={0}
role='button'
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={cx([style.link, mirror && style.current])}
tabIndex={0}
role='button'
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} />
<EditorNavigation />
<Link
to='/editor'
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
</Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline />
Cuesheet
@@ -74,23 +112,86 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label}
</ClientLink>
))}
</div>
{isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody>
</DrawerContent>
</Drawer>
</div>,
document.body,
);
}
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);
@@ -0,0 +1,15 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
</div>
);
}
@@ -1,39 +1,25 @@
import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps {
isLockable?: boolean;
supressSettings?: boolean;
}
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const isViewLocked = isStringBoolean(searchParams.get('locked'));
useHotkeys([
[
'Space',
() => {
if (isViewLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
[
'mod + ,',
() => {
if (isViewLocked || supressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
],
]);
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
@@ -41,11 +27,10 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
return (
<>
<FloatingNavigation
toggleMenu={menuHandler.toggle}
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
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,26 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -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;
}
@@ -1,16 +0,0 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
import style from './ViewLockedIcon.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -0,0 +1,42 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
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>
);
}
@@ -9,6 +9,10 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
}
}
.progress-bar__indicator {
@@ -7,15 +7,16 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -0,0 +1,76 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -0,0 +1,52 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -0,0 +1,102 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -0,0 +1,43 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -0,0 +1,23 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -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>
);
}
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.8;
opacity: 0.6;
}
.text {
@@ -1,20 +1,18 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
style?: CSSProperties;
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, className, ...rest } = props;
const { text, ...rest } = props;
return (
<div className={cx([style.emptyContainer, className])} {...rest}>
<div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>

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