diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..b58b603fe --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..1ff6325df --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,51 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index ab81f4d79..afe691e57 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ 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 ------------------------------------------------------------- diff --git a/apps/client/package.json b/apps/client/package.json index b255909ae..175359ec4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -5,21 +5,21 @@ "type": "module", "dependencies": { "@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": "^7.17.2", + "@mantine/hooks": "^7.13.3", "@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", "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", diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 7b747d639..63cb9099d 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -29,6 +29,7 @@ const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdow const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); +const Public = React.lazy(() => import('./views/public/Public')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); @@ -39,6 +40,7 @@ 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)); @@ -86,6 +88,14 @@ export default function AppRouter() { } /> + + + + } + /> { - const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField }); +export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise { + const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField }); return res.data; } /** * Deletes single custom field */ -export async function deleteCustomField(key: CustomFieldKey): Promise { - const res = await axios.delete(`${customFieldsPath}/${key}`); +export async function deleteCustomField(label: CustomFieldLabel): Promise { + const res = await axios.delete(`${customFieldsPath}/${label}`); return res.data; } diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 0a3092d0d..9edf73eb5 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -2,7 +2,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 { makeCSVFromArrayOfArrays } from '../utils/csv'; import { apiEntryUrl } from './constants'; import { createBlob, downloadBlob } from './utils'; @@ -40,11 +40,9 @@ 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 flatRundowns = aggregateRundowns(rundowns); - const sheetData = makeTable(project, flatRundowns, customFields); + const { project, rundown, customFields } = data; + const sheetData = makeTable(project, rundown, customFields); const fileContent = makeCSVFromArrayOfArrays(sheetData); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts index 5c21c987a..b73aa585f 100644 --- a/apps/client/src/common/api/excel.ts +++ b/apps/client/src/common/api/excel.ts @@ -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 { return response.data; } -type PreviewSpreadsheetResponse = { - rundown: Rundown; - customFields: CustomFields; -}; export async function importRundownPreview(options: ImportMap): Promise { const response: AxiosResponse = await axios.post(`${excelPath}/preview`, { options, diff --git a/apps/client/src/common/api/external.ts b/apps/client/src/common/api/external.ts index 9c2c0ba53..e84e49a15 100644 --- a/apps/client/src/common/api/external.ts +++ b/apps/client/src/common/api/external.ts @@ -11,7 +11,7 @@ export type HasUpdate = { * HTTP request to get the latest version and url from github */ export async function getLatestVersion(): Promise { - const res = await axios.get(apiRepoLatest); + const res = await axios.get(`${apiRepoLatest}`); return { url: res.data.html_url as string, version: res.data.tag_name as string, diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 619d946e6..2ea67e94a 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`; * HTTP request to fetch all reports */ export async function fetchReport(): Promise { - const res = await axios.get(reportUrl); + const res = await axios.get(`${reportUrl}/`); return res.data; } diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index dc9d5f7a5..9d854eee1 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -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 { - const res = await axios.get(rundownPath); - return res.data; -} - /** * HTTP request to fetch all events */ -export async function fetchCurrentRundown(): Promise { - const res = await axios.get(`${rundownPath}/current`); +export async function fetchNormalisedRundown(): Promise { + 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> { +export async function requestPostEvent(data: TransientEventPayload): Promise> { return axios.post(rundownPath, data); } /** - * HTTP request to edit an entry + * HTTP request to put new event */ -export async function putEditEntry(data: Partial): Promise> { +export async function requestPutEvent(data: Partial): Promise> { 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> { +export async function requestBatchPutEvents(data: BatchEditEntry): Promise> { 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> { +export async function requestReorderEvent(data: ReorderEntry): Promise> { return axios.patch(`${rundownPath}/reorder`, data); } @@ -83,36 +67,15 @@ export async function requestEventSwap(data: SwapEntry): Promise> { - return axios.patch(`${rundownPath}/applydelay/${delayId}`); +export async function requestApplyDelay(eventId: string): Promise> { + 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> { - return axios.post(`${rundownPath}/clone/${entryId}`); -} - -/** - * HTTP request for dissolving of a block - */ -export async function requestUngroup(blockId: EntryId): Promise> { - return axios.post(`${rundownPath}/ungroup/${blockId}`); -} - -/** - * HTTP request for grouping a list of entries into a block - */ -export async function requestGroupEntries(entryIds: EntryId[]): Promise> { - return axios.post(`${rundownPath}/group`, { ids: entryIds }); -} - -/** - * HTTP request to delete entries - */ -export async function deleteEntries(entryIds: EntryId[]): Promise> { - return axios.delete(rundownPath, { data: { ids: entryIds } }); +export async function requestDelete(eventIds: string[]): Promise> { + return axios.delete(rundownPath, { data: { ids: eventIds } }); } /** diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts index 231f543db..ebeb9fdce 100644 --- a/apps/client/src/common/api/sheets.ts +++ b/apps/client/src/common/api/sheets.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { AuthenticationStatus, CustomFields, 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 }); diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts index b49fc335c..9f2f35ce2 100644 --- a/apps/client/src/common/api/utils.ts +++ b/apps/client/src/common/api/utils.ts @@ -7,7 +7,7 @@ import { addLog } from '../stores/logger'; import { nowInMillis } from '../utils/time'; /** - * Utility 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 */ diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx index 331532ee4..3ed5a842a 100644 --- a/apps/client/src/common/components/context-menu/ContextMenu.tsx +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -23,7 +23,7 @@ export type OptionWithoutGroup = { withDivider?: boolean; }; -type OptionWithGroup = { +export type OptionWithGroup = { label: string; group: Omit[]; }; diff --git a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx index 0cf4d228e..f0d3e9477 100644 --- a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx +++ b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx @@ -3,8 +3,8 @@ import React from 'react'; // skipcq: JS-C1003 - sentry does not expose itself as an ES Module. import * as Sentry from '@sentry/react'; -import { 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'; @@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component { scope.setExtras({ error, store: appState, - hasSocket: { hasConnected, reconnectAttempts }, + hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, }); const eventId = Sentry.captureException(error); this.setState({ eventId, info }); diff --git a/apps/client/src/common/components/input/delay-input/DelayInput.tsx b/apps/client/src/common/components/input/delay-input/DelayInput.tsx index bf4e22851..1187eb6c8 100644 --- a/apps/client/src/common/components/input/delay-input/DelayInput.tsx +++ b/apps/client/src/common/components/input/delay-input/DelayInput.tsx @@ -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(''); const inputRef = useRef(null); @@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) { }; const submitChange = (value: number) => { - updateEntry({ + updateEvent({ id: eventId, duration: value, }); diff --git a/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss b/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss new file mode 100644 index 000000000..074e32f2f --- /dev/null +++ b/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss @@ -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; + } +} diff --git a/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx b/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx new file mode 100644 index 000000000..bc77a2814 --- /dev/null +++ b/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx @@ -0,0 +1,9 @@ +import style from './LoaderOverlay.module.scss'; + +export default function LoaderOverlay() { + return ( +
+ +
+ ); +} diff --git a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx new file mode 100644 index 000000000..2841ba069 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx @@ -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 ; +} + +export default memo(ProductionNavigationMenu); diff --git a/apps/client/src/common/components/view-params-editor/types.ts b/apps/client/src/common/components/view-params-editor/types.ts index 84ab3ff68..51d8a2ad1 100644 --- a/apps/client/src/common/components/view-params-editor/types.ts +++ b/apps/client/src/common/components/view-params-editor/types.ts @@ -12,7 +12,7 @@ type OptionsField = { defaultValue?: string; }; -type MultiselectOption = { value: string; label: string; colour: string }; +export type MultiselectOption = { value: string; label: string; colour: string }; export type MultiselectOptions = Record; type MultiOptionsField = { type: 'multi-option'; diff --git a/apps/client/src/common/context/useMediaQuery.ts b/apps/client/src/common/context/useMediaQuery.ts new file mode 100644 index 000000000..e2d282529 --- /dev/null +++ b/apps/client/src/common/context/useMediaQuery.ts @@ -0,0 +1,32 @@ +// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts + +import { useCallback, useEffect, useState } from 'react'; + +function getMatches(query: string): boolean { + return window.matchMedia(query).matches; +} + +// TODO: debounce handleChange +export default function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(getMatches(query)); + + const handleChange = useCallback(() => { + setMatches(getMatches(query)); + }, [query]); + + useEffect(() => { + const matchMedia = window.matchMedia(query); + + // Triggered at the first client-side load and if query changes + handleChange(); + + // Listen matchMedia + matchMedia.addEventListener('change', handleChange); + + return () => { + matchMedia.removeEventListener('change', handleChange); + }; + }, [handleChange, query]); + + return matches; +} diff --git a/apps/client/src/common/hooks-query/useAutomationSettings.ts b/apps/client/src/common/hooks-query/useAutomationSettings.ts index 18d9f81ab..60fffa64b 100644 --- a/apps/client/src/common/hooks-query/useAutomationSettings.ts +++ b/apps/client/src/common/hooks-query/useAutomationSettings.ts @@ -1,9 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { getAutomationSettings } from '../api/automation'; +import { editAutomationSettings, getAutomationSettings } from '../api/automation'; import { AUTOMATION } from '../api/constants'; +import { logAxiosError } from '../api/utils'; import { automationPlaceholderSettings } from '../models/AutomationSettings'; +import { ontimeQueryClient } from '../queryClient'; export default function useAutomationSettings() { const { data, status, isFetching, isError, refetch } = useQuery({ @@ -18,3 +20,15 @@ export default function useAutomationSettings() { return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; } + +export function useAutomationSettingsMutation() { + const { isPending, mutateAsync } = useMutation({ + mutationFn: editAutomationSettings, + onError: (error) => logAxiosError('Error saving Automation settings', error), + onSuccess: (data) => { + ontimeQueryClient.setQueryData(AUTOMATION, data); + }, + onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }), + }); + return { isPending, mutateAsync }; +} diff --git a/apps/client/src/common/hooks-query/useProjectList.ts b/apps/client/src/common/hooks-query/useProjectList.ts index 329bba44b..57d3b6f01 100644 --- a/apps/client/src/common/hooks-query/useProjectList.ts +++ b/apps/client/src/common/hooks-query/useProjectList.ts @@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = { lastLoadedProject: '', }; -function useProjectList() { +export function useProjectList() { const { data, status, refetch } = useQuery({ queryKey: PROJECT_LIST, queryFn: getProjects, diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 595cf1161..67629d4d9 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,30 +1,23 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { OntimeEntry, Rundown } from 'ontime-types'; +import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { RUNDOWN } from '../api/constants'; -import { fetchCurrentRundown } from '../api/rundown'; +import { fetchNormalisedRundown } from '../api/rundown'; import useProjectData from './useProjectData'; // revision is -1 so that the remote revision is higher -const cachedRundownPlaceholder: Rundown = { - id: 'default', - title: '', - order: [], - flatOrder: [], - entries: {}, - revision: -1, -}; +const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; /** * Normalised rundown data */ export default function useRundown() { - const { data, status, isError, refetch, isFetching } = useQuery({ + const { data, status, isError, refetch, isFetching } = useQuery({ queryKey: RUNDOWN, - queryFn: fetchCurrentRundown, + queryFn: fetchNormalisedRundown, placeholderData: (previousData, _previousQuery) => previousData, retry: 5, retryDelay: (attempt) => attempt * 2500, @@ -44,16 +37,16 @@ export function useFlatRundown() { const loadedProject = useRef(''); const [prevRevision, setPrevRevision] = useState(-1); - const [flatRundown, setFlatRundown] = useState([]); + const [flatRunDown, setFlatRunDown] = useState([]); // update data whenever the revision changes useEffect(() => { if (data.revision !== -1 && data.revision !== prevRevision) { - const flatRundown = data.order.map((id) => data.entries[id]); - setFlatRundown(flatRundown); + const flatRundown = data.order.map((id) => data.rundown[id]); + setFlatRunDown(flatRundown); setPrevRevision(data.revision); } - }, [data.entries, data.order, data.revision, prevRevision]); + }, [data.order, data.revision, data.rundown, prevRevision]); // TODO: should we have a project id field? // invalidate current version if project changes @@ -64,13 +57,13 @@ export function useFlatRundown() { } }, [projectData]); - return { data: flatRundown, status }; + return { data: flatRunDown, status }; } /** * Provides access to a partial rundown based on a filter callback */ -export function usePartialRundown(cb: (event: OntimeEntry) => boolean) { +export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) { const { data, status } = useFlatRundown(); const filteredData = useMemo(() => { return data.filter(cb); diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts deleted file mode 100644 index 1cd19529f..000000000 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ /dev/null @@ -1,801 +0,0 @@ -import { useCallback } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { - EntryId, - isOntimeBlock, - isOntimeEvent, - MaybeString, - OntimeBlock, - OntimeEntry, - OntimeEvent, - Rundown, - TimeField, - TimeStrategy, - TransientEventPayload, -} from 'ontime-types'; -import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; - -import { moveDown, moveUp } from '../../features/rundown/rundown.utils'; -import { RUNDOWN } from '../api/constants'; -import { - deleteEntries, - patchReorderEntry, - postAddEntry, - postCloneEntry, - putBatchEditEvents, - putEditEntry, - ReorderEntry, - requestApplyDelay, - requestDeleteAll, - requestEventSwap, - requestGroupEntries, - requestUngroup, - SwapEntry, -} from '../api/rundown'; -import { logAxiosError } from '../api/utils'; -import { useEditorSettings } from '../stores/editorSettings'; - -export type EventOptions = Partial<{ - // options of any new entries (event / delay / block) - after: MaybeString; - before: MaybeString; - // options of entries of type OntimeEvent - linkPrevious: boolean; - lastEventId: MaybeString; -}>; - -/** - * Gather utilities for actions on entries - */ -export const useEntryActions = () => { - const queryClient = useQueryClient(); - const { - linkPrevious, - defaultTimeStrategy, - defaultDuration, - defaultWarnTime, - defaultDangerTime, - defaultTimerType, - defaultEndAction, - } = useEditorSettings(); - - const getEntryById = useCallback( - (eventId: string): OntimeEntry | undefined => { - const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.entries) { - return; - } - return cachedRundown.entries[eventId]; - }, - [queryClient], - ); - - /** - * Calls mutation to add new entry - * @private - */ - const _addEntryMutation = useMutation({ - // TODO(v4): optimistic create entry - mutationFn: postAddEntry, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), - }); - - /** - * Adds an entry to rundown - */ - const addEntry = useCallback( - async (entry: Partial, options?: EventOptions) => { - const newEntry: TransientEventPayload = { ...entry, id: generateId() }; - - // ************* CHECK OPTIONS specific to events - if (isOntimeEvent(newEntry)) { - // merge creation time options with event settings - const applicationOptions = { - after: options?.after, - before: options?.before, - lastEventId: options?.lastEventId, - linkPrevious: options?.linkPrevious ?? linkPrevious, - }; - - if (applicationOptions?.lastEventId) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value - const rundownData = queryClient.getQueryData(RUNDOWN)!; - const previousEvent = rundownData.entries[applicationOptions.lastEventId]; - if (isOntimeEvent(previousEvent)) { - newEntry.timeStart = previousEvent.timeEnd; - } - } - - // Override event with options from editor settings - newEntry.linkStart = applicationOptions.linkPrevious; - - if (newEntry.duration === undefined && newEntry.timeEnd === undefined) { - newEntry.duration = parseUserTime(defaultDuration); - } - - if (newEntry.timeDanger === undefined) { - newEntry.timeDanger = parseUserTime(defaultDangerTime); - } - - if (newEntry.timeWarning === undefined) { - newEntry.timeWarning = parseUserTime(defaultWarnTime); - } - - if (newEntry.timerType === undefined) { - newEntry.timerType = defaultTimerType; - } - - if (newEntry.endAction === undefined) { - newEntry.endAction = defaultEndAction; - } - - if (newEntry.timeStrategy === undefined) { - newEntry.timeStrategy = defaultTimeStrategy; - } - } - - // handle adding options that concern all event type - if (options?.after) { - (newEntry as TransientEventPayload).after = options.after; - } - if (options?.before) { - (newEntry as TransientEventPayload).before = options.before; - } - - try { - await _addEntryMutation.mutateAsync(newEntry); - } catch (error) { - logAxiosError('Failed adding event', error); - } - }, - [ - _addEntryMutation, - defaultDangerTime, - defaultDuration, - defaultEndAction, - defaultTimerType, - defaultTimeStrategy, - defaultWarnTime, - linkPrevious, - queryClient, - ], - ); - - /** - * Calls mutation to clone a selection - * @private - */ - const _cloneMutation = useMutation({ - mutationFn: postCloneEntry, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), - }); - - /** - * Clone a selection - */ - const clone = useCallback( - async (entryId: EntryId) => { - try { - await _cloneMutation.mutateAsync(entryId); - } catch (error) { - logAxiosError('Error cloning entry', error); - } - }, - [_cloneMutation], - ); - - /** - * Calls mutation to update existing entry - * @private - */ - const _updateEntryMutation = useMutation({ - mutationFn: putEditEntry, - // we optimistically update here - onMutate: async (newEvent) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - const eventId = newEvent.id; - - if (previousData && eventId) { - // optimistically update object - const newRundown = { ...previousData.entries }; - // @ts-expect-error -- we expect the events to be of same type - newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; - queryClient.setQueryData(RUNDOWN, { - id: previousData.id, - title: previousData.title, - order: previousData.order, - flatOrder: previousData.flatOrder, - entries: newRundown, - revision: -1, - }); - } - - // Return a context with the previous and new events - return { previousData, newEvent }; - }, - // Mutation fails, rollback undoes optimist update - onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - // Mutation finished, failed or successful - // Fetch anyway, just to be sure - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Updates existing entry - */ - const updateEntry = useCallback( - async (event: Partial) => { - try { - await _updateEntryMutation.mutateAsync(event); - } catch (error) { - logAxiosError('Error updating event', error); - } - }, - [_updateEntryMutation], - ); - - const updateCustomField = useCallback( - async (entryId: EntryId, field: string, value: string) => { - updateEntry({ id: entryId, custom: { [field]: value } }); - }, - [updateEntry], - ); - - /** - * Updates time of existing event - * @param eventId {EntryId} - id of the event - * @param field {TimeField} - field to update - * @param value {string} - new value string to be parsed - * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update - */ - const updateTimer = useCallback( - async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { - // an empty value with no lock has no domain validity - if (!lockOnUpdate && value === '') { - return; - } - - const newEvent: Partial = { - id: eventId, - }; - - // check if we should lock the field - if (lockOnUpdate) { - if (field === 'timeEnd') { - // an empty value indicates that we should unlock the field - newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd; - newEvent.timeEnd = value === '' ? undefined : calculateNewValue(); - } else if (field === 'duration') { - // an empty value indicates that we should unlock the field - newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration; - newEvent.duration = value === '' ? undefined : calculateNewValue(); - } else if (field === 'timeStart') { - // an empty values means we should link to the previous - newEvent.linkStart = value === ''; - newEvent.timeStart = value === '' ? undefined : calculateNewValue(); - } - } else { - newEvent[field] = calculateNewValue(); - } - - try { - await _updateEntryMutation.mutateAsync(newEvent); - } catch (error) { - logAxiosError('Error updating event', error); - } - - /** - * Utility function to calculate the new time value - */ - function calculateNewValue(): number { - let newValMillis = 0; - - // check for previous keyword - if (value === 'p' || value === 'prev' || value === 'previous') { - newValMillis = getPreviousEnd(); - - // check for adding time keyword - } else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) { - // TODO: is this logic solid? - const remainingString = value.substring(1); - newValMillis = getPreviousEnd() + parseUserTime(remainingString); - } else { - newValMillis = parseUserTime(value); - } - // dont allow timer values over 23:59:59 - return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND); - } - - /** - * Utility function to get the previous event end time - */ - function getPreviousEnd(): number { - const cachedRundown = queryClient.getQueryData(RUNDOWN); - - if (!cachedRundown?.order || !cachedRundown?.entries) { - return 0; - } - - const index = cachedRundown.order.indexOf(eventId); - if (index === 0) { - return 0; - } - let previousEnd = 0; - for (let i = index - 1; i >= 0; i--) { - const event = cachedRundown.entries[cachedRundown.order[i]]; - if (isOntimeEvent(event)) { - previousEnd = event.timeEnd; - break; - } - } - return previousEnd; - } - }, - [_updateEntryMutation, queryClient], - ); - - /** - * Calls mutation to edit multiple events - * @private - */ - const _batchUpdateEventsMutation = useMutation({ - mutationFn: putBatchEditEvents, - onMutate: async ({ ids, data }) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousRundown = queryClient.getQueryData(RUNDOWN); - - if (previousRundown) { - const eventIds = new Set(ids); - const newRundown = { ...previousRundown.entries }; - - eventIds.forEach((eventId) => { - if (Object.hasOwn(newRundown, eventId)) { - const event = newRundown[eventId]; - if (isOntimeEvent(event)) { - newRundown[eventId] = { - ...event, - ...data, - }; - } - } - }); - - queryClient.setQueryData(RUNDOWN, { - id: previousRundown.id, - title: previousRundown.title, - order: previousRundown.order, - flatOrder: previousRundown.flatOrder, - entries: newRundown, - revision: -1, - }); - } - - // Return a context with the previous rundown - return { previousRundown }; - }, - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousRundown); - }, - }); - - const batchUpdateEvents = useCallback( - async (data: Partial, eventIds: string[]) => { - try { - await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data }); - } catch (error) { - logAxiosError('Error updating events', error); - } - }, - [_batchUpdateEventsMutation], - ); - - /** - * Calls mutation to delete an entry - * @private - */ - const _deleteEntryMutation = useMutation({ - mutationFn: deleteEntries, - // we optimistically update here - onMutate: async (entryIds: EntryId[]) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - - if (previousData) { - // optimistically update object - const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData); - - queryClient.setQueryData(RUNDOWN, { - id: previousData.id, - title: previousData.title, - order, - flatOrder, - entries, - revision: -1, - }); - } - - // Return a context with the previous and new events - return { previousData }; - }, - - // Mutation fails, rollback undoes optimist update - onError: (_error, _entryIds, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - // Mutation finished, failed or successful - // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Deletes an event entry from the rundown - */ - const deleteEntry = useCallback( - async (entryIds: EntryId[]) => { - try { - await _deleteEntryMutation.mutateAsync(entryIds); - } catch (error) { - logAxiosError('Error deleting event', error); - } - }, - [_deleteEntryMutation], - ); - - /** - * Calls mutation to delete all events - * @private - */ - const _deleteAllEntriesMutation = useMutation({ - mutationFn: requestDeleteAll, - // we optimistically update here - onMutate: async () => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - - // optimistically update object - queryClient.setQueryData(RUNDOWN, { - id: previousData?.id ?? 'default', - title: previousData?.title ?? '', - order: [], - flatOrder: [], - entries: {}, - revision: -1, - }); - - // Return a context with the previous and new events - return { previousData }; - }, - - // Mutation fails, rollback optimist update - onError: (_error, _, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - // Mutation finished, failed or successful - // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Deletes all entries in the rundown - */ - const deleteAllEntries = useCallback(async () => { - try { - await _deleteAllEntriesMutation.mutateAsync(); - } catch (error) { - logAxiosError('Error deleting events', error); - } - }, [_deleteAllEntriesMutation]); - - /** - * Calls mutation to apply a delay - * @private - */ - const _applyDelayMutation = useMutation({ - mutationFn: requestApplyDelay, - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - // Mutation finished, failed or successful - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Applies a given delay - */ - const applyDelay = useCallback( - async (delayEventId: EntryId) => { - try { - await _applyDelayMutation.mutateAsync(delayEventId); - } catch (error) { - logAxiosError('Error applying delay', error); - } - }, - [_applyDelayMutation], - ); - - /** - * Calls mutation to dissolve a block - * @private - */ - const _ungroupMutation = useMutation({ - mutationFn: requestUngroup, - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), - }); - - /** - * Deletes a block and moves its events to the top level - */ - const ungroup = useCallback( - async (blockId: EntryId) => { - try { - await _ungroupMutation.mutateAsync(blockId); - } catch (error) { - logAxiosError('Error dissolving block', error); - } - }, - [_ungroupMutation], - ); - - /** - * Calls mutation to create a block with a selection - * @private - */ - const _groupEntriesMutation = useMutation({ - mutationFn: requestGroupEntries, - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), - }); - - /** - * Create a block with a selection - */ - const groupEntries = useCallback( - async (entryIds: EntryId[]) => { - try { - await _groupEntriesMutation.mutateAsync(entryIds); - } catch (error) { - logAxiosError('Error grouping entries', error); - } - }, - [_groupEntriesMutation], - ); - - /** - * Calls mutation to reorder an entry - * @private - */ - const _reorderEntryMutation = useMutation({ - mutationFn: patchReorderEntry, - // Mutation finished, failed or successful - // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Reorders a given entry - */ - const reorderEntry = useCallback( - async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { - try { - const reorderObject: ReorderEntry = { - entryId, - destinationId, - order, - }; - await _reorderEntryMutation.mutateAsync(reorderObject); - } catch (error) { - logAxiosError('Error re-ordering event', error); - } - }, - [_reorderEntryMutation], - ); - - const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => { - const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.order) { - return; - } - const { destinationId, order } = - direction === 'up' - ? moveUp(entryId, cachedRundown.order, cachedRundown.entries) - : moveDown(entryId, cachedRundown.order, cachedRundown.entries); - - if (destinationId) { - try { - const reorderObject: ReorderEntry = { - entryId, - destinationId, - order: order as 'before' | 'after' | 'insert', - }; - await _reorderEntryMutation.mutateAsync(reorderObject); - } catch (error) { - logAxiosError('Error re-ordering event', error); - } - } - }, []); - - /** - * Calls mutation to swap events - * @private - */ - const _swapEvents = useMutation({ - mutationFn: requestEventSwap, - // we optimistically update here - onMutate: async ({ from, to }) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - if (previousData) { - // optimistically update object - const newRundown = { ...previousData.entries }; - const eventA = previousData.entries[from]; - const eventB = previousData.entries[to]; - - if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { - return; - } - - const [newA, newB] = swapEventData(eventA, eventB); - newRundown[from] = newA; - newRundown[to] = newB; - - queryClient.setQueryData(RUNDOWN, { - id: previousData.id, - title: previousData.title, - order: previousData.order, - flatOrder: previousData.flatOrder, - entries: newRundown, - revision: -1, - }); - } - - // Return a context with the previous events - return { previousData }; - }, - - // Mutation fails, rollback undoes optimist update - onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - // Mutation finished, failed or successful - // Fetch anyway, just to be sure - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RUNDOWN }); - }, - }); - - /** - * Swaps the schedule of two events - */ - const swapEvents = useCallback( - async ({ from, to }: SwapEntry) => { - try { - await _swapEvents.mutateAsync({ from, to }); - } catch (error) { - logAxiosError('Error re-ordering event', error); - } - }, - [_swapEvents], - ); - - return { - addEntry, - applyDelay, - batchUpdateEvents, - clone, - deleteEntry, - deleteAllEntries, - ungroup, - getEntryById, - groupEntries, - move, - reorderEntry, - swapEvents, - updateEntry, - updateTimer, - updateCustomField, - }; -}; - -/** - * Utility to optimistically delete entries from client cache - */ -function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) { - const entries = { ...rundown.entries }; - let order = [...rundown.order]; - let flatOrder = [...rundown.flatOrder]; - - for (let i = 0; i < entryIds.length; i++) { - const entry = entries[entryIds[i]]; - deleteEntry(entry); - } - - function deleteEntry(entry: OntimeEntry) { - if (isOntimeBlock(entry) || !entry.parent) { - order = order.filter((id) => id !== entry.id); - } else { - const parent = entries[entry.parent] as OntimeBlock; - parent.events = parent.events.filter((event) => event !== entry.id); - } - - delete entries[entry.id]; - flatOrder = flatOrder.filter((id) => id !== entry.id); - } - - return { entries, order, flatOrder }; -} diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts new file mode 100644 index 000000000..3ebe479e5 --- /dev/null +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -0,0 +1,636 @@ +import { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + isOntimeEvent, + OntimeBlock, + OntimeDelay, + OntimeEvent, + OntimeRundownEntry, + RundownCached, + TimeField, + TimeStrategy, + TransientEventPayload, +} from 'ontime-types'; +import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; + +import { RUNDOWN } from '../api/constants'; +import { + ReorderEntry, + requestApplyDelay, + requestBatchPutEvents, + requestDelete, + requestDeleteAll, + requestEventSwap, + requestPostEvent, + requestPutEvent, + requestReorderEvent, + SwapEntry, +} from '../api/rundown'; +import { logAxiosError } from '../api/utils'; +import { useEditorSettings } from '../stores/editorSettings'; + +export type EventOptions = Partial<{ + // options to any new block (event / delay / block) + after: string; + before: string; + // options to blocks of type OntimeEvent + defaultPublic: boolean; + linkPrevious: boolean; + lastEventId: string; +}>; + +/** + * @description Set of utilities for events //TODO: should this be called useEntryAction and so on + */ +export const useEventAction = () => { + const queryClient = useQueryClient(); + const { + defaultPublic, + linkPrevious, + defaultTimeStrategy, + defaultDuration, + defaultWarnTime, + defaultDangerTime, + defaultTimerType, + defaultEndAction, + } = useEditorSettings(); + + const getEventById = useCallback( + (eventId: string) => { + const cachedRundown = queryClient.getQueryData(RUNDOWN); + if (!cachedRundown?.rundown) { + return; + } + return cachedRundown.rundown[eventId]; + }, + [queryClient], + ); + + /** + * Calls mutation to add new event + * @private + */ + const _addEventMutation = useMutation({ + mutationFn: requestPostEvent, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Adds an event to rundown + */ + const addEvent = useCallback( + async (event: Partial, options?: EventOptions) => { + const newEvent: TransientEventPayload = { ...event }; + + // ************* CHECK OPTIONS specific to events + if (isOntimeEvent(newEvent)) { + // merge creation time options with event settings + const applicationOptions = { + after: options?.after, + before: options?.before, + defaultPublic: options?.defaultPublic ?? defaultPublic, + lastEventId: options?.lastEventId, + linkPrevious: options?.linkPrevious ?? linkPrevious, + }; + + if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { + newEvent.linkStart = applicationOptions.lastEventId; + } else if (applicationOptions?.lastEventId) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value + const rundownData = queryClient.getQueryData(RUNDOWN)!; + const { rundown } = rundownData; + const previousEvent = rundown[applicationOptions.lastEventId]; + if (isOntimeEvent(previousEvent)) { + newEvent.timeStart = previousEvent.timeEnd; + } + } + + // Override event with options from editor settings + if (applicationOptions.defaultPublic) { + newEvent.isPublic = true; + } + + if (newEvent.duration === undefined && newEvent.timeEnd === undefined) { + newEvent.duration = parseUserTime(defaultDuration); + } + + if (newEvent.timeDanger === undefined) { + newEvent.timeDanger = parseUserTime(defaultDangerTime); + } + + if (newEvent.timeWarning === undefined) { + newEvent.timeWarning = parseUserTime(defaultWarnTime); + } + + if (newEvent.timerType === undefined) { + newEvent.timerType = defaultTimerType; + } + + if (newEvent.endAction === undefined) { + newEvent.endAction = defaultEndAction; + } + + if (newEvent.timeStrategy === undefined) { + newEvent.timeStrategy = defaultTimeStrategy; + } + } + + // handle adding options that concern all event type + if (options?.after) { + // @ts-expect-error -- not sure how to type this, is a transient property + newEvent.after = options.after; + } + if (options?.before) { + // @ts-expect-error -- not sure how to type this, is a transient property + newEvent.before = options.before; + } + + try { + await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); + } catch (error) { + logAxiosError('Failed adding event', error); + } + }, + [ + _addEventMutation, + defaultDangerTime, + defaultDuration, + defaultEndAction, + defaultPublic, + defaultTimerType, + defaultTimeStrategy, + defaultWarnTime, + linkPrevious, + queryClient, + ], + ); + + /** + * Calls mutation to update existing event + * @private + */ + const _updateEventMutation = useMutation({ + mutationFn: requestPutEvent, + // we optimistically update here + onMutate: async (newEvent) => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousData = queryClient.getQueryData(RUNDOWN); + const eventId = newEvent.id; + + if (previousData && eventId) { + // optimistically update object + const newRundown = { ...previousData.rundown }; + // @ts-expect-error -- we expect the events to be of same type + newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; + queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + } + + // Return a context with the previous and new events + return { previousData, newEvent }; + }, + // Mutation fails, rollback undoes optimist update + onError: (_error, _newEvent, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousData); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Updates existing event + */ + const updateEvent = useCallback( + async (event: Partial) => { + try { + await _updateEventMutation.mutateAsync(event); + } catch (error) { + logAxiosError('Error updating event', error); + } + }, + [_updateEventMutation], + ); + + const updateCustomField = useCallback( + async (eventId: string, field: string, value: string) => { + updateEvent({ id: eventId, custom: { [field]: value } }); + }, + [updateEvent], + ); + + /** + * Updates time of existing event + * @param eventId {string} - id of the event + * @param field {TimeField} - field to update + * @param value {string} - new value string to be parsed + * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update + */ + const updateTimer = useCallback( + async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { + // an empty value with no lock has no domain validity + if (!lockOnUpdate && value === '') { + return; + } + + const newEvent: Partial = { + id: eventId, + }; + + // check if we should lock the field + if (lockOnUpdate) { + if (field === 'timeEnd') { + // an empty value indicates that we should unlock the field + newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd; + newEvent.timeEnd = value === '' ? undefined : calculateNewValue(); + } else if (field === 'duration') { + // an empty value indicates that we should unlock the field + newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration; + newEvent.duration = value === '' ? undefined : calculateNewValue(); + } else if (field === 'timeStart') { + // an empty values means we should link to the previous + newEvent.linkStart = value === '' ? 'true' : null; + newEvent.timeStart = value === '' ? undefined : calculateNewValue(); + } + } else { + newEvent[field] = calculateNewValue(); + } + + try { + await _updateEventMutation.mutateAsync(newEvent); + } catch (error) { + logAxiosError('Error updating event', error); + } + + /** + * Utility function to calculate the new time value + */ + function calculateNewValue(): number { + let newValMillis = 0; + + // check for previous keyword + if (value === 'p' || value === 'prev' || value === 'previous') { + newValMillis = getPreviousEnd(); + + // check for adding time keyword + } else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) { + // TODO: is this logic solid? + const remainingString = value.substring(1); + newValMillis = getPreviousEnd() + parseUserTime(remainingString); + } else { + newValMillis = parseUserTime(value); + } + // dont allow timer values over 23:59:59 + return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND); + } + + /** + * Utility function to get the previous event end time + */ + function getPreviousEnd(): number { + const cachedRundown = queryClient.getQueryData(RUNDOWN); + + if (!cachedRundown?.order || !cachedRundown?.rundown) { + return 0; + } + + const index = cachedRundown.order.indexOf(eventId); + if (index === 0) { + return 0; + } + let previousEnd = 0; + for (let i = index - 1; i >= 0; i--) { + const event = cachedRundown.rundown[cachedRundown.order[i]]; + if (isOntimeEvent(event)) { + previousEnd = event.timeEnd; + break; + } + } + return previousEnd; + } + }, + [_updateEventMutation, queryClient], + ); + + /** + * Calls mutation to edit multiple events + * @private + */ + const _batchUpdateEventsMutation = useMutation({ + mutationFn: requestBatchPutEvents, + onMutate: async ({ ids, data }) => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousEvents = queryClient.getQueryData(RUNDOWN); + + if (previousEvents) { + const eventIds = new Set(ids); + const newRundown = { ...previousEvents.rundown }; + + eventIds.forEach((eventId) => { + if (Object.hasOwn(newRundown, eventId)) { + const event = newRundown[eventId]; + if (isOntimeEvent(event)) { + newRundown[eventId] = { + ...event, + ...data, + }; + } + } + }); + + queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); + } + // Return a context with the previous and new events + return { previousEvents }; + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + onError: (_error, _newEvent, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousEvents); + }, + networkMode: 'always', + }); + + const batchUpdateEvents = useCallback( + async (data: Partial, eventIds: string[]) => { + try { + await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data }); + } catch (error) { + logAxiosError('Error updating events', error); + } + }, + [_batchUpdateEventsMutation], + ); + + /** + * Calls mutation to delete an event + * @private + */ + const _deleteEventMutation = useMutation({ + mutationFn: requestDelete, + // we optimistically update here + onMutate: async (eventIds: string[]) => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousData = queryClient.getQueryData(RUNDOWN); + + if (previousData) { + // optimistically update object + const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); + const newRundown = { ...previousData.rundown }; + for (const eventId of eventIds) { + delete newRundown[eventId]; + } + + queryClient.setQueryData(RUNDOWN, { + order: newOrder, + rundown: newRundown, + revision: -1, + }); + } + + // Return a context with the previous and new events + return { previousData }; + }, + + // Mutation fails, rollback undoes optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousData); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Deletes an event form the list + */ + const deleteEvent = useCallback( + async (eventIds: string[]) => { + try { + await _deleteEventMutation.mutateAsync(eventIds); + } catch (error) { + logAxiosError('Error deleting event', error); + } + }, + [_deleteEventMutation], + ); + + /** + * Calls mutation to delete all events + * @private + */ + const _deleteAllEventsMutation = useMutation({ + mutationFn: requestDeleteAll, + // we optimistically update here + onMutate: async () => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousData = queryClient.getQueryData(RUNDOWN); + + // optimistically update object + queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); + + // Return a context with the previous and new events + return { previousData }; + }, + + // Mutation fails, rollback undos optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousData); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Deletes all events from list + */ + const deleteAllEvents = useCallback(async () => { + try { + await _deleteAllEventsMutation.mutateAsync(); + } catch (error) { + logAxiosError('Error deleting events', error); + } + }, [_deleteAllEventsMutation]); + + /** + * Calls mutation to apply a delay + * @private + */ + const _applyDelayMutation = useMutation({ + mutationFn: requestApplyDelay, + // Mutation finished, failed or successful + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Applies a given delay block + */ + const applyDelay = useCallback( + async (delayEventId: string) => { + try { + await _applyDelayMutation.mutateAsync(delayEventId); + } catch (error) { + logAxiosError('Error applying delay', error); + } + }, + [_applyDelayMutation], + ); + + /** + * Calls mutation to reorder an event + * @private + */ + const _reorderEventMutation = useMutation({ + mutationFn: requestReorderEvent, + // we optimistically update here + onMutate: async (data) => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousData = queryClient.getQueryData(RUNDOWN); + + if (previousData) { + // optimistically update object + const newOrder = reorderArray(previousData.order, data.from, data.to); + + queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); + } + + // Return a context with the previous and new events + return { previousData }; + }, + + // Mutation fails, rollback undoes optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousData); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Reorders a given event + */ + const reorderEvent = useCallback( + async (eventId: string, from: number, to: number) => { + try { + const reorderObject: ReorderEntry = { + eventId, + from, + to, + }; + await _reorderEventMutation.mutateAsync(reorderObject); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + }, + [_reorderEventMutation], + ); + + /** + * Calls mutation to swap events + * @private + */ + const _swapEvents = useMutation({ + mutationFn: requestEventSwap, + // we optimistically update here + onMutate: async ({ from, to }) => { + // cancel ongoing queries + await queryClient.cancelQueries({ queryKey: RUNDOWN }); + + // Snapshot the previous value + const previousData = queryClient.getQueryData(RUNDOWN); + if (previousData) { + // optimistically update object + const newRundown = { ...previousData.rundown }; + const eventA = previousData.rundown[from]; + const eventB = previousData.rundown[to]; + + if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { + return; + } + + const { newA, newB } = swapEventData(eventA, eventB); + newRundown[from] = newA; + newRundown[to] = newB; + + queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + } + + // Return a context with the previous events + return { previousData }; + }, + + // Mutation fails, rollback undoes optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN, context?.previousData); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RUNDOWN }); + }, + networkMode: 'always', + }); + + /** + * Swaps the schedule of two events + */ + const swapEvents = useCallback( + async ({ from, to }: SwapEntry) => { + try { + await _swapEvents.mutateAsync({ from, to }); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + }, + [_swapEvents], + ); + + return { + addEvent, + applyDelay, + batchUpdateEvents, + deleteEvent, + deleteAllEvents, + getEventById, + reorderEvent, + swapEvents, + updateEvent, + updateTimer, + updateCustomField, + }; +}; diff --git a/apps/client/src/common/hooks/useMemoisedFn.ts b/apps/client/src/common/hooks/useMemoisedFn.ts index b1a1545b5..179c00e0c 100644 --- a/apps/client/src/common/hooks/useMemoisedFn.ts +++ b/apps/client/src/common/hooks/useMemoisedFn.ts @@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any; type PickFunction = (this: ThisParameterType, ...args: Parameters) => ReturnType; -const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; +export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; export default function useMemoisedFn(fn: T) { if (isDev) { diff --git a/apps/client/src/common/hooks/useRuntimeStylesheet.js b/apps/client/src/common/hooks/useRuntimeStylesheet.js new file mode 100644 index 000000000..0c9164d94 --- /dev/null +++ b/apps/client/src/common/hooks/useRuntimeStylesheet.js @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +const scriptTagId = 'ontime-override'; +export const useRuntimeStylesheet = (pathToFile) => { + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + const fetchData = async () => { + const response = await fetch(pathToFile); + if (response.ok) { + return response.text(); + } + }; + + if (!pathToFile) { + document.getElementById(scriptTagId)?.remove(); + setShouldRender(true); + return; + } + + if (document.getElementById(scriptTagId)) { + setShouldRender(true); + return; + } + + setShouldRender(false); + const styleSheet = document.createElement('style'); + styleSheet.rel = 'stylesheet'; + styleSheet.setAttribute('id', scriptTagId); + + fetchData() + .then((data) => { + styleSheet.innerHTML = data; + document.head.append(styleSheet); + }) + .catch((error) => { + console.error(`Error loading stylesheet: ${error}`); + }) + .finally(() => { + // schedule render for next tick + setTimeout(() => setShouldRender(true), 0); + }); + }, [pathToFile]); + + return { shouldRender }; +}; diff --git a/apps/client/src/common/hooks/useRuntimeStylesheet.ts b/apps/client/src/common/hooks/useRuntimeStylesheet.ts deleted file mode 100644 index c757b7117..000000000 --- a/apps/client/src/common/hooks/useRuntimeStylesheet.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useState } from 'react'; - -const scriptTagId = 'ontime-override'; - -export const useRuntimeStylesheet = (pathToFile?: string): { shouldRender: boolean } => { - const [shouldRender, setShouldRender] = useState(false); - - /** - * When a view mounts or the stylesheet path changes we need to handle potentially loading a new stylesheet - * - if no path is given, ensure there is no stylesheet loaded - * - if a path is given, fetch the stylesheet and inject it into the document head - * @returns { shouldRender: boolean } - after the stylesheet is handled and the clients are ready to render - */ - useEffect(() => { - if (!pathToFile) { - handleNoStylesheet(); - return; - } - - // there is already a stylesheet loaded, nothing further to do - if (document.getElementById(scriptTagId)) { - setShouldRender(true); - return; - } - - setShouldRender(false); - - fetchStylesheetData(pathToFile) - .then((data: string | undefined) => { - if (!data) { - console.error('Error loading stylesheet: no data'); - return; - } - return injectStylesheet(data); - }) - .catch((error: unknown) => { - console.error(`Error loading stylesheet: ${error}`); - }) - .finally(() => { - // schedule render for next tick - setTimeout(() => setShouldRender(true), 0); - }); - - /** - * No stylesheet was provided, remove any existing stylesheet - */ - function handleNoStylesheet() { - document.getElementById(scriptTagId)?.remove(); - setShouldRender(true); - } - - /** - * Get data from backend - */ - async function fetchStylesheetData(path: string) { - const response = await fetch(path); - if (response.ok) { - return response.text(); - } - return undefined; - } - - /** - * Add a stylesheet with given content to the document head - */ - async function injectStylesheet(styleContent: string) { - const styleSheet = document.createElement('style'); - styleSheet.setAttribute('id', scriptTagId); - styleSheet.innerHTML = styleContent; - document.head.append(styleSheet); - } - }, [pathToFile]); - - return { shouldRender }; -}; diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 973c8b0b8..6bbd8634f 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -91,6 +91,14 @@ export const setPlayback = { }, }; +export const useInfoPanel = createSelector((state: RuntimeStore) => ({ + eventNow: state.eventNow, + eventNext: state.eventNext, + playback: state.timer.playback, + selectedEventIndex: state.runtime.selectedEventIndex, + numEvents: state.runtime.numEvents, +})); + export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ @@ -137,6 +145,8 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({ timeDanger: state.eventNow?.timeDanger ?? null, })); +export const setClientName = (newName: string) => socketSendJson('set-client-name', newName); + export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ plannedStart: state.runtime.plannedStart, actualStart: state.runtime.actualStart, diff --git a/apps/client/src/common/models/Info.ts b/apps/client/src/common/models/Info.ts index 6d6dd09a3..d05acdd2b 100644 --- a/apps/client/src/common/models/Info.ts +++ b/apps/client/src/common/models/Info.ts @@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types'; export const ontimePlaceholderInfo: GetInfo = { networkInterfaces: [], - version: '4.0.0', + version: '2.0.0', serverPort: 4001, publicDir: '', }; diff --git a/apps/client/src/common/models/OntimeSettings.ts b/apps/client/src/common/models/OntimeSettings.ts index 662a935c6..bb26409da 100644 --- a/apps/client/src/common/models/OntimeSettings.ts +++ b/apps/client/src/common/models/OntimeSettings.ts @@ -1,7 +1,8 @@ import { Settings } from 'ontime-types'; export const ontimePlaceholderSettings: Settings = { - version: '4.0.0', + app: 'ontime', + version: '2.0.0', serverPort: 4001, editorKey: null, operatorKey: null, diff --git a/apps/client/src/common/models/ProjectData.ts b/apps/client/src/common/models/ProjectData.ts index 8ee964be4..a57066cac 100644 --- a/apps/client/src/common/models/ProjectData.ts +++ b/apps/client/src/common/models/ProjectData.ts @@ -3,6 +3,8 @@ import { ProjectData } from 'ontime-types'; export const projectDataPlaceholder: ProjectData = { title: '', description: '', + publicUrl: '', + publicInfo: '', backstageUrl: '', backstageInfo: '', projectLogo: null, diff --git a/apps/client/src/common/queryClient.ts b/apps/client/src/common/queryClient.ts index c391abdfb..7da808d11 100644 --- a/apps/client/src/common/queryClient.ts +++ b/apps/client/src/common/queryClient.ts @@ -1,20 +1,9 @@ import { QueryClient } from '@tanstack/react-query'; -import { isOntimeCloud } from '../externals'; - export const ontimeQueryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 10, // 10 min }, - mutations: { - /** - * React Query detects whether the client is online - * However, web access is not required for the clients when deployed locally - * - use 'always' for clients that may be online - * - use 'online' for clients that are connected to the cloud - */ - networkMode: isOntimeCloud ? 'online' : 'always', - }, }, }); diff --git a/apps/client/src/common/stores/editorSettings.ts b/apps/client/src/common/stores/editorSettings.ts index 8a102f4c9..9439a25e5 100644 --- a/apps/client/src/common/stores/editorSettings.ts +++ b/apps/client/src/common/stores/editorSettings.ts @@ -10,6 +10,7 @@ type EditorSettingsStore = { defaultTimeStrategy: TimeStrategy; defaultWarnTime: string; defaultDangerTime: string; + defaultPublic: boolean; defaultTimerType: TimerType; defaultEndAction: EndAction; setDefaultDuration: (defaultDuration: string) => void; @@ -17,6 +18,7 @@ type EditorSettingsStore = { setTimeStrategy: (timeStrategy: TimeStrategy) => void; setWarnTime: (warnTime: string) => void; setDangerTime: (dangerTime: string) => void; + setDefaultPublic: (defaultPublic: boolean) => void; setDefaultTimerType: (defaultTimerType: TimerType) => void; setDefaultEndAction: (defaultEndAction: EndAction) => void; }; @@ -27,6 +29,7 @@ export const editorSettingsDefaults = { timeStrategy: TimeStrategy.LockDuration, warnTime: '00:02:00', // 120000 same as backend dangerTime: '00:01:00', // 60000 same as backend + isPublic: true, timerType: TimerType.CountDown, endAction: EndAction.None, }; @@ -37,6 +40,7 @@ enum EditorSettingsKeys { DefaultTimeStrategy = 'ontime-time-strategy', DefaultWarnTime = 'ontime-default-warn-time', DefaultDangerTime = 'ontime-default-danger-time', + DefaultPublic = 'ontime-default-public', DefaultTimerType = 'ontime-default-timer-type', DefaultEndAction = 'ontime-default-end-action', } @@ -51,6 +55,7 @@ export const useEditorSettings = create((set) => { ), defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime, defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime, + defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic), defaultTimerType: validateTimerType( localStorage.getItem(EditorSettingsKeys.DefaultTimerType), editorSettingsDefaults.timerType, @@ -87,6 +92,11 @@ export const useEditorSettings = create((set) => { localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime)); return { defaultDangerTime }; }), + setDefaultPublic: (defaultPublic) => + set(() => { + localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); + return { defaultPublic }; + }), setDefaultTimerType: (defaultTimerType) => set(() => { localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType)); diff --git a/apps/client/src/common/stores/logger.ts b/apps/client/src/common/stores/logger.ts index 3e919cf46..fb60d07c4 100644 --- a/apps/client/src/common/stores/logger.ts +++ b/apps/client/src/common/stores/logger.ts @@ -11,7 +11,7 @@ type LogStore = { logs: Log[]; }; -const logger = createStore(() => ({ +export const logger = createStore(() => ({ logs: [], })); diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index e8a291932..70ac4c993 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -14,6 +14,18 @@ export const runtimeStore = createWithEqualityFn( export const useRuntimeStore = (selector: (state: RuntimeStore) => T) => useStoreWithEqualityFn(runtimeStore, selector, deepCompare); +let batchStore: Partial = {}; + +export function addToBatchUpdates(key: K, value: RuntimeStore[K]) { + batchStore[key] = value; +} + +export function flushBatchUpdates() { + const state = runtimeStore.getState(); + runtimeStore.setState({ ...state, ...batchStore }); + batchStore = {}; +} + /** * Allows patching a property of the runtime store */ diff --git a/apps/client/src/common/utils/__tests__/csv.test.ts b/apps/client/src/common/utils/__tests__/csv.test.ts index 2b5fbc562..13bd6b1b5 100644 --- a/apps/client/src/common/utils/__tests__/csv.test.ts +++ b/apps/client/src/common/utils/__tests__/csv.test.ts @@ -1,6 +1,4 @@ -import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types'; - -import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv'; +import { makeCSVFromArrayOfArrays } from '../csv'; describe('makeCSVFromArrayOfArrays()', () => { it('joins an array of arrays with commas and newlines', () => { @@ -13,34 +11,3 @@ after newline,after comma `); }); }); - -describe('aggregateRundowns()', () => { - it('flattens an object of rundowns into a single array', () => { - const rundowns = { - first: { - id: '', - title: '', - revision: 0, - order: ['1', '2'], - flatOrder: ['1', '2'], - entries: { - '1': { id: '1' } as OntimeEntry, - '2': { id: '2' } as OntimeEntry, - }, - }, - second: { - id: '', - title: '', - revision: 0, - order: ['3', '4'], - flatOrder: ['3', '4'], - entries: { - '3': { id: '3' } as OntimeEntry, - '4': { id: '4' } as OntimeEntry, - }, - } as Rundown, - } as ProjectRundowns; - - expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]); - }); -}); diff --git a/apps/client/src/common/utils/__tests__/dateConfig.test.ts b/apps/client/src/common/utils/__tests__/dateConfig.test.js similarity index 100% rename from apps/client/src/common/utils/__tests__/dateConfig.test.ts rename to apps/client/src/common/utils/__tests__/dateConfig.test.js diff --git a/apps/client/src/common/utils/__tests__/clone.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts similarity index 74% rename from apps/client/src/common/utils/__tests__/clone.test.ts rename to apps/client/src/common/utils/__tests__/eventsManager.test.ts index adf213ee0..a2781d3e3 100644 --- a/apps/client/src/common/utils/__tests__/clone.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -1,12 +1,12 @@ -import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types'; +import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; -import { cloneEvent } from '../clone'; +import { cloneEvent } from '../eventsManager'; describe('cloneEvent()', () => { it('creates a stem from a given event', () => { const original: OntimeEvent = { id: 'unique', - type: SupportedEntry.Event, + type: SupportedEvent.Event, title: 'title', cue: 'cue', note: 'note', @@ -15,10 +15,10 @@ describe('cloneEvent()', () => { timeEnd: 10, timerType: TimerType.CountDown, timeStrategy: TimeStrategy.LockEnd, - parent: 'test', - linkStart: false, + linkStart: null, countToEnd: false, endAction: EndAction.None, + isPublic: false, skip: false, colour: 'F00', revision: 10, @@ -27,19 +27,17 @@ describe('cloneEvent()', () => { delay: 0, dayOffset: 0, gap: 0, - triggers: [], custom: { lighting: '3', - } as EntryCustomFields, + } as EventCustomFields, }; const cloned = cloneEvent(original); expect(cloned).not.toBe(original); expect(cloned.custom).not.toBe(original.custom); - expect(cloned.triggers).not.toBe(original.triggers); expect(cloned).toMatchObject({ - type: SupportedEntry.Event, + type: SupportedEvent.Event, title: original.title, note: original.note, timeStart: original.timeStart, @@ -47,10 +45,10 @@ describe('cloneEvent()', () => { timeEnd: original.timeEnd, timerType: original.timerType, timeStrategy: original.timeStrategy, - parent: 'test', countToEnd: original.countToEnd, linkStart: original.linkStart, endAction: original.endAction, + isPublic: original.isPublic, skip: original.skip, colour: original.colour, revision: 0, @@ -59,8 +57,6 @@ describe('cloneEvent()', () => { gap: 0, timeWarning: original.timeWarning, timeDanger: original.timeDanger, - triggers: original.triggers, - custom: original.custom, }); }); }); diff --git a/apps/client/src/common/utils/__tests__/math.test.ts b/apps/client/src/common/utils/__tests__/math.test.js similarity index 100% rename from apps/client/src/common/utils/__tests__/math.test.ts rename to apps/client/src/common/utils/__tests__/math.test.js diff --git a/apps/client/src/common/utils/__tests__/urlPresets.test.ts b/apps/client/src/common/utils/__tests__/urlPresets.test.ts index dbdcc8253..7b127d703 100644 --- a/apps/client/src/common/utils/__tests__/urlPresets.test.ts +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.ts @@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => { describe('handle url sharing edge cases', () => { it('finds the correct preset when the url contains extra arguments', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy(); - }); + expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() + }) it('appends the feature params to the alias', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); - }); + expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') + }) }); }); @@ -83,26 +83,25 @@ describe('generatePathFromPreset()', () => { }); test('appends the feature params to the alias', () => { - expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe( - 'timer?user=guest&alias=demopage&locked=true&token=123', - ); + expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); }); }); describe('arePathsEquivalent()', () => { - it('checks whether the paths match', () => { + it("checks whether the paths match", () => { expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); - }); + }) - it('checks whether the params match', () => { + it("checks whether the params match", () => { expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); - }); + }) - it('considers edge cases for the url sharing feature', () => { + it("considers edge cases for the url sharing feature", () => { expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); - }); + }) }); + diff --git a/apps/client/src/common/utils/csv.ts b/apps/client/src/common/utils/csv.ts index 055c0c9d6..527e7a5f2 100644 --- a/apps/client/src/common/utils/csv.ts +++ b/apps/client/src/common/utils/csv.ts @@ -1,31 +1,10 @@ import { stringify } from 'csv-stringify/browser/esm/sync'; -import { OntimeEntry, ProjectRundowns } from 'ontime-types'; /** - * Converts an array of arrays to a CSV file + * @description Converts an array of arrays to a CSV file + * @param {string[][]} arrayOfArrays + * @return {string} */ export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { return stringify(arrayOfArrays); } - -/** - * Receives an object of rundowns, and flattens them into a single, linear rundown - * Used for CSV export - */ -export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] { - const rundownKeys = Object.keys(rundowns); - if (rundownKeys.length === 0) return []; - const flatRundown: OntimeEntry[] = []; - - for (const key of rundownKeys) { - const { order, entries } = rundowns[key]; - - for (let i = 0; i < order.length; i++) { - const entryId = order[i]; - const entry = entries[entryId]; - - flatRundown.push(entry); - } - } - return flatRundown; -} diff --git a/apps/client/src/common/utils/clone.ts b/apps/client/src/common/utils/eventsManager.ts similarity index 80% rename from apps/client/src/common/utils/clone.ts rename to apps/client/src/common/utils/eventsManager.ts index 8ef98e545..51aeac470 100644 --- a/apps/client/src/common/utils/clone.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -1,4 +1,4 @@ -import { OntimeEvent, SupportedEntry } from 'ontime-types'; +import { OntimeEvent, SupportedEvent } from 'ontime-types'; /** * @description Creates a safe duplicate of an event @@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEntry } from 'ontime-types'; type ClonedEvent = Omit; export const cloneEvent = (event: OntimeEvent): ClonedEvent => { return { - type: SupportedEntry.Event, + type: SupportedEvent.Event, title: event.title, note: event.note, timeStart: event.timeStart, @@ -20,16 +20,15 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => { countToEnd: event.countToEnd, linkStart: event.linkStart, endAction: event.endAction, + isPublic: event.isPublic, skip: event.skip, colour: event.colour, - parent: event.parent, revision: 0, delay: event.delay, // the events will be collocated, so having the same metadata is a good start dayOffset: event.dayOffset, gap: 0, timeWarning: event.timeWarning, timeDanger: event.timeDanger, - triggers: structuredClone(event.triggers), - custom: structuredClone(event.custom), + custom: { ...event.custom }, }; }; diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index e6e809593..6fd92475c 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,4 +1,4 @@ -import { Log, Rundown, RuntimeStore } from 'ontime-types'; +import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { isProduction, websocketUrl } from '../../externals'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; @@ -14,12 +14,13 @@ import { } from '../stores/clientStore'; import { addDialog } from '../stores/dialogStore'; import { addLog } from '../stores/logger'; -import { patchRuntime, patchRuntimeProperty } from '../stores/runtime'; +import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; -let websocket: WebSocket | null = null; +export let websocket: WebSocket | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const reconnectInterval = 1000; +export let shouldReconnect = true; export let hasConnected = false; export let reconnectAttempts = 0; @@ -48,17 +49,18 @@ export const connectSocket = () => { websocket.onclose = () => { console.warn('WebSocket disconnected'); - // we decide to allows reconnect - reconnectTimeout = setTimeout(() => { - if (reconnectAttempts > 2) { - setOnlineStatus(false); - } - console.warn('WebSocket: attempting reconnect'); - if (websocket && websocket.readyState === WebSocket.CLOSED) { - reconnectAttempts += 1; - connectSocket(); - } - }, reconnectInterval); + if (shouldReconnect) { + reconnectTimeout = setTimeout(() => { + if (reconnectAttempts > 2) { + setOnlineStatus(false); + } + console.warn('WebSocket: attempting reconnect'); + if (websocket && websocket.readyState === WebSocket.CLOSED) { + reconnectAttempts += 1; + connectSocket(); + } + }, reconnectInterval); + } }; websocket.onerror = (error) => { @@ -141,10 +143,59 @@ export const connectSocket = () => { updateDevTools(serverPayload); break; } - case 'ontime-patch': { - const patch = payload as Partial; - patchRuntime(patch); - updateDevTools(patch); + case 'ontime-clock': { + addToBatchUpdates('clock', payload); + updateDevTools({ clock: payload }); + break; + } + case 'ontime-timer': { + addToBatchUpdates('timer', payload); + updateDevTools({ timer: payload }); + break; + } + case 'ontime-onAir': { + addToBatchUpdates('onAir', payload); + updateDevTools({ onAir: payload }); + break; + } + case 'ontime-message': { + addToBatchUpdates('message', payload); + updateDevTools({ message: payload }); + break; + } + case 'ontime-runtime': { + addToBatchUpdates('runtime', payload); + updateDevTools({ runtime: payload }); + break; + } + case 'ontime-eventNow': { + addToBatchUpdates('eventNow', payload); + updateDevTools({ eventNow: payload }); + break; + } + case 'ontime-currentBlock': { + addToBatchUpdates('currentBlock', payload); + updateDevTools({ currentBlock: payload }); + break; + } + case 'ontime-publicEventNow': { + addToBatchUpdates('publicEventNow', payload); + updateDevTools({ publicEventNow: payload }); + break; + } + case 'ontime-eventNext': { + addToBatchUpdates('eventNext', payload); + updateDevTools({ eventNext: payload }); + break; + } + case 'ontime-publicEventNext': { + addToBatchUpdates('publicEventNext', payload); + updateDevTools({ publicEventNext: payload }); + break; + } + case 'ontime-auxtimer1': { + addToBatchUpdates('auxtimer1', payload); + updateDevTools({ auxtimer1: payload }); break; } case 'ontime-refetch': { @@ -154,7 +205,7 @@ export const connectSocket = () => { invalidateAllCaches(); } else if (target === 'RUNDOWN') { const { revision } = payload; - const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; + const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; if (revision > currentRevision) { ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); @@ -164,8 +215,8 @@ export const connectSocket = () => { } break; } - default: { - console.log('unknown WS message', type); + case 'ontime-flush': { + flushBatchUpdates(); break; } } @@ -175,6 +226,11 @@ export const connectSocket = () => { }; }; +export const disconnectSocket = () => { + shouldReconnect = false; + websocket?.close(); +}; + export const socketSend = (message: any) => { if (websocket && websocket.readyState === WebSocket.OPEN) { websocket.send(message); diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 760be0f54..fc1c42dce 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -35,7 +35,7 @@ function getFormatFromParams() { * Gets the format options from the applicaton settings * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss */ -function getFormatFromSettings(): TimeFormat { +export function getFormatFromSettings(): TimeFormat { const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); return settings?.timeFormat ?? '24'; } @@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string { } if (!hideSeconds) { - const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); + const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); if (seconds > 0) { result += `${seconds}s`; } diff --git a/apps/client/src/declarations/test.d.ts b/apps/client/src/declarations/test.d.ts new file mode 100644 index 000000000..bf28ed90f --- /dev/null +++ b/apps/client/src/declarations/test.d.ts @@ -0,0 +1,11 @@ +import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; + +import 'vitest'; + +// ugly hack because vite and pnpm are not playing ball with jest +// https://github.com/testing-library/jest-dom/issues/123 +declare global { + namespace Vi { + type Assertion = TestingLibraryMatchers; + } +} diff --git a/apps/client/src/externals.ts b/apps/client/src/externals.ts index d2ed0bced..0d8454496 100644 --- a/apps/client/src/externals.ts +++ b/apps/client/src/externals.ts @@ -49,7 +49,10 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) { url.pathname = baseURI ? `${baseURI}/${path}` : path; // in development mode, we use the React port for UI, but need the requests to target the server - // this is done with a proxy in the vite config to avoid CORS issues in the dev environment + if (isDev) { + // this is used as a fallback port for development + url.port = '4001'; + } const result = url.toString(); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts index 892946389..64d650e49 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -31,6 +31,7 @@ const staticSelectProperties = [ { value: 'eventNow.title', label: 'Title' }, { value: 'eventNow.cue', label: 'Cue' }, { value: 'eventNow.countToEnd', label: 'Count to end' }, + { value: 'eventNow.isPublic', label: 'Is public' }, { value: 'eventNow.note', label: 'Note' }, { value: 'eventNow.colour', label: 'Colour' }, ]; diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts index bbc8e389b..d911709f7 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts @@ -38,6 +38,7 @@ const eventStaticPropertiesNow = [ '{{eventNow.timeStart}}', '{{eventNow.timeEnd}}', '{{eventNow.duration}}', + '{{eventNow.isPublic}}', '{{eventNow.colour}}', '{{eventNow.delay}}', ]; @@ -50,6 +51,7 @@ const eventStaticPropertiesNext = [ '{{eventNext.timeStart}}', '{{eventNext.timeEnd}}', '{{eventNext.duration}}', + '{{eventNext.isPublic}}', '{{eventNext.colour}}', '{{eventNext.delay}}', ]; diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx index 0d0926b1f..090cc1d4a 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx @@ -29,8 +29,8 @@ export default function ReportSettings() { }; const combinedReport = useMemo(() => { - return getCombinedReport(reportData, data.entries, data.order); - }, [reportData, data.entries, data.order]); + return getCombinedReport(reportData, data.rundown, data.order); + }, [reportData, data.rundown, data.order]); return ( diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx index 351beadec..b99435319 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { IoPencil, IoTrash } from 'react-icons/io5'; import { IconButton } from '@chakra-ui/react'; -import { CustomField, CustomFieldKey } from 'ontime-types'; +import { CustomField, CustomFieldLabel } from 'ontime-types'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import Swatch from '../../../../../common/components/input/colour-input/Swatch'; @@ -17,8 +17,8 @@ interface CustomFieldEntryProps { label: string; fieldKey: string; type: 'string' | 'image'; - onEdit: (key: CustomFieldKey, patch: CustomField) => Promise; - onDelete: (key: CustomFieldKey) => Promise; + onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise; + onDelete: (label: CustomFieldLabel) => Promise; } export default function CustomFieldEntry(props: CustomFieldEntryProps) { diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx index 243a2d6f0..151ff95bd 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; -import { CustomField, CustomFieldKey } from 'ontime-types'; +import { CustomField, CustomFieldLabel } from 'ontime-types'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import Info from '../../../../../common/components/info/Info'; @@ -31,14 +31,14 @@ export default function CustomFields() { setIsAdding(false); }; - const handleEditField = async (key: CustomFieldKey, customField: CustomField) => { - await editCustomField(key, customField); + const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => { + await editCustomField(label, customField); refetch(); }; - const handleDelete = async (key: CustomFieldKey) => { + const handleDelete = async (label: string) => { try { - await deleteCustomField(key); + await deleteCustomField(label); refetch(); } catch (_error) { /** we do not handle errors here */ diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts index d47ee4f9a..0370baa00 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts @@ -1,4 +1,4 @@ -import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types'; +import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { formatTime } from '../../../../common/utils/time'; @@ -16,7 +16,7 @@ export type CombinedReport = { /** * Creates a combined report with the rundown data */ -export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] { +export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { if (Object.keys(report).length === 0) return []; if (order.length === 0) return []; diff --git a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx index e041094c1..47fca788c 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx @@ -13,6 +13,10 @@ import * as Panel from '../../panel-utils/PanelUtils'; import GeneralPinInput from './GeneralPinInput'; +export type GeneralPanelFormValues = { + filename: string; +}; + export default function GeneralPanelForm() { const { data, status, refetch } = useSettings(); const { diff --git a/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss index 75dc36904..8eb0cb467 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss +++ b/apps/client/src/features/app-settings/panel/general-panel/StyleEditorModal.module.scss @@ -8,5 +8,7 @@ } .column { + align-items: start; + display: flex; flex-direction: column; } diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index c7fdcaa54..de277363e 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -123,7 +123,7 @@ export default function ViewSettingsForm() { onClick={onCodeEditorOpen} variant='ontime-subtle' size='sm' - isDisabled={isSubmitting} + isDisabled={!data.overrideStyles} width='fit-content' > Edit CSS override diff --git a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx index a080e0956..f6bcd00f6 100644 --- a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx @@ -13,6 +13,7 @@ export default function EditorSettingsForm() { defaultTimeStrategy, defaultWarnTime, defaultDangerTime, + defaultPublic, defaultTimerType, defaultEndAction, setDefaultDuration, @@ -20,6 +21,7 @@ export default function EditorSettingsForm() { setTimeStrategy, setWarnTime, setDangerTime, + setDefaultPublic, setDefaultTimerType, setDefaultEndAction, } = useEditorSettings((state) => state); @@ -100,6 +102,7 @@ export default function EditorSettingsForm() { onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} > + @@ -125,6 +128,17 @@ export default function EditorSettingsForm() { /> + + + + setDefaultPublic(event.target.checked)} + /> + + Run mode diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx index f0cc243c8..e7c1deee2 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx @@ -8,7 +8,7 @@ import { PROJECT_LIST } from '../../../../common/api/constants'; import { createProject } from '../../../../common/api/db'; import { maybeAxiosError } from '../../../../common/api/utils'; import { preventEscape } from '../../../../common/utils/keyEvent'; -import { documentationUrl } from '../../../../externals'; +import { documentationUrl, websiteUrl } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; import style from './ProjectPanel.module.scss'; @@ -20,6 +20,8 @@ interface ProjectCreateFromProps { type ProjectCreateFormValues = { title?: string; description?: string; + publicInfo?: string; + publicUrl?: string; backstageInfo?: string; backstageUrl?: string; custom?: { title: string; value: string }[]; @@ -118,6 +120,28 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) { {...register('description')} /> +