From 6c6f5c2c0f380450725731efb0ed79c722dd3032 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 22 Jul 2025 05:45:20 +0200 Subject: [PATCH] feat: cuesheet sharing feat: locked presets refactor: simplify locked param refactor: create share links --- apps/client/src/AppRouter.tsx | 128 +++++++- apps/client/src/common/api/session.ts | 11 +- .../common/components/modal/Modal.module.scss | 2 +- .../navigation-menu/ViewNavigationMenu.tsx | 11 +- .../navigation-menu/useViewEditor.tsx | 23 -- .../radio-group/RadioGroup.module.scss | 7 + .../state/EmptyTableBody.module.scss | 1 + .../view-params-editor/ViewParamsEditor.tsx | 11 +- .../src/common/context/PresetContext.tsx | 4 + .../common/stores/useCuesheetLinksStore.ts | 89 ++++++ .../common/utils/__tests__/urlPresets.test.ts | 48 ++- apps/client/src/common/utils/regex.ts | 1 + apps/client/src/common/utils/urlPresets.ts | 131 ++++---- apps/client/src/declarations/declaration.d.ts | 6 + apps/client/src/externals.ts | 23 ++ .../panel-utils/PanelUtils.module.scss | 1 + .../src/features/operator/OperatorExport.tsx | 3 +- .../rundown/rundown-header/RundownMenu.tsx | 2 +- .../sharing/GenerateLinkForm.module.scss | 14 +- .../src/features/sharing/GenerateLinkForm.tsx | 292 +++++++++++++----- .../sharing/GenerateLinkFormExport.tsx | 34 +- .../composite/CuesheetLinkOptions.module.scss | 18 ++ .../sharing/composite/CuesheetLinkOptions.tsx | 187 +++++++++++ .../src/views/backstage/backstage.options.ts | 21 +- .../common/not-found/NotFound.module.scss | 11 + .../src/views/common/not-found/NotFound.tsx | 19 ++ .../src/views/countdown/countdown.options.ts | 31 +- .../src/views/cuesheet/CuesheetPage.tsx | 7 +- .../views/cuesheet/CuesheetTableWrapper.tsx | 46 ++- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 17 +- .../cuesheet-table-elements/BlockRow.tsx | 14 +- .../cuesheet-table-elements/CuesheetBody.tsx | 2 +- .../CuesheetHeader.tsx | 10 +- .../cuesheet-table-elements/EventRow.tsx | 16 +- .../cuesheet-table-elements/MilestoneRow.tsx | 14 +- .../cuesheetColsFactory.tsx | 160 ++++++---- .../cuesheet-table-menu/CuesheetTableMenu.tsx | 19 +- .../CuesheetShareModal.tsx | 5 +- .../CuesheetTableSettings.tsx | 20 +- .../src/views/cuesheet/cuesheet.options.ts | 20 ++ .../views/cuesheet/useTablePermissions.tsx | 27 ++ .../client/src/views/studio/studio.options.ts | 19 +- .../src/views/timeline/timeline.options.ts | 21 +- apps/client/src/views/timer/timer.options.ts | 55 ++-- .../api-data/db/migration/db.migration.v3.ts | 3 +- .../api-data/db/migration/migration.test.ts | 15 +- .../session/__tests__/session.service.test.ts | 156 ++++++++-- .../src/api-data/session/session.router.ts | 12 +- .../src/api-data/session/session.service.ts | 23 +- .../api-data/session/session.validation.ts | 9 +- .../api-data/url-presets/urlPresets.parser.ts | 7 +- .../api-data/url-presets/urlPresets.router.ts | 3 +- .../url-presets/urlPresets.validation.ts | 8 + apps/server/src/middleware/authenticate.ts | 6 +- apps/server/src/models/demoProject.ts | 11 +- e2e/tests/002-view-navigation.spec.ts | 12 + .../features/201-message-control.spec.ts | 4 + e2e/tests/features/206-url-preset.spec.ts | 232 ++++++++++++-- e2e/tests/features/207-view-params.spec.ts | 3 +- .../BackendResponse.type.ts | 8 + .../src/definitions/core/UrlPreset.type.ts | 21 +- packages/types/src/index.ts | 5 +- 62 files changed, 1661 insertions(+), 478 deletions(-) delete mode 100644 apps/client/src/common/components/navigation-menu/useViewEditor.tsx create mode 100644 apps/client/src/common/context/PresetContext.tsx create mode 100644 apps/client/src/common/stores/useCuesheetLinksStore.ts create mode 100644 apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss create mode 100644 apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx create mode 100644 apps/client/src/views/common/not-found/NotFound.module.scss create mode 100644 apps/client/src/views/common/not-found/NotFound.tsx create mode 100644 apps/client/src/views/cuesheet/useTablePermissions.tsx create mode 100644 packages/types/src/api/session-controller/BackendResponse.type.ts diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index a2e39d87b..4c372d62c 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -1,14 +1,17 @@ -import { ComponentType, lazy, Suspense, useMemo } from 'react'; -import { Navigate, Route, useLocation } from 'react-router'; -import { OntimeView, OntimeViewPresettable } from 'ontime-types'; +import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react'; +import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; +import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; +import { PresetContext } from './common/context/PresetContext'; import { useClientPath } from './common/hooks/useClientPath'; import useUrlPresets from './common/hooks-query/useUrlPresets'; +import { getRouteFromPreset } from './common/utils/urlPresets'; import Log from './features/log/Log'; import Loader from './views/common/loader/Loader'; import NotFound from './views/common/not-found/NotFound'; import ViewLoader from './views/ViewLoader'; +import { getIsViewLocked, sessionScope } from './externals'; import { initializeSentry } from './sentry.config'; const Timer = lazy(() => import('./views/timer/Timer')); @@ -42,7 +45,7 @@ export default function AppRouter() { path='timer' element={ - + } @@ -51,7 +54,7 @@ export default function AppRouter() { path='countdown' element={ - + } @@ -60,7 +63,7 @@ export default function AppRouter() { path='backstage' element={ - + } @@ -69,7 +72,7 @@ export default function AppRouter() { path='studio' element={ - + } @@ -78,7 +81,7 @@ export default function AppRouter() { path='timeline' element={ - + } @@ -87,12 +90,11 @@ export default function AppRouter() { path='info' element={ - + } /> - {/*/!* Protected Routes *!/*/} } /> } /> @@ -104,7 +106,6 @@ export default function AppRouter() { } /> - {/*/!* Protected Routes - Elements *!/*/} } /> + {/** + * If the views are prefixed with the "preset" path, we are in a locked preset + * Locked presets do not expose their parameters + */} + } /> + + {/** + * If we havent matched any views or presets, we may be in an unlocked preset + * Unlocked presets are unwrapped to expose their target and parameters + */} + } /> ); } + +const PresetViewMap: Record = { + [OntimeView.Cuesheet]: Cuesheet, + [OntimeView.Operator]: Operator, + [OntimeView.Timer]: Timer, + [OntimeView.Backstage]: Backstage, + [OntimeView.Timeline]: Timeline, + [OntimeView.StudioClock]: StudioClock, + [OntimeView.Countdown]: Countdown, + [OntimeView.ProjectInfo]: ProjectInfo, +}; + +/** + * This view will mask a configured canonical route + * and inject the preset search parameters to context + * User are not able to configure the parameters locked presets + */ +function PresetView() { + const { data, status } = useUrlPresets(); + const { alias } = useParams(); + + const preset: URLPreset | undefined = useMemo(() => { + if (status === 'pending' || !alias) return; + return data.find((p) => p.alias === alias && p.enabled); + }, [data, status, alias]); + + if (status === 'pending') { + return ; + } + + /** + * We need to check the session scope to determine if the user can navigate + * If the user has a global scope, they can navigate freely + * Otherwise, they are locked to the preset view + */ + const showNav = sessionScope === 'rw'; + + /** + * If we are in a preset path but cannot find a preset, we will need to show a not found page + * This can happen if the preset was deleted or disabled + */ + if (!preset) { + return ( + <> + + + + ); + } + + /** + * Locked presets do not allow configuration changes + * Whether the user can navigate is determined by the locked param + */ + const Component = PresetViewMap[preset.target as OntimeViewPresettable]; + return ( + + {preset.target !== OntimeView.Cuesheet && ( + + )} + {Component ? : } + + ); +} + +function RedirectPreset() { + const { data, status } = useUrlPresets(); + const navigate = useNavigate(); + const location = useLocation(); + + // checks if we are in a preset path and resolves a destination URL + const destination = useMemo(() => { + if (status === 'pending') return null; + return getRouteFromPreset(location, data); + }, [data, location, status]); + + // if we have a destination, we will navigate to it + useEffect(() => { + if (destination) { + navigate(`/${destination}`, { replace: true }); + } + }, [destination, navigate]); + + if (status === 'pending') { + return ; + } + + return ( + <> + + + + ); +} diff --git a/apps/client/src/common/api/session.ts b/apps/client/src/common/api/session.ts index 2449cf18e..ae71fa512 100644 --- a/apps/client/src/common/api/session.ts +++ b/apps/client/src/common/api/session.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { GetInfo } from 'ontime-types'; +import { GetInfo, LinkOptions } from 'ontime-types'; import { apiEntryUrl } from './constants'; @@ -16,12 +16,7 @@ export async function getInfo(): Promise { /** * HTTP request to get a pre-authenticated URL */ -export async function generateUrl( - baseUrl: string, - path: string, - lock: boolean, - authenticate: boolean, -): Promise { - const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate }); +export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise { + const res = await axios.post(`${sessionPath}/url`, options); return res.data.url; } diff --git a/apps/client/src/common/components/modal/Modal.module.scss b/apps/client/src/common/components/modal/Modal.module.scss index 8c1c43760..81951ae0c 100644 --- a/apps/client/src/common/components/modal/Modal.module.scss +++ b/apps/client/src/common/components/modal/Modal.module.scss @@ -7,7 +7,7 @@ padding-inline: 1rem; min-width: min(680px, 90vw); min-height: min(200px, 10vh); - max-width: min(800px, 90vw); + max-width: min(900px, 90vw); background-color: $gray-1250; color: $ui-white; diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx index 733cd1f54..df039d939 100644 --- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx @@ -1,20 +1,23 @@ import { memo } from 'react'; import { useDisclosure, useHotkeys } from '@mantine/hooks'; +import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; + import FloatingNavigation from './floating-navigation/FloatingNavigation'; import ViewLockedIcon from './view-locked-icon/ViewLockedIcon'; import NavigationMenu from './NavigationMenu'; -import useViewEditor from './useViewEditor'; interface ViewNavigationMenuProps { - isLockable?: boolean; + /** prevent navigation and settings*/ + isViewLocked?: boolean; + /** prevent showing settings */ suppressSettings?: boolean; } export default memo(ViewNavigationMenu); -function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) { +function ViewNavigationMenu({ isViewLocked, suppressSettings }: ViewNavigationMenuProps) { const [isMenuOpen, menuHandler] = useDisclosure(); - const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable }); + const { open: showEditFormDrawer } = useViewParamsEditorStore(); useHotkeys([ [ diff --git a/apps/client/src/common/components/navigation-menu/useViewEditor.tsx b/apps/client/src/common/components/navigation-menu/useViewEditor.tsx deleted file mode 100644 index e05716094..000000000 --- a/apps/client/src/common/components/navigation-menu/useViewEditor.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useMemo } from 'react'; -import { useSearchParams } from 'react-router'; - -import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; -import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; - -interface EditorVisibilityOptions { - isLockable?: boolean; -} - -export default function useViewEditor({ isLockable }: EditorVisibilityOptions) { - const [searchParams] = useSearchParams(); - const { open: showEditFormDrawer } = useViewParamsEditorStore(); - - const isViewLocked = useMemo(() => { - if (!isLockable) { - return false; - } - return isStringBoolean(searchParams.get('locked')); - }, [isLockable, searchParams]); - - return { showEditFormDrawer, isViewLocked }; -} diff --git a/apps/client/src/common/components/radio-group/RadioGroup.module.scss b/apps/client/src/common/components/radio-group/RadioGroup.module.scss index d6dcb0824..e2d1de528 100644 --- a/apps/client/src/common/components/radio-group/RadioGroup.module.scss +++ b/apps/client/src/common/components/radio-group/RadioGroup.module.scss @@ -3,6 +3,13 @@ color: $gray-900; font-size: calc(1rem - 2px); color: $ui-white; + + &[data-disabled] { + .item { + opacity: $opacity-disabled; + cursor: not-allowed; + } + } } .horizontal { diff --git a/apps/client/src/common/components/state/EmptyTableBody.module.scss b/apps/client/src/common/components/state/EmptyTableBody.module.scss index d51f7e752..678dd083f 100644 --- a/apps/client/src/common/components/state/EmptyTableBody.module.scss +++ b/apps/client/src/common/components/state/EmptyTableBody.module.scss @@ -5,6 +5,7 @@ .emptyCell { margin-inline: auto; text-align: center; + padding-top: 10vh; } .empty { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index 87d749ed8..e771c8bb1 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -24,6 +24,7 @@ interface EditFormDrawerProps { export default memo(ViewParamsEditor); function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { + // TODO: can we ensure that the options update when the user loads an alias? const [_, setSearchParams] = useSearchParams(); const { data: viewSettings } = useViewSettings(); const { isOpen, close } = useViewParamsEditorStore(); @@ -62,7 +63,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
Customise - +
@@ -86,7 +87,13 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { - diff --git a/apps/client/src/common/context/PresetContext.tsx b/apps/client/src/common/context/PresetContext.tsx new file mode 100644 index 000000000..3b5f85920 --- /dev/null +++ b/apps/client/src/common/context/PresetContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { URLPreset } from 'ontime-types'; + +export const PresetContext = createContext(undefined); diff --git a/apps/client/src/common/stores/useCuesheetLinksStore.ts b/apps/client/src/common/stores/useCuesheetLinksStore.ts new file mode 100644 index 000000000..56473c229 --- /dev/null +++ b/apps/client/src/common/stores/useCuesheetLinksStore.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand'; + +type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio'; + +interface SelectionState { + [key: string]: boolean; +} + +interface ColumnPermissions { + read: string[]; + write: string[]; +} + +interface CuesheetLinksState { + target: Target | null; + readSelected: SelectionState; + writeSelected: SelectionState; + setTarget: (target: Target | null) => void; + setField: (field: 'read' | 'write', key: string, value: boolean) => void; + toggleField: (field: 'read' | 'write', key: string) => void; + selectAll: (field: 'read' | 'write', keys: string[]) => void; + clearAll: (field: 'read' | 'write', keys: string[]) => void; + // Returns arrays of column keys that have read/write permissions if target is 'cuesheet' + getSelections: () => ColumnPermissions | null; +} + +export const useCuesheetLinksStore = create((set, get) => ({ + target: null, + readSelected: {}, + writeSelected: {}, + setTarget: (target) => set({ target }), + setField: (field, key, value) => + set((state) => ({ + ...(field === 'read' + ? { readSelected: { ...state.readSelected, [key]: value } } + : { writeSelected: { ...state.writeSelected, [key]: value } }), + })), + toggleField: (field, key) => + set((state) => ({ + ...(field === 'read' + ? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } } + : { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }), + })), + selectAll: (field, keys) => + set((_state) => ({ + ...(field === 'read' + ? { + readSelected: keys.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as SelectionState), + } + : { + writeSelected: keys.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as SelectionState), + }), + })), + clearAll: (field, keys) => + set((_state) => ({ + ...(field === 'read' + ? { + readSelected: keys.reduce((acc, key) => { + acc[key] = false; + return acc; + }, {} as SelectionState), + } + : { + writeSelected: keys.reduce((acc, key) => { + acc[key] = false; + return acc; + }, {} as SelectionState), + }), + })), + getSelections: () => { + const state = get(); + if (state.target !== 'cuesheet') return null; + + return { + read: Object.entries(state.readSelected) + .filter(([_, selected]) => selected) + .map(([key]) => key), + write: Object.entries(state.writeSelected) + .filter(([_, selected]) => selected) + .map(([key]) => key), + }; + }, +})); diff --git a/apps/client/src/common/utils/__tests__/urlPresets.test.ts b/apps/client/src/common/utils/__tests__/urlPresets.test.ts index c0eb62315..896be4757 100644 --- a/apps/client/src/common/utils/__tests__/urlPresets.test.ts +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.ts @@ -1,9 +1,11 @@ -import { resolvePath } from 'react-router'; +import { Path, resolvePath } from 'react-router'; +import { OntimeView, URLPreset } from 'ontime-types'; import { arePathsEquivalent, generatePathFromPreset, generateUrlPresetOptions, + getCurrentPath, getRouteFromPreset, validateUrlPresetPath, } from '../urlPresets'; @@ -29,12 +31,13 @@ describe('validateUrlPresetPaths()', () => { }); describe('getRouteFromPreset()', () => { - const presets = [ + const presets: URLPreset[] = [ { enabled: true, alias: 'demopage', - target: 'timer', + target: OntimeView.Timer, search: 'user=guest', + options: {}, }, ]; @@ -70,28 +73,28 @@ 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'); + const location = resolvePath('/demopage?n=1&token=123'); 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'); + const location = resolvePath('/demopage?n=1&token=123'); + expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123'); }); }); }); describe('generatePathFromPreset()', () => { test.each([ - ['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'], - ['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'], - ])('generates a path from a preset: %s', (target, path, alias, expected) => { - expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected); + ['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'], + ['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'], + ])('generates a path from a preset: %s', (target, search, alias, locked, expected) => { + expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected); }); 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&n=1&token=123', ); }); }); @@ -108,9 +111,13 @@ describe('arePathsEquivalent()', () => { expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); }); + it('checks whether we are in a locked preset', () => { + expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy(); + }); + 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(); + expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy(); + expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy(); }); }); @@ -172,3 +179,16 @@ describe('generateUrlPresetOptions', () => { expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); }); }); + +describe('getCurrentPath()', () => { + test.each([ + [resolvePath('http://localhost:4001/timer'), 'timer'], + [resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'], + [resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'], + [resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'], + [resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'], + [resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'], + ])('resolves the current: %s', (location, expected) => { + expect(getCurrentPath(location as Path)).toEqual(expected); + }); +}); diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts index c34d2ed29..845f49361 100644 --- a/apps/client/src/common/utils/regex.ts +++ b/apps/client/src/common/utils/regex.ts @@ -11,3 +11,4 @@ export const isAlphanumeric = /^[a-z0-9]+$/i; export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isNotEmpty = /\S/; +export const isUrlSafe = /^[a-zA-Z0-9_-]*$/; // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex diff --git a/apps/client/src/common/utils/urlPresets.ts b/apps/client/src/common/utils/urlPresets.ts index 5cb4a950c..95dbce7b0 100644 --- a/apps/client/src/common/utils/urlPresets.ts +++ b/apps/client/src/common/utils/urlPresets.ts @@ -1,5 +1,5 @@ import { Path, resolvePath } from 'react-router'; -import { OntimeView, URLPreset } from 'ontime-types'; +import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { checkRegex } from 'ontime-utils'; /** @@ -27,62 +27,72 @@ export function validateUrlPresetPath(preset: string): { message: string; isVali return { isValid: true, message: 'ok' }; } -/** - * Utility removes trailing slash from a string - */ -function removeTrailingSlash(text: string): string { - return text.replace(/\/$/, ''); -} - /** * Checks whether the current location corresponds to a preset and returns the new path if necessary */ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null { - // current url is the pathname without the leading slash - const currentURL = location.pathname.substring(1); - const searchParams = new URLSearchParams(location.search); - - // check if we have token or locked in the search params - const locked = searchParams.get('locked'); - const token = searchParams.get('token'); - - // we need to check if the whole url is an alias - const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled); - if (foundPreset) { - // if so, we can redirect to the preset path - return generatePathFromPreset(foundPreset.target, foundPreset.search, foundPreset.alias, locked, token); - } - - // if the current url is not an alias, we check if the alias is in the search parameters - const presetOnPage = searchParams.get('alias'); - if (!presetOnPage) { + // if we're already on a preset path, no need to redirect + if (isPresetPath(location)) { return null; } + // NOTE: verify that this resolves correctly in cloud const currentPath = `${location.pathname}${location.search}`.substring(1); + const currentURL = getCurrentPath(location); + const token = new URLSearchParams(location.search).get('token'); + const isLocked = location.search.includes('n=1'); for (const preset of urlPresets) { - // if the page has a known enabled alias, we check if we need to redirect - if (preset.alias === presetOnPage && preset.enabled) { - const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, locked, token); - if (!arePathsEquivalent(currentPath, newPath)) { - // if current path is out of date - // return new path so we can redirect - return newPath; - } + if (!preset.enabled) continue; + /** + * If the page is a known alias it would be like + * /preset/{alias} <- locked to a preset + * or + * /{target}?alias={alias} <- unwrapped preset options + * + * we need to compare the saved preset to the current path to see if we need to redirect + */ + if (preset.alias === currentURL || preset.target === currentURL) { + const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token); + /** + * if the current path is equivalent to the new path, we return null + * this means we will not redirect + */ + return arePathsEquivalent(currentPath, newPath) ? null : newPath; } } + return null; } +/** + * Resolves the current path accounting for the base URI + * Returns the alias if it's a preset path, or the last segment otherwise + */ +export function getCurrentPath(location: Path): string { + // 1. get path without query parameters + const pathWithoutQuery = location.pathname.split('?')[0]; + // 2. split path into segments and filter out empty segments + const segments = pathWithoutQuery.split('/').filter(Boolean); + + // If this is a preset path, return the alias (last segment) + if (segments[0] === 'preset' && segments.length > 1) { + return segments[1]; + } + + // Otherwise return the last segment (view name) + return segments[segments.length - 1] || ''; +} + /** * Handles generating a path and search parameters from a preset + * This is done when we want to keep the current navigation and unwrap the search params */ export function generatePathFromPreset( target: Omit, search: string, alias: string, - locked: string | null, + locked: boolean, token: string | null, ): string { const path = resolvePath(`${target}?${search}`); @@ -93,7 +103,7 @@ export function generatePathFromPreset( // maintain params from the URL search feature if (locked) { - searchParams.set('locked', locked); + searchParams.set('n', '1'); } if (token) { @@ -106,28 +116,27 @@ export function generatePathFromPreset( /** * Utility checks if two paths are equivalent - * Considers the edge cases for url sharing where a path may contain extra arguments from the alias - * - token - * - locked + * For preset paths, only compares the path (since params are stored in session) + * For regular paths, compares path and search params (ignoring token) */ export function arePathsEquivalent(currentPath: string, newPath: string): boolean { const currentUrl = new URL(currentPath, document.location.origin); const newUrl = new URL(newPath, document.location.origin); - // check path + // For preset paths, only compare the path + if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) { + return currentUrl.pathname === newUrl.pathname; + } + + // For regular paths, compare path and search params (ignoring token) if (currentUrl.pathname !== newUrl.pathname) { return false; } - // check search params - // if the params match, we dont need further checks - if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) { - return true; - } - - // if there is no match, we check the edge cases for the url sharing feature currentUrl.searchParams.delete('token'); - currentUrl.searchParams.delete('locked'); + currentUrl.searchParams.delete('n'); + newUrl.searchParams.delete('token'); + newUrl.searchParams.delete('n'); return currentUrl.searchParams.toString() === newUrl.searchParams.toString(); } @@ -143,26 +152,28 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre } const url = new URL(sanitisedUrl); - const target = extractLastSegment(url.pathname); + const path = getCurrentPath(url); - if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) { - throw new Error(`Invalid target view: ${target}`); + if (!isPresettableView(path)) { + throw new Error(`Invalid target view: ${path}`); } return { alias, - target, + target: path, search: url.searchParams.toString(), enabled: true, }; } -/** - * the path can contain the stage hash - * "/team-hash/timer" or "/timer" - * we need to extract ontime view it targets - */ -function extractLastSegment(pathname: string): string { - const segments = pathname.split('/').filter(Boolean); - return segments[segments.length - 1] || ''; +function isPresettableView(view: string): view is OntimeViewPresettable { + return view !== OntimeView.Editor && Object.values(OntimeView).includes(view as OntimeView); +} + +/** + * Check if current location is a preset path + */ +export function isPresetPath(location: Path): boolean { + const segments = location.pathname.split('/').filter(Boolean); + return segments[0] === 'preset'; } diff --git a/apps/client/src/declarations/declaration.d.ts b/apps/client/src/declarations/declaration.d.ts index 60b363132..899dc0b67 100644 --- a/apps/client/src/declarations/declaration.d.ts +++ b/apps/client/src/declarations/declaration.d.ts @@ -1,3 +1,5 @@ +import { AppMode } from '../ontimeConfig'; + declare module '*.scss' { const content: Record; export default content; @@ -26,6 +28,8 @@ declare global { * - `handleUpdateTimer` callback to update the timer for a specific event * - `options-showDelayedTimes` whether to show or hide delayed times * - `options-hideTableSeconds` whether to hide seconds in the table + * - `options-hideIndexColumn` whether to hide the index column + * - `options-cuesheetMode` run or edit mode * * And metadata specific for each column * - `canWrite` whether the user can write to this column @@ -39,6 +43,8 @@ declare module '@tanstack/react-table' { options: { showDelayedTimes: boolean; hideTableSeconds: boolean; + hideIndexColumn: boolean; + cuesheetMode: AppMode; }; } diff --git a/apps/client/src/externals.ts b/apps/client/src/externals.ts index d2ed0bced..97135ace2 100644 --- a/apps/client/src/externals.ts +++ b/apps/client/src/externals.ts @@ -74,3 +74,26 @@ function resolveBaseURI(): string { return base; } + +/** + * Resolves a session scope for the session + */ +export const sessionScope = resolveSessionScope(); +export const getIsViewLocked = () => window.location.search.includes('n=1'); + +/** + * The session scope is read from the cookie and will only exist if the app is password protected + */ +function resolveSessionScope() { + const tokenCookie = document.cookie.split('; ').find((cookie) => cookie.startsWith('token=')); + + if (tokenCookie) { + try { + const { scope } = JSON.parse(tokenCookie.split('=')[1]); + return scope; + } catch { + return 'rw'; + } + } + return 'rw'; +} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 23ac33cce..0f1c458c4 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -132,6 +132,7 @@ $inner-padding: 1rem; display: grid; grid-template-columns: 1fr auto; align-items: center; + gap: 1rem; padding: 0.5rem 0; } diff --git a/apps/client/src/features/operator/OperatorExport.tsx b/apps/client/src/features/operator/OperatorExport.tsx index f96c130f8..57bc65224 100644 --- a/apps/client/src/features/operator/OperatorExport.tsx +++ b/apps/client/src/features/operator/OperatorExport.tsx @@ -1,12 +1,13 @@ import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; +import { getIsViewLocked } from '../../externals'; import Operator from './Operator'; export default function OperatorExport() { return ( - + ); diff --git a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx index 5e3fe4ecf..24663ff12 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx @@ -17,7 +17,7 @@ function RundownMenu() { const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const [editorMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, + key: sessionKeys.editorMode, defaultValue: AppMode.Edit, }); const { deleteAllEntries } = useEntryActions(); diff --git a/apps/client/src/features/sharing/GenerateLinkForm.module.scss b/apps/client/src/features/sharing/GenerateLinkForm.module.scss index 644aec555..b88daa3a1 100644 --- a/apps/client/src/features/sharing/GenerateLinkForm.module.scss +++ b/apps/client/src/features/sharing/GenerateLinkForm.module.scss @@ -16,4 +16,16 @@ .copiableLink { user-select: text; color: $ui-white; -} \ No newline at end of file + white-space: nowrap; + overflow-x: auto; +} + +.shareInline { + display: grid; + grid-template-columns: 1fr 172px; + gap: 1rem; +} + +.end { + padding-right: 2rem; +} diff --git a/apps/client/src/features/sharing/GenerateLinkForm.tsx b/apps/client/src/features/sharing/GenerateLinkForm.tsx index d255e5615..1d43779c2 100644 --- a/apps/client/src/features/sharing/GenerateLinkForm.tsx +++ b/apps/client/src/features/sharing/GenerateLinkForm.tsx @@ -1,70 +1,157 @@ -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useRef, useState } from 'react'; +import { FieldErrors, useForm } from 'react-hook-form'; import QRCode from 'react-qr-code'; +import { OntimeView, URLPreset } from 'ontime-types'; +import { generateId } from 'ontime-utils'; import { generateUrl } from '../../common/api/session'; import { maybeAxiosError } from '../../common/api/utils'; import Button from '../../common/components/buttons/Button'; +import CopyTag from '../../common/components/copy-tag/CopyTag'; import Info from '../../common/components/info/Info'; +import Input from '../../common/components/input/input/Input'; import Select from '../../common/components/select/Select'; import Switch from '../../common/components/switch/Switch'; +import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets'; import copyToClipboard from '../../common/utils/copyToClipboard'; import { preventEscape } from '../../common/utils/keyEvent'; import { linkToOtherHost } from '../../common/utils/linkUtils'; -import { currentHostName, isOntimeCloud, serverURL } from '../../externals'; +import { isUrlSafe } from '../../common/utils/regex'; +import { isOntimeCloud, serverURL } from '../../externals'; import * as Panel from '../app-settings/panel-utils/PanelUtils'; +import CuesheetLinkOptions from './composite/CuesheetLinkOptions'; + import style from './GenerateLinkForm.module.scss'; interface GenerateLinkFormProps { hostOptions: { value: string; label: string }[]; - pathOptions: { value: string; label: string }[]; + pathOptions: { value: OntimeView | string; label: string }[]; + presets: URLPreset[]; isLockedToView?: boolean; } -interface GenerateLinkFormOptions { +type GenericLinkOptions = { baseUrl: string; - path: string; - lock: boolean; + path: OntimeView | string; // we use empty string for Companion view authenticate: boolean; -} + lockConfig: boolean; + lockNav: boolean; +}; + +type CuesheetLinkOptions = GenericLinkOptions & { + path: OntimeView.Cuesheet; + + alias: string; + options: { + read?: string; + write?: string; + }; +}; + +type GenerateLinkFormOptions = GenericLinkOptions | CuesheetLinkOptions; type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error'; -export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) { +export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) { const [formState, setFormState] = useState('pending'); const [url, setUrl] = useState(serverURL); + const cuesheetReadRef = useRef(null); + const cuesheetWriteRef = useRef(null); + const generatedAlias = useRef(`cuesheet-${generateId()}`); + + const { addPreset } = useUpdateUrlPreset(); const { handleSubmit, setError, watch, setValue, - formState: { errors }, + reset, + register, + formState: { errors, isDirty }, } = useForm({ mode: 'onChange', defaultValues: { - baseUrl: currentHostName, - path: isLockedToView ? pathOptions[0].value : 'timer', - lock: false, + baseUrl: serverURL, + path: isLockedToView ? pathOptions[0].value : OntimeView.Timer, authenticate: false, - }, - resetOptions: { - keepDirtyValues: true, + lockConfig: false, + lockNav: false, }, }); + /** + * If the user is generating a link to the cuesheet we gather extra options + * The extra options are saved into a URL preset which we then request a share link for + */ + const createPresetFromOptions = async ( + alias: string, + options: Required, + ): Promise => { + if (options.read === '-') { + throw new Error('Cannot create a share with no read permissions'); + } + const presets = await addPreset({ + target: OntimeView.Cuesheet, + enabled: true, + alias, + search: '', + options: { + read: options.read, + write: options.write, + }, + }); + return presets.find((preset) => preset.alias === alias); + }; + const onSubmit = async (options: GenerateLinkFormOptions) => { try { setFormState('loading'); - const baseUrl = linkToOtherHost(options.baseUrl); - const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate); - await copyToClipboard(url); - setUrl(url); + if (options.path === OntimeView.Cuesheet) { + const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, { + read: cuesheetReadRef.current?.value ?? 'full', + write: cuesheetWriteRef.current?.value ?? 'full', + }); + + if (!urlPreset) { + throw new Error('Failed to create URL preset for Cuesheet'); + } + + const url = await generateUrl({ + baseUrl: options.baseUrl, + path: options.path, + authenticate: options.authenticate, + lockConfig: options.lockConfig, + lockNav: options.lockNav, + preset: urlPreset.alias, + }); + await copyToClipboard(url); + setUrl(url); + } else { + const presetPath = options.path.startsWith('preset-') ? options.path.replace('preset-', '') : undefined; + const path = presetPath ? presets.find((preset) => preset.alias === presetPath)?.target : options.path; + if (!path) { + throw new Error(`Could not resolve preset: ${path}`); + } + + const url = await generateUrl({ + baseUrl: linkToOtherHost(options.baseUrl), + path, + authenticate: options.authenticate, + lockConfig: options.lockConfig, + lockNav: options.lockNav, + preset: presetPath, + }); + + await copyToClipboard(url); + setUrl(url); + } + reset(options, { + keepValues: true, + keepDirty: false, + }); setFormState('success'); - setTimeout(() => { - setFormState('pending'); - }, 4000); } catch (error) { const message = maybeAxiosError(error); setError('root', { message }); @@ -72,70 +159,113 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToV } }; + const canSubmit = isDirty || formState !== 'success'; + return (
preventEscape(event)}> - {errors.root && {errors.root.message}} - {!isLockedToView ? ( + {!isLockedToView && ( You can generate a link to share with your team or to use in automation (such as companion). - ) : ( - You can generate a link to share with your team )} - - - - - ) : ( - - - + ) : ( + + + + ) : ( + + + + + + + )} + + + + setValue('lockNav', checked, { shouldDirty: true })} + /> + + {watch('path') !== OntimeView.Cuesheet && ( + + + setValue('lockConfig', checked, { shouldDirty: true })} + /> + + )} + + + setValue('authenticate', checked, { shouldDirty: true })} + /> + + + {errors.root?.message} + + + + + + Share this link + +
+ {url}
- - + Copy link +
+
); } diff --git a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx index 556645bed..569587317 100644 --- a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx +++ b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { OntimeView } from 'ontime-types'; import useInfo from '../../common/hooks-query/useInfo'; import useUrlPresets from '../../common/hooks-query/useUrlPresets'; @@ -6,37 +7,42 @@ import useUrlPresets from '../../common/hooks-query/useUrlPresets'; import GenerateLinkForm from './GenerateLinkForm'; interface GenerateLinkFormExportProps { - lockedPath?: { value: string; label: string }; + lockedPath?: { value: OntimeView; label: string }; } export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) { const { data: infoData } = useInfo(); const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined }); - const hostOptions = useMemo( - () => - infoData.networkInterfaces.map((nif) => ({ - value: nif.address, - label: `${nif.name} - ${nif.address}`, - })), - [infoData.networkInterfaces], - ); + const hostOptions = useMemo(() => { + return infoData.networkInterfaces.map((nif) => ({ + value: nif.address, + label: `${nif.name} - ${nif.address}`, + })); + }, [infoData.networkInterfaces]); const pathOptions = useMemo(() => { if (lockedPath) { return [{ value: lockedPath.value, label: lockedPath.label }]; } return [ - { value: 'timer', label: 'Timer' }, - { value: 'cuesheet', label: 'Cuesheet' }, - { value: 'op', label: 'Operator' }, + { value: OntimeView.Timer, label: 'Timer' }, + { value: OntimeView.Cuesheet, label: 'Cuesheet' }, + { value: OntimeView.Operator, label: 'Operator' }, { value: '', label: 'Companion' }, ...urlPresetData.map((preset) => ({ - value: preset.alias, + value: `preset-${preset.alias}`, label: `URL Preset: ${preset.alias}`, })), ]; }, [lockedPath, urlPresetData]); - return ; + return ( + + ); } diff --git a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss new file mode 100644 index 000000000..4de828e96 --- /dev/null +++ b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss @@ -0,0 +1,18 @@ +.twoCols { + display: grid; + grid-template-columns: max-content max-content; + column-gap: 3rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, max-content); + column-gap: 1rem; + row-gap: 0.5rem; + align-content: start; +} + +.inline { + display: flex; + gap: 0.5rem; +} \ No newline at end of file diff --git a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx new file mode 100644 index 000000000..ac83f34e2 --- /dev/null +++ b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx @@ -0,0 +1,187 @@ +import { Fragment, RefObject, useMemo, useState } from 'react'; + +import RadioGroup from '../../../common/components/radio-group/RadioGroup'; +import Switch from '../../../common/components/switch/Switch'; +import useCustomFields from '../../../common/hooks-query/useCustomFields'; +import { cuesheetDefaultColumns, makeCuesheetCustomColumns } from '../../../views/cuesheet/cuesheet.options'; +import * as Panel from '../../app-settings/panel-utils/PanelUtils'; + +import style from './CuesheetLinkOptions.module.scss'; + +type AccessMode = 'full' | 'custom'; + +interface CuesheetLinkOptionsProps { + readRef?: RefObject; + writeRef?: RefObject; +} + +export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) { + const { data } = useCustomFields(); + const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]); + + const [readPermissions, setReadPermissions] = useState('full'); + const [writePermissions, setWritePermissions] = useState('full'); + + const [readSwitches, setReadSwitches] = useState>(() => { + const initialState: Record = {}; + [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => { + initialState[column.value] = true; + }); + return initialState; + }); + + const [writeSwitches, setWriteSwitches] = useState>(() => { + const initialState: Record = {}; + [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => { + initialState[column.value] = true; + }); + return initialState; + }); + + const handleReadModeChange = (value: AccessMode) => { + setReadPermissions(value); + + setReadSwitches((prevReadSwitches) => { + const updatedReadSwitches = { ...prevReadSwitches }; + Object.keys(updatedReadSwitches).forEach((key) => { + updatedReadSwitches[key] = true; + }); + return updatedReadSwitches; + }); + }; + + const handleWriteModeChange = (value: AccessMode) => { + if (value === 'full') { + setReadPermissions('full'); + } + setWritePermissions(value); + + setReadSwitches((prevReadSwitches) => { + const updatedReadSwitches = { ...prevReadSwitches }; + setWriteSwitches((prevWriteSwitches) => { + const updatedWriteSwitches = { ...prevWriteSwitches }; + [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => { + updatedReadSwitches[column.value] = true; + updatedWriteSwitches[column.value] = true; + }); + return updatedWriteSwitches; + }); + return updatedReadSwitches; + }); + }; + + const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => { + if (type === 'read') { + setReadSwitches((prevReadSwitches) => { + const updatedReadSwitches = { ...prevReadSwitches, [key]: value }; + return updatedReadSwitches; + }); + } else { + setWriteSwitches((prevWriteSwitches) => { + const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value }; + return updatedWriteSwitches; + }); + } + }; + + const getReadPermissions = () => { + if (readPermissions === 'full' || writePermissions === 'full') { + return 'full'; + } + + return Object.entries(readSwitches) + .filter(([_, value]) => value) + .map(([key]) => key) + .join(','); + }; + + const getWritePermissions = () => { + if (writePermissions === 'full') { + return 'full'; + } + + return Object.entries(writeSwitches) + .filter(([_, value]) => value) + .map(([key]) => key) + .join(','); + }; + + return ( + + + +
+ +
+ + +
+
+
+
+ Ontime columns + Read + Write + {cuesheetDefaultColumns.map((column) => ( + +
{column.label}
+ handleSwitchChange(column.value, 'read', value)} + disabled={readPermissions === 'full' || writePermissions === 'full'} + data-testid={`read-${column.value}`} + /> + handleSwitchChange(column.value, 'write', value)} + disabled={writePermissions === 'full'} + data-testid={`write-${column.value}`} + /> +
+ ))} +
+ {customFieldColumns.length > 0 && ( +
+ Custom fields + Read + Write + {customFieldColumns.map((column) => ( + + {column.label} + handleSwitchChange(column.value, 'read', value)} + disabled={readPermissions === 'full' || writePermissions === 'full'} + data-testid={`read-${column.value}`} + /> + handleSwitchChange(column.value, 'write', value)} + disabled={writePermissions === 'full'} + data-testid={`write-${column.value}`} + /> + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/client/src/views/backstage/backstage.options.ts b/apps/client/src/views/backstage/backstage.options.ts index d50103de9..7fd2a708a 100644 --- a/apps/client/src/views/backstage/backstage.options.ts +++ b/apps/client/src/views/backstage/backstage.options.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { use, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types'; @@ -9,6 +9,7 @@ import { makeOptionsFromCustomFields, makeProjectDataOptions, } from '../../common/components/view-params-editor/viewParams.utils'; +import { PresetContext } from '../../common/context/PresetContext'; import { scheduleOptions } from '../common/schedule/schedule.options'; export const getBackstageOptions = ( @@ -62,11 +63,13 @@ type BackstageOptions = { * Utility extract the view options from URL Params * the names and fallback are manually matched with timerOptions */ -function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions { - // we manually make an object that matches the key above +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): BackstageOptions { + // Helper to get value from either source, prioritizing defaultValues + const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key); + return { - secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, - extraInfo: searchParams.get('extra-info'), + secondarySource: getValue('secondary-src') as keyof OntimeEvent | null, + extraInfo: getValue('extra-info'), }; } @@ -75,6 +78,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions { */ export function useBackstageOptions(): BackstageOptions { const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + const maybePreset = use(PresetContext); + + const options = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + return options; } diff --git a/apps/client/src/views/common/not-found/NotFound.module.scss b/apps/client/src/views/common/not-found/NotFound.module.scss new file mode 100644 index 000000000..eaa16a671 --- /dev/null +++ b/apps/client/src/views/common/not-found/NotFound.module.scss @@ -0,0 +1,11 @@ +@use '@/theme/viewerDefs' as *; + +.notFound { + display: grid; + place-items: center; + background-color: var(--background-color-override, $viewer-background-color); + margin-top: 10vh; + color: $ui-white; + justify-content: center; + text-align: center; +} diff --git a/apps/client/src/views/common/not-found/NotFound.tsx b/apps/client/src/views/common/not-found/NotFound.tsx new file mode 100644 index 000000000..2e9f3221a --- /dev/null +++ b/apps/client/src/views/common/not-found/NotFound.tsx @@ -0,0 +1,19 @@ +import EmptyImage from '../../../assets/images/empty.svg?react'; + +import style from './NotFound.module.scss'; + +export default function NotFound() { + return ( +
+ +

Not found

+
+ The page you are after was not found. +
+ It may have moved or your URL may be incorrect. +
+ Double check the URL and try again. +
+
+ ); +} diff --git a/apps/client/src/views/countdown/countdown.options.ts b/apps/client/src/views/countdown/countdown.options.ts index 93d590ef5..7cd1bf26d 100644 --- a/apps/client/src/views/countdown/countdown.options.ts +++ b/apps/client/src/views/countdown/countdown.options.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { use, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { CustomFields, EntryId, OntimeEvent } from 'ontime-types'; @@ -6,6 +6,7 @@ import { getTimeOption } from '../../common/components/view-params-editor/common import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/viewParams.types'; import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; +import { PresetContext } from '../../common/context/PresetContext'; import { isStringBoolean } from '../../features/viewers/common/viewUtils'; export const getCountdownOptions = ( @@ -69,12 +70,22 @@ type CountdownOptions = { * Utility extract the view options from URL Params * the names and fallback are manually matched with timerOptions */ -function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions { - // we manually make an object that matches the key above +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): CountdownOptions { + // Helper to get single value from either source, prioritizing defaultValues + const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key); + + // Helper to get array values from either source + const getArrayValues = (key: string): EntryId[] => { + if (defaultValues?.has(key)) { + return defaultValues.getAll(key) as EntryId[]; + } + return searchParams.getAll(key) as EntryId[]; + }; + return { - subscriptions: searchParams.getAll('sub') as EntryId[], - secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, - showExpected: isStringBoolean(searchParams.get('showExpected')), + subscriptions: getArrayValues('sub'), + secondarySource: getValue('secondary-src') as keyof OntimeEvent | null, + showExpected: isStringBoolean(getValue('showExpected')), }; } @@ -83,6 +94,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions { */ export function useCountdownOptions(): CountdownOptions { const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + const maybePreset = use(PresetContext); + + const options = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + return options; } diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index 1c2778a08..37daf3c92 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -3,8 +3,8 @@ import { useDisclosure } from '@mantine/hooks'; import IconButton from '../../common/components/buttons/IconButton'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; -import useViewEditor from '../../common/components/navigation-menu/useViewEditor'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; +import { getIsViewLocked } from '../../externals'; import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal'; @@ -14,18 +14,19 @@ import CuesheetTableWrapper from './CuesheetTableWrapper'; import styles from './CuesheetPage.module.scss'; export default function CuesheetPage() { - const { isViewLocked } = useViewEditor({ isLockable: true }); const [isMenuOpen, menuHandler] = useDisclosure(); useWindowTitle('Cuesheet'); + const isLocked = getIsViewLocked(); + return ( <>
- {!isViewLocked && ( + {!isLocked && ( diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 797c834b4..8e16b2cfc 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -1,30 +1,66 @@ -import { memo, useMemo } from 'react'; +import { memo, use, useEffect, useMemo } from 'react'; import { useSessionStorage } from '@mantine/hooks'; import EmptyPage from '../../common/components/state/EmptyPage'; +import { PresetContext } from '../../common/context/PresetContext'; import useCustomFields from '../../common/hooks-query/useCustomFields'; import { useFlatRundown } from '../../common/hooks-query/useRundown'; +import { sessionScope } from '../../externals'; import { AppMode, sessionKeys } from '../../ontimeConfig'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import CuesheetTable from './cuesheet-table/CuesheetTable'; +import { useCuesheetPermissions } from './useTablePermissions'; export default memo(CuesheetTableWrapper); function CuesheetTableWrapper() { const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: customFields, status: customFieldStatus } = useCustomFields(); + const setPermissions = useCuesheetPermissions((state) => state.setPermissions); + const preset = use(PresetContext); + + // set permissions based on preset + useEffect(() => { + if (preset) { + const fullWrite = preset.options?.write === 'full'; + setPermissions({ + canChangeMode: preset.options?.write !== '-', + canCreateEntries: fullWrite, + canEditEntries: fullWrite, + canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')), + canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes + }); + } else { + setPermissions({ + canChangeMode: true, + canCreateEntries: true, + canEditEntries: true, + canFlag: true, + canShare: sessionScope === 'rw', + }); + } + }, [preset, setPermissions]); const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, + key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode, + defaultValue: preset ? AppMode.Run : AppMode.Edit, }); - const columns = useMemo(() => makeCuesheetColumns(customFields, cuesheetMode), [customFields, cuesheetMode]); + + const columns = useMemo( + () => makeCuesheetColumns(customFields, cuesheetMode, preset), + [customFields, cuesheetMode, preset], + ); + const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending'; return ( - {isLoading ? : } + {isLoading ? ( + + ) : ( + + )} ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index d1ceea187..7237a4952 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,12 +1,11 @@ import { memo, useCallback, useMemo } from 'react'; -import { useSessionStorage } from '@mantine/hooks'; import { useTableNav } from '@table-nav/react'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { OntimeEntry, TimeField } from 'ontime-types'; import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useFollowSelected } from '../../../common/hooks/useFollowComponent'; -import { AppMode, sessionKeys } from '../../../ontimeConfig'; +import { AppMode } from '../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../cuesheet.options'; import CuesheetBody from './cuesheet-table-elements/CuesheetBody'; @@ -20,16 +19,14 @@ import style from './CuesheetTable.module.scss'; interface CuesheetTableProps { data: OntimeEntry[]; columns: ColumnDef[]; + cuesheetMode: AppMode; } -export default function CuesheetTable({ data, columns }: CuesheetTableProps) { +export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) { const { updateEntry, updateTimer } = useEntryActions(); const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes); const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds); - const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, - }); + const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run); @@ -66,9 +63,11 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) { options: { showDelayedTimes, hideTableSeconds, + cuesheetMode, + hideIndexColumn, }, }), - [data, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer], + [cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer], ); const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = @@ -129,7 +128,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) { />
- + {table.getState().columnSizingInfo.isResizingColumn ? ( ) : ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx index 80febf474..3cf787199 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx @@ -1,12 +1,10 @@ import { IoEllipsisHorizontal } from 'react-icons/io5'; -import { useSessionStorage } from '@mantine/hooks'; import { flexRender, Table } from '@tanstack/react-table'; import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types'; import IconButton from '../../../../common/components/buttons/IconButton'; import { useCurrentBlockId } from '../../../../common/hooks/useSocket'; -import { AppMode, sessionKeys } from '../../../../ontimeConfig'; -import { usePersistedCuesheetOptions } from '../../cuesheet.options'; +import { AppMode } from '../../../../ontimeConfig'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import style from './BlockRow.module.scss'; @@ -23,11 +21,11 @@ interface BlockRowProps { export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) { const { currentBlockId } = useCurrentBlockId(); - const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, - }); + const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { + cuesheetMode: AppMode.Edit, + hideIndexColumn: false, + }; + const openMenu = useCuesheetTableMenu((store) => store.openMenu); if (hidePast && !currentBlockId) { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx index 123bcf94f..3bf15a18a 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx @@ -113,7 +113,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB if (entry.parent) { const rundown = queryClient.getQueryData(RUNDOWN); const parentEntry = rundown?.entries[entry.parent]; - parentBgColour = (parentEntry as OntimeBlock).colour ?? null; + parentBgColour = (parentEntry as OntimeBlock | undefined)?.colour ?? null; } return ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx index 3765db5ed..1d2b7956e 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx @@ -1,11 +1,10 @@ import { CSSProperties } from 'react'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; -import { useSessionStorage } from '@mantine/hooks'; import { flexRender, HeaderGroup } from '@tanstack/react-table'; import { OntimeEntry } from 'ontime-types'; import { getAccessibleColour } from '../../../../common/utils/styleUtils'; -import { AppMode, sessionKeys } from '../../../../ontimeConfig'; +import { AppMode } from '../../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { SortableCell } from './SortableCell'; @@ -14,14 +13,11 @@ import style from '../CuesheetTable.module.scss'; interface CuesheetHeaderProps { headerGroups: HeaderGroup[]; + cuesheetMode: AppMode; } -export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) { +export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) { const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, - }); return ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index 34465f632..f6b5661fd 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -1,14 +1,12 @@ import { RefObject, useEffect, useRef } from 'react'; import { IoEllipsisHorizontal } from 'react-icons/io5'; -import { useSessionStorage } from '@mantine/hooks'; import { flexRender, Table } from '@tanstack/react-table'; import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types'; import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import IconButton from '../../../../common/components/buttons/IconButton'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; -import { AppMode, sessionKeys } from '../../../../ontimeConfig'; -import { usePersistedCuesheetOptions } from '../../cuesheet.options'; +import { AppMode } from '../../../../ontimeConfig'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { observeRow, unobserveRow } from './rowObserver'; @@ -43,15 +41,13 @@ export default function EventRow({ table, firstAfterBlock, }: EventRowProps) { - const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, - }); + const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { + cuesheetMode: AppMode.Edit, + hideIndexColumn: false, + }; + const ownRef = useRef(null); - const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId)); - const openMenu = useCuesheetTableMenu((store) => store.openMenu); // register this row with the intersection observer diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx index a543d55b9..fb5e07768 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx @@ -1,12 +1,10 @@ import { IoEllipsisHorizontal } from 'react-icons/io5'; -import { useSessionStorage } from '@mantine/hooks'; import { flexRender, Table } from '@tanstack/react-table'; import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types'; import IconButton from '../../../../common/components/buttons/IconButton'; import { cx, enDash } from '../../../../common/utils/styleUtils'; -import { AppMode, sessionKeys } from '../../../../ontimeConfig'; -import { usePersistedCuesheetOptions } from '../../cuesheet.options'; +import { AppMode } from '../../../../ontimeConfig'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import style from './MilestoneRow.module.scss'; @@ -32,11 +30,11 @@ export default function MilestoneRow({ rowIndex, table, }: MilestoneRowProps) { - const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); - const [cuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, - }); + const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { + cuesheetMode: AppMode.Edit, + hideIndexColumn: false, + }; + const openMenu = useCuesheetTableMenu((store) => store.openMenu); return ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx index 2651e421b..46f469b2f 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { CellContext, ColumnDef } from '@tanstack/react-table'; -import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types'; +import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; @@ -225,85 +225,113 @@ function MakeCustomField({ row, column, table }: CellContext[] { +export function makeCuesheetColumns( + customFields: CustomFields, + cuesheetMode: AppMode, + preset: URLPreset | undefined, +): ColumnDef[] { const columnsDef: ColumnDef[] = []; const modeAllowsWrite = cuesheetMode === AppMode.Edit; + const fullRead = preset ? preset.options?.read === 'full' : true; + const fullWrite = preset ? preset.options?.write === 'full' : true; + const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set(); + const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set(); - columnsDef.push({ - accessorKey: 'flag', - id: 'flag', - header: 'Flag', - cell: MakeFlagField, - size: 45, - minSize: 45, - meta: { canWrite: modeAllowsWrite }, - }); + // helpers to check read/write for a given key + const canRead = (key: string) => fullRead || canReadKeys.has(key); + const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key)); - columnsDef.push({ - accessorKey: 'cue', - id: 'cue', - header: 'Cue', - cell: MakeSingleLineField, - size: 75, - minSize: 40, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('flag')) { + columnsDef.push({ + accessorKey: 'flag', + id: 'flag', + header: 'Flag', + cell: MakeFlagField, + size: 45, + minSize: 45, + meta: { canWrite: canWrite('flag') }, + }); + } - columnsDef.push({ - accessorKey: 'timeStart', - id: 'timeStart', - header: 'Start', - cell: MakeStart, - size: 75, - minSize: 75, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('cue')) { + columnsDef.push({ + accessorKey: 'cue', + id: 'cue', + header: 'Cue', + cell: MakeSingleLineField, + size: 75, + minSize: 40, + meta: { canWrite: canWrite('cue') }, + }); + } - columnsDef.push({ - accessorKey: 'timeEnd', - id: 'timeEnd', - header: 'End', - cell: MakeEnd, - size: 75, - minSize: 75, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('timeStart')) { + columnsDef.push({ + accessorKey: 'timeStart', + id: 'timeStart', + header: 'Start', + cell: MakeStart, + size: 75, + minSize: 75, + meta: { canWrite: canWrite('timeStart') }, + }); + } - columnsDef.push({ - accessorKey: 'duration', - id: 'duration', - header: 'Duration', - cell: MakeDuration, - size: 75, - minSize: 75, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('timeEnd')) { + columnsDef.push({ + accessorKey: 'timeEnd', + id: 'timeEnd', + header: 'End', + cell: MakeEnd, + size: 75, + minSize: 75, + meta: { canWrite: canWrite('timeEnd') }, + }); + } - columnsDef.push({ - accessorKey: 'title', - id: 'title', - header: 'Title', - cell: MakeSingleLineField, - size: 250, - minSize: 75, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('duration')) { + columnsDef.push({ + accessorKey: 'duration', + id: 'duration', + header: 'Duration', + cell: MakeDuration, + size: 75, + minSize: 75, + meta: { canWrite: canWrite('duration') }, + }); + } - columnsDef.push({ - accessorKey: 'note', - id: 'note', - header: 'Note', - cell: MakeMultiLineField, - size: 250, - minSize: 75, - meta: { canWrite: modeAllowsWrite }, - }); + if (canRead('title')) { + columnsDef.push({ + accessorKey: 'title', + id: 'title', + header: 'Title', + cell: MakeSingleLineField, + size: 250, + minSize: 75, + meta: { canWrite: canWrite('title') }, + }); + } + + if (canRead('note')) { + columnsDef.push({ + accessorKey: 'note', + id: 'note', + header: 'Note', + cell: MakeMultiLineField, + size: 250, + minSize: 75, + meta: { canWrite: canWrite('note') }, + }); + } // custom fields at the end const customFieldKeys = Object.keys(customFields); for (let i = 0; i < customFieldKeys.length; i++) { const key = customFieldKeys[i]; + const permissionKey = `custom-${key}`; + if (!canRead(permissionKey)) continue; columnsDef.push({ accessorKey: key, id: key, @@ -313,7 +341,7 @@ export function makeCuesheetColumns(customFields: CustomFields, cuesheetMode: Ap minSize: 75, meta: { colour: customFields[key].colour, - canWrite: true, + canWrite: canWrite(permissionKey), }, }); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx index 9be15e695..8745d9a5e 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx @@ -5,6 +5,7 @@ import { SupportedEntry } from 'ontime-types'; import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; +import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetTableMenu } from './useCuesheetTableMenu'; @@ -14,6 +15,7 @@ function CuesheetTableMenu() { const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu(); const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActions(); const showModal = useCuesheetEditModal((state) => state.setEditableEntry); + const permissions = useCuesheetPermissions(); if (!isOpen) { return null; @@ -24,14 +26,20 @@ function CuesheetTableMenu() { isOpen onClose={closeMenu} items={[ - { type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: IoOptions }, + { + type: 'item', + label: 'Edit...', + onClick: () => showModal(entryId), + icon: IoOptions, + disabled: !permissions.canEditEntries, + }, { type: 'divider' }, { type: 'item', label: flag ? 'Remove flag' : 'Add flag', onClick: () => updateEntry({ id: entryId, flag: !flag }), icon: IoDuplicateOutline, - disabled: flag === null, + disabled: flag === null || !permissions.canFlag, }, { type: 'divider' }, { @@ -39,18 +47,21 @@ function CuesheetTableMenu() { label: 'Add event above', onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }), icon: IoAdd, + disabled: !permissions.canCreateEntries, }, { type: 'item', label: 'Add event below', onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }), icon: IoAdd, + disabled: !permissions.canCreateEntries, }, { type: 'item', label: 'Clone event', onClick: () => clone(entryId), icon: IoDuplicateOutline, + disabled: !permissions.canCreateEntries, }, { type: 'divider' }, { @@ -58,13 +69,14 @@ function CuesheetTableMenu() { label: 'Move up', onClick: () => move(entryId, 'up'), icon: IoArrowUp, - disabled: entryIndex < 1, + disabled: entryIndex < 1 || !permissions.canEditEntries, }, { type: 'item', label: 'Move down', onClick: () => move(entryId, 'down'), icon: IoArrowDown, + disabled: !permissions.canEditEntries, }, { type: 'divider' }, { @@ -72,6 +84,7 @@ function CuesheetTableMenu() { label: 'Delete', onClick: () => deleteEntry([entryId]), icon: IoTrash, + disabled: !permissions.canEditEntries, }, ]} position={position} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx index 2a19c74c3..7562367f1 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx @@ -1,5 +1,6 @@ import { Toolbar } from '@base-ui-components/react/toolbar'; import { useDisclosure } from '@mantine/hooks'; +import { OntimeView } from 'ontime-types'; import Button from '../../../../common/components/buttons/Button'; import RotatedLink from '../../../../common/components/icons/RotatedLink'; @@ -29,7 +30,9 @@ function CuesheetShareModal() { showBackdrop showCloseButton bodyElements={ - showModalContent ? : null + showModalContent ? ( + + ) : null } /> diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index d33cc3a2a..f420fbccd 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, use } from 'react'; import { IoChevronDown, IoOptions, IoSettingsOutline } from 'react-icons/io5'; import { Popover } from '@base-ui-components/react/popover'; import { Toggle } from '@base-ui-components/react/toggle'; @@ -12,9 +12,11 @@ import Button from '../../../../common/components/buttons/Button'; import Checkbox from '../../../../common/components/checkbox/Checkbox'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import PopoverContents from '../../../../common/components/popover/Popover'; +import { PresetContext } from '../../../../common/context/PresetContext'; import { cx } from '../../../../common/utils/styleUtils'; import { AppMode, sessionKeys } from '../../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../../cuesheet.options'; +import { useCuesheetPermissions } from '../../useTablePermissions'; import CuesheetShareModal from './CuesheetShareModal'; @@ -33,9 +35,12 @@ export default function CuesheetTableSettings({ handleResetReordering, handleClearToggles, }: CuesheetTableSettingsProps) { + const canShare = useCuesheetPermissions((state) => state.canShare); + const preset = use(PresetContext); + const [cuesheetMode, setCuesheetMode] = useSessionStorage({ - key: sessionKeys.cuesheetMode, - defaultValue: AppMode.Edit, + key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode, + defaultValue: preset ? AppMode.Run : AppMode.Edit, }); const toggleCuesheetMode = (mode: AppMode[]) => { @@ -54,7 +59,6 @@ export default function CuesheetTableSettings({ handleResetReordering={handleResetReordering} handleClearToggles={handleClearToggles} /> - } value={AppMode.Run} className={style.radioButton}> Run @@ -64,8 +68,12 @@ export default function CuesheetTableSettings({ - - + {canShare && ( + <> + + + + )} ); } diff --git a/apps/client/src/views/cuesheet/cuesheet.options.ts b/apps/client/src/views/cuesheet/cuesheet.options.ts index feb76eec7..623b99e07 100644 --- a/apps/client/src/views/cuesheet/cuesheet.options.ts +++ b/apps/client/src/views/cuesheet/cuesheet.options.ts @@ -1,3 +1,4 @@ +import { CustomFields } from 'ontime-types'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -40,3 +41,22 @@ export const usePersistedCuesheetOptions = create()( }, ), ); + +export const cuesheetDefaultColumns = [ + { value: 'flag', label: 'Flag' }, + { value: 'cue', label: 'Cue' }, + { value: 'title', label: 'Title' }, + { value: 'timeStart', label: 'Time start' }, + { value: 'timeEnd', label: 'Time end' }, + { value: 'duration', label: 'Duration' }, + { value: 'note', label: 'Note' }, +]; + +export function makeCuesheetCustomColumns(customFields: CustomFields) { + return Object.entries(customFields).map(([key, field]) => { + return { + value: `custom-${key}`, + label: field.label, + }; + }); +} diff --git a/apps/client/src/views/cuesheet/useTablePermissions.tsx b/apps/client/src/views/cuesheet/useTablePermissions.tsx new file mode 100644 index 000000000..f4fa1b785 --- /dev/null +++ b/apps/client/src/views/cuesheet/useTablePermissions.tsx @@ -0,0 +1,27 @@ +import { create } from 'zustand'; + +interface CuesheetPermissionsStore { + canChangeMode: boolean; + canCreateEntries: boolean; + canEditEntries: boolean; + canFlag: boolean; + canShare: boolean; + setPermissions: (permissions: Omit) => void; +} + +export const useCuesheetPermissions = create((set) => ({ + canChangeMode: false, + canCreateEntries: false, + canEditEntries: false, + canFlag: false, + canShare: false, + setPermissions(permissions) { + set({ + canChangeMode: permissions.canChangeMode, + canFlag: permissions.canFlag, + canCreateEntries: permissions.canCreateEntries, + canEditEntries: permissions.canEditEntries, + canShare: permissions.canShare, + }); + }, +})); diff --git a/apps/client/src/views/studio/studio.options.ts b/apps/client/src/views/studio/studio.options.ts index cdc95b968..89fe5074b 100644 --- a/apps/client/src/views/studio/studio.options.ts +++ b/apps/client/src/views/studio/studio.options.ts @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { use, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { getTimeOption } from '../../common/components/view-params-editor/common.options'; import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/viewParams.types'; +import { PresetContext } from '../../common/context/PresetContext'; import { isStringBoolean } from '../../features/viewers/common/viewUtils'; export const getStudioOptions = (timeFormat: string): ViewOption[] => [ @@ -31,10 +32,12 @@ type StudioOptions = { * Utility extract the view options from URL Params * the names and fallback are manually matched with timerOptions */ -function getOptionsFromParams(searchParams: URLSearchParams): StudioOptions { - // we manually make an object that matches the key above +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): StudioOptions { + // Helper to get value from either source, prioritizing defaultValues + const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key); + return { - hideCards: isStringBoolean(searchParams.get('hideCards')), + hideCards: isStringBoolean(getValue('hideCards')), }; } @@ -43,6 +46,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): StudioOptions { */ export function useStudioOptions(): StudioOptions { const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + const maybePreset = use(PresetContext); + + const options = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + return options; } diff --git a/apps/client/src/views/timeline/timeline.options.ts b/apps/client/src/views/timeline/timeline.options.ts index a67e99f84..b0ea293c2 100644 --- a/apps/client/src/views/timeline/timeline.options.ts +++ b/apps/client/src/views/timeline/timeline.options.ts @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { use, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { getTimeOption } from '../../common/components/view-params-editor/common.options'; import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/viewParams.types'; +import { PresetContext } from '../../common/context/PresetContext'; import { isStringBoolean } from '../../features/viewers/common/viewUtils'; export const getTimelineOptions = (timeFormat: string): ViewOption[] => { @@ -41,11 +42,13 @@ type TimelineOptions = { * Utility extract the view options from URL Params * the names and fallback are manually matched with timerOptions */ -function getOptionsFromParams(searchParams: URLSearchParams): TimelineOptions { - // we manually make an object that matches the key above +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): TimelineOptions { + // Helper to get value from either source, prioritizing defaultValues + const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key); + return { - hidePast: isStringBoolean(searchParams.get('hidePast')), - autosize: isStringBoolean(searchParams.get('autosize')), + hidePast: isStringBoolean(getValue('hidePast')), + autosize: isStringBoolean(getValue('autosize')), }; } @@ -54,6 +57,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): TimelineOptions { */ export function useTimelineOptions(): TimelineOptions { const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + const maybePreset = use(PresetContext); + + const options = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + return options; } diff --git a/apps/client/src/views/timer/timer.options.ts b/apps/client/src/views/timer/timer.options.ts index da406e9b7..4d88636b9 100644 --- a/apps/client/src/views/timer/timer.options.ts +++ b/apps/client/src/views/timer/timer.options.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { use, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { CustomFields, OntimeEvent, TimerType } from 'ontime-types'; import { validateTimerType } from 'ontime-utils'; @@ -12,6 +12,7 @@ import { import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/viewParams.types'; import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; +import { PresetContext } from '../../common/context/PresetContext'; import { isStringBoolean, makeColourString } from '../../features/viewers/common/viewUtils'; // manually match the properties of TimerType excluding the None @@ -197,31 +198,35 @@ type TimerOptions = { * Utility extract the view options from URL Params * the names and fallbacks are manually matched with timerOptions */ -function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions { - const timerType = validateTimerType(searchParams.get('timerType'), TimerType.None); - // we manually make an object that matches the key above - return { - hideClock: isStringBoolean(searchParams.get('hideClock')), - hideCards: isStringBoolean(searchParams.get('hideCards')), - hideProgress: isStringBoolean(searchParams.get('hideProgress')), - hideMessage: isStringBoolean(searchParams.get('hideMessage')), - hideSecondary: isStringBoolean(searchParams.get('hideSecondary')), - hideLogo: isStringBoolean(searchParams.get('hideLogo')), - hideTimerSeconds: isStringBoolean(searchParams.get('hideTimerSeconds')), - removeLeadingZeros: !isStringBoolean(searchParams.get('showLeadingZeros')), +function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): TimerOptions { + // Helper to get value from either source, prioritizing defaultValues + const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key); - mainSource: searchParams.get('main') as keyof OntimeEvent | null, - secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, + // Get timerType from either source + const timerType = validateTimerType(getValue('timerType'), TimerType.None); + + return { + hideClock: isStringBoolean(getValue('hideClock')), + hideCards: isStringBoolean(getValue('hideCards')), + hideProgress: isStringBoolean(getValue('hideProgress')), + hideMessage: isStringBoolean(getValue('hideMessage')), + hideSecondary: isStringBoolean(getValue('hideSecondary')), + hideLogo: isStringBoolean(getValue('hideLogo')), + hideTimerSeconds: isStringBoolean(getValue('hideTimerSeconds')), + removeLeadingZeros: !isStringBoolean(getValue('showLeadingZeros')), + + mainSource: getValue('main') as keyof OntimeEvent | null, + secondarySource: getValue('secondary-src') as keyof OntimeEvent | null, // none doesnt make sense as a configuration of the view timerType: timerType === TimerType.None ? undefined : timerType, - freezeOvertime: isStringBoolean(searchParams.get('freezeOvertime')), - freezeMessage: searchParams.get('freezeMessage') ?? '', - hidePhase: isStringBoolean(searchParams.get('hidePhase')), + freezeOvertime: isStringBoolean(getValue('freezeOvertime')), + freezeMessage: getValue('freezeMessage') ?? '', + hidePhase: isStringBoolean(getValue('hidePhase')), - font: searchParams.get('font') ?? undefined, - keyColour: makeColourString(searchParams.get('keyColour')), - textColour: makeColourString(searchParams.get('textColour')), + font: getValue('font') ?? undefined, + keyColour: makeColourString(getValue('keyColour')), + textColour: makeColourString(getValue('textColour')), }; } @@ -230,6 +235,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions { */ export function useTimerOptions(): TimerOptions { const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + const maybePreset = use(PresetContext); + + const options = useMemo(() => { + const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined; + return getOptionsFromParams(searchParams, defaultValues); + }, [maybePreset, searchParams]); + return options; } diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts index 3058698d5..74cf8651b 100644 --- a/apps/server/src/api-data/db/migration/db.migration.v3.ts +++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts @@ -104,13 +104,14 @@ type old_URLPreset = { /** * migrates a url presets from v3 to v4 * - pathAndParams split into a target and search + * */ export function migrateURLPresets(jsonData: object): URLPreset[] | undefined { if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) { const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset; const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => { const [target, search] = pathAndParams.split('?'); - return { enabled, alias, target, search }; + return { enabled, alias, target, search, options: {} } as URLPreset; }); return newURLPreset; } diff --git a/apps/server/src/api-data/db/migration/migration.test.ts b/apps/server/src/api-data/db/migration/migration.test.ts index 908c68c6c..8e757cde4 100644 --- a/apps/server/src/api-data/db/migration/migration.test.ts +++ b/apps/server/src/api-data/db/migration/migration.test.ts @@ -2,6 +2,7 @@ import { AutomationSettings, CustomFields, EndAction, + OntimeView, ProjectData, Rundown, Settings, @@ -202,16 +203,18 @@ describe('v3 to v4', () => { { enabled: true, alias: 'clock', - target: 'timer', + target: OntimeView.Timer, search: 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + options: {}, }, { enabled: true, alias: 'minimal', - target: 'timer', + target: OntimeView.Timer, search: 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + options: {}, }, ]; const newUrlPreset = v3.migrateURLPresets(oldDb); @@ -249,10 +252,10 @@ describe('v3 to v4', () => { colour: '#E80000', }, }; - const { customFields, translationTable } = v3.migrateCustomFields(oldDb)!; - expect(customFields).toEqual(expectCustomFields); - - expect(translationTable).toEqual( + const parsedData = v3.migrateCustomFields(oldDb); + expect(parsedData).not.toBeUndefined(); + expect(parsedData?.customFields).toEqual(expectCustomFields); + expect(parsedData?.translationTable).toEqual( new Map([ ['song', 'Song_and_Dance'], ['artist', 'Artist_and_Host'], diff --git a/apps/server/src/api-data/session/__tests__/session.service.test.ts b/apps/server/src/api-data/session/__tests__/session.service.test.ts index 82b11e41f..4a925df3e 100644 --- a/apps/server/src/api-data/session/__tests__/session.service.test.ts +++ b/apps/server/src/api-data/session/__tests__/session.service.test.ts @@ -1,50 +1,150 @@ -import { generateAuthenticatedUrl } from '../session.service.js'; +import { generateShareUrl } from '../session.service.js'; describe('generateAuthenticatedUrl()', () => { describe('for local IP addresses', () => { it('generates a link without locking or authentication', () => { - const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false); + const localhostNotLocked = generateShareUrl('http://localhost:3000', 'timer', { + lockConfig: false, + lockNav: false, + authenticate: false, + }); expect(localhostNotLocked.toString()).toBe('http://localhost:3000/timer'); }); - it('generates a link with IP locking enabled', () => { - const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false); - expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?locked=true'); + it('generates a link with navigation locking enabled', () => { + const ipLocked = generateShareUrl('http://192.168.10.173:4001', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: false, + }); + expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?n=1'); }); - it('generates a link with authentication token and IP locking', () => { - const withAuth = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, true, undefined, '1234'); - expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&locked=true'); + it('generates a link with authentication token and navigation locking', () => { + const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: true, + hash: '1234', + }); + expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1'); + }); + + it('generates a link to an unlocked preset', () => { + const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', { + lockConfig: false, + lockNav: false, + authenticate: false, + preset: 'minimal', + }); + expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal'); + }); + + it('generates a link to an unlocked preset without navigation', () => { + const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: false, + preset: 'minimal', + }); + expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal?n=1'); + }); + + it('generates a link to a locked preset', () => { + const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', { + lockConfig: true, + lockNav: false, + authenticate: false, + preset: 'minimal', + }); + expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/minimal'); + }); + + it('generates a link to a locked preset', () => { + const withAuth = generateShareUrl('http://192.168.10.173:4001', 'cuesheet', { + lockConfig: false, + lockNav: false, + authenticate: false, + preset: 'some-cuesheet-preset', + }); + expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset'); }); }); describe('for ontime-cloud URLs', () => { it('generates a link without locking or authentication', () => { - const cloudNotLocked = generateAuthenticatedUrl( - 'https://cloud.getontime.no/userhash', - 'timer', - false, - false, - 'prefix', - ); + const cloudNotLocked = generateShareUrl('https://cloud.getontime.no/userhash', 'timer', { + lockConfig: false, + lockNav: false, + authenticate: false, + prefix: 'prefix', + }); expect(cloudNotLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer'); }); - it('generates a link with IP locking enabled', () => { - const ipLocked = generateAuthenticatedUrl('https://cloud.getontime.no/prefix', 'timer', true, false, 'prefix'); - expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?locked=true'); + it('generates a link with navigation locking enabled', () => { + const ipLocked = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: false, + prefix: 'prefix', + }); + expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1'); }); - it('generates a link with authentication token and IP locking', () => { - const withAuth = generateAuthenticatedUrl( - 'https://cloud.getontime.no/prefix', - 'timer', - true, - true, - 'prefix', - '1234', - ); - expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&locked=true'); + it('generates a link with authentication token and navigation locking', () => { + const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: true, + prefix: 'prefix', + hash: '1234', + }); + expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1'); + }); + + it('generates a link to an unlocked preset', () => { + const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', { + lockConfig: false, + lockNav: false, + authenticate: false, + preset: 'minimal', + prefix: 'prefix', + }); + expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal'); + }); + + it('generates a link to an unlocked preset without navigation', () => { + const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', { + lockConfig: false, + lockNav: true, + authenticate: false, + preset: 'minimal', + prefix: 'prefix', + }); + expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal?n=1'); + }); + + it('generates a link to a locked preset', () => { + const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', { + lockConfig: true, + lockNav: false, + authenticate: false, + prefix: 'prefix', + preset: 'minimal', + }); + expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/minimal'); + }); + + it('generates a link to a locked preset', () => { + const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'cuesheet', { + lockConfig: false, + lockNav: false, + authenticate: false, + prefix: 'prefix', + preset: 'some-cuesheet-preset', + }); + expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/some-cuesheet-preset'); }); }); }); diff --git a/apps/server/src/api-data/session/session.router.ts b/apps/server/src/api-data/session/session.router.ts index 61de685ee..743b2859f 100644 --- a/apps/server/src/api-data/session/session.router.ts +++ b/apps/server/src/api-data/session/session.router.ts @@ -29,12 +29,12 @@ router.get('/info', async (_req: Request, res: Response router.post('/url', validateGenerateUrl, (req: Request, res: Response) => { try { - const url = sessionService.generateAuthenticatedUrl( - req.body.baseUrl, - req.body.path, - req.body.lock, - req.body.authenticate, - ); + const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, { + authenticate: req.body.authenticate, + lockConfig: req.body.lockConfig, + lockNav: req.body.lockNav, + preset: req.body.preset, + }); res.status(200).send({ url: url.toString() }); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts index 31b110d66..62d56e9e9 100644 --- a/apps/server/src/api-data/session/session.service.ts +++ b/apps/server/src/api-data/session/session.service.ts @@ -1,4 +1,4 @@ -import { GetInfo, SessionStats } from 'ontime-types'; +import { GetInfo, LinkOptions, OntimeView, SessionStats } from 'ontime-types'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { publicDir } from '../../setup/index.js'; @@ -57,22 +57,25 @@ export const hashedPassword = hasPassword ? hashPassword(password as string) : u /** * Generates a pre-authenticated URL by injecting a token in the URL params */ -export function generateAuthenticatedUrl( +export function generateShareUrl( baseUrl: string, - path: string, - lock: boolean, - authenticate: boolean, - prefix = routerPrefix, - hash = hashedPassword, + canonicalPath: string, + { authenticate, lockConfig, lockNav, preset, prefix = routerPrefix, hash = hashedPassword }: LinkOptions, ): URL { const url = new URL(baseUrl); - url.pathname = prefix ? `${prefix}/${path}` : path; + + // if the config is locked and we are in a preset, we hide the canonical path + const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig); + const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath; + url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath; if (authenticate && hash) { url.searchParams.append('token', hash); } - if (lock) { - url.searchParams.append('locked', 'true'); + + if (lockNav) { + url.searchParams.append('n', '1'); } + return url; } diff --git a/apps/server/src/api-data/session/session.validation.ts b/apps/server/src/api-data/session/session.validation.ts index 0872588c4..3903703b4 100644 --- a/apps/server/src/api-data/session/session.validation.ts +++ b/apps/server/src/api-data/session/session.validation.ts @@ -3,9 +3,14 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio export const validateGenerateUrl = [ body('baseUrl').isString().trim().notEmpty(), - body('path').isString().trim(), - body('lock').isBoolean(), + body('path').isString().trim().notEmpty(), + body('authenticate').isBoolean(), + body('lockConfig').isBoolean(), + body('lockNav').isBoolean(), + body('preset').optional().isString().trim().notEmpty(), + body('prefix').optional().isString().trim().notEmpty(), + body('hash').optional().isString().trim().notEmpty(), requestValidationFunction, ]; diff --git a/apps/server/src/api-data/url-presets/urlPresets.parser.ts b/apps/server/src/api-data/url-presets/urlPresets.parser.ts index 781381ffc..3e2c1fd57 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.parser.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.parser.ts @@ -16,16 +16,17 @@ export function parseUrlPresets(data: Partial, emitError?: ErrorE const newPresets: URLPreset[] = []; for (const preset of data.urlPresets) { - if (!preset.alias || !preset.search || !preset.target) { + if (!preset.alias || !preset.target) { emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`); continue; } - const newPreset = { + const newPreset: URLPreset = { enabled: preset.enabled ?? false, alias: preset.alias, target: preset.target, - search: preset.search, + search: preset.search ?? '', + options: preset?.options, }; newPresets.push(newPreset); } diff --git a/apps/server/src/api-data/url-presets/urlPresets.router.ts b/apps/server/src/api-data/url-presets/urlPresets.router.ts index 650ba57d8..f9518bee5 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.router.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.router.ts @@ -20,6 +20,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response { page.locator('data-testid=timer-view'); await expect(page).toHaveURL('http://localhost:4001/timer'); }); + + test('not-found', async ({ page }) => { + await page.goto('http://localhost:4001/not-found'); + + await expect(page).toHaveTitle(/ontime/); + await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); + + await page.goto('http://localhost:4001/preset/not-found'); + + await expect(page).toHaveTitle(/ontime/); + await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); + }); }); async function openNavigationMenu(page: Page) { diff --git a/e2e/tests/features/201-message-control.spec.ts b/e2e/tests/features/201-message-control.spec.ts index 98145bd87..90358fef2 100644 --- a/e2e/tests/features/201-message-control.spec.ts +++ b/e2e/tests/features/201-message-control.spec.ts @@ -14,4 +14,8 @@ test('message control sends messages to screens', async ({ context }) => { await featurePage.goto('http://localhost:4001/timer'); await featurePage.waitForLoadState('load', { timeout: 5000 }); await expect(featurePage.getByText('testing stage')).toBeVisible(); + + await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 }); + + await expect(featurePage.getByText('TIME NOW')).toBeVisible(); }); diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 2214915e3..df2c17e2b 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -1,28 +1,224 @@ import { expect, test } from '@playwright/test'; -test('URL preset feature, it should redirect to given URL', async ({ page }) => { - await page.goto('http://localhost:4001/editor'); +const aliasName = 'testing'; +const aliasUrl = + 'www.getontime.no/team/timer/?hideTimerSeconds=true&showLeadingZeros=true&freezeOvertime=true&hidePhase=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true'; - // open settings - await page.getByRole('button', { name: 'Toggle settings' }).click(); - await page.getByRole('button', { name: 'URL Presets' }).click(); +test.describe('URL Preset', () => { + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('http://localhost:4001/editor'); - // create preset - await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded(); - await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click(); + // Create the preset that will be used by other tests + await page.getByRole('button', { name: 'Toggle settings' }).click(); + await page.getByRole('button', { name: 'URL Presets' }).click(); - await page.locator('input[name="alias"]').click(); - await page.locator('input[name="alias"]').fill('testing'); + await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded(); + await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click(); - await page.getByRole('textbox', { name: 'Paste URL' }).click(); - await page.getByRole('textbox', { name: 'Paste URL' }).fill('www.getontime.no/team/countdown'); - await page.getByRole('button', { name: 'Generate' }).click(); + await page.locator('input[name="alias"]').click(); + await page.locator('input[name="alias"]').fill(aliasName); - await page.getByRole('combobox').filter({ hasText: 'Countdown' }); + await page.getByRole('textbox', { name: 'Paste URL' }).click(); + await page.getByRole('textbox', { name: 'Paste URL' }).fill(aliasUrl); + await page.getByRole('button', { name: 'Generate' }).click(); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('combobox').filter({ hasText: 'Timer' }); + await page.getByRole('button', { name: 'Save' }).click(); - // make sure preset works - await page.goto('http://localhost:4001/testing'); - await expect(page.getByTestId('countdown-view')).toBeVisible(); + await page.close(); + }); + + test('Unwrapping a preset from a view', async ({ page }) => { + await page.goto('http://localhost:4001/timer'); + + // 1. the URL points to the view + expect(page.url().includes('hideTimerSeconds=true')).not.toBeTruthy(); + expect(page.url().includes('alias=testing')).not.toBeTruthy(); + + // open settings + await page.getByRole('button', { name: 'Toggle settings' }).click(); + await page + .locator('div') + .filter({ hasText: /^testingApply$/ }) + .getByRole('button') + .click(); + await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible(); + + // 2. the URL contains the preset + expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy(); + expect(page.url().includes('alias=testing')).toBeTruthy(); + }); + + test('Sharing a link to an unwrapped preset', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + + // open settings + await page.getByRole('button', { name: 'Toggle settings' }).click(); + await page.getByRole('button', { name: 'Share link' }).click(); + + // select options + await page.getByRole('combobox').filter({ hasText: 'Timer' }).click(); + await page.getByText('URL Preset: testing').click(); + + // create and verify link + await page.getByRole('button', { name: 'Create share link' }).click(); + await expect(page.getByTestId('copy-link')).toContainText('testing'); + await expect(page.getByTestId('copy-link')).not.toContainText('n=1'); + + // verify the preset + const generatedUrl = await page.getByTestId('copy-link').textContent(); + await page.goto(generatedUrl); + + // make sure preset works in mask mode + await expect(page.getByTestId('timer-view')).toBeVisible(); + // the url unwraps the preset + expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy(); + expect(page.url().includes('alias=testing')).toBeTruthy(); + expect(page.url().includes('/preset/testing')).not.toBeTruthy(); + + // the menus work + await expect(page.getByTestId('navigation__toggle-settings')).toBeVisible(); + }); + + test('Sharing a link to a masked preset', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + + // open settings + await page.getByRole('button', { name: 'Toggle settings' }).click(); + await page.getByRole('button', { name: 'Share link' }).click(); + + // select options + await page.getByRole('combobox').filter({ hasText: 'Timer' }).click(); + await page.getByText('URL Preset: testing').click(); + await page.locator('button[name="lockNav"]').click(); + await page.locator('button[name="lockConfig"]').click(); + + // create and verify link + await page.getByRole('button', { name: 'Create share link' }).click(); + await expect(page.getByTestId('copy-link')).toContainText('/preset/testing'); + await expect(page.getByTestId('copy-link')).toContainText('n=1'); + + // verify the preset + const generatedUrl = await page.getByTestId('copy-link').textContent(); + await page.goto(generatedUrl); + + // make sure preset works in mask mode + await expect(page.getByTestId('timer-view')).toBeVisible(); + // the url masks the preset + expect(page.url().includes('hideTimerSeconds=true')).not.toBeTruthy(); + expect(page.url().includes('alias=testing')).not.toBeTruthy(); + expect(page.url().includes('/preset/testing')).toBeTruthy(); + }); +}); + +test.describe('Sharing from cuesheet', () => { + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('http://localhost:4001/editor'); + + // we create some elements to test with + await page.getByRole('button', { name: 'Clear all' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').getByTestId('block__title').click(); + await page.getByTestId('entry-1').getByTestId('block__title').fill('title 1'); + await page.getByTestId('entry-1').getByTestId('block__title').press('Enter'); + + await page.close(); + }); + + test('Sharing a link with readonly permissions', async ({ page }) => { + await page.goto('http://localhost:4001/cuesheet'); + await expect(page.getByTestId('cuesheet')).toBeVisible(); + + await page.getByRole('button', { name: 'Share...' }).click(); + + // configure share for readonly + await page.getByRole('textbox').fill('cuesheet-read-test'); + await page.getByText('Custom write').click(); + await page.getByText('Custom read').click(); + await page.locator('button[name="lockNav"]').click(); + await page.getByTestId('write-flag').click(); + await page.getByTestId('write-cue').click(); + await page.getByTestId('write-title').click(); + await page.getByTestId('write-timeStart').click(); + await page.getByTestId('write-timeEnd').click(); + await page.getByTestId('write-duration').click(); + await page.getByTestId('write-note').click(); + + // create and verify link + await page.getByRole('button', { name: 'Create share link' }).click(); + await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-read-test'); + await expect(page.getByTestId('copy-link')).toContainText('n=1'); + + // verify the preset + const generatedUrl = await page.getByTestId('copy-link').textContent(); + await page.goto(generatedUrl); + + // the menu is locked and we cant make shares + await expect(page.getByTestId('cuesheet')).toBeVisible(); + await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden(); + + // check that we are locked and cannot edit + await page.getByRole('button', { name: 'Edit' }).click(); + + // Verify that the title is visible but not editable + await expect(page.getByTestId('cuesheet-event').getByText('title 1')).toBeVisible(); + await expect(page.getByTestId('cuesheet-event').locator('input')).toBeHidden(); + + // other elements are still there + await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible(); + }); + + test('Sharing a link with scoped read-write permissions', async ({ page }) => { + await page.goto('http://localhost:4001/cuesheet'); + await expect(page.getByTestId('cuesheet')).toBeVisible(); + + await page.getByRole('button', { name: 'Share...' }).click(); + + // configure share for readonly + await page.getByRole('textbox').fill('cuesheet-scope-test'); + await page.getByText('Custom write').click(); + await page.getByText('Custom read').click(); + await page.locator('button[name="lockNav"]').click(); + await page.getByTestId('write-flag').click(); + await page.getByTestId('write-cue').click(); + await page.getByTestId('write-timeStart').click(); + await page.getByTestId('write-timeEnd').click(); + await page.getByTestId('write-duration').click(); + await page.getByTestId('write-note').click(); + await page.getByTestId('read-flag').click(); + await page.getByTestId('read-cue').click(); + await page.getByTestId('read-timeStart').click(); + await page.getByTestId('read-timeEnd').click(); + await page.getByTestId('read-duration').click(); + await page.getByTestId('read-note').click(); + + // create and verify link + await page.getByRole('button', { name: 'Create share link' }).click(); + await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-scope-test'); + await expect(page.getByTestId('copy-link')).toContainText('n=1'); + + // verify the preset + const generatedUrl = await page.getByTestId('copy-link').textContent(); + await page.goto(generatedUrl); + + // the menu is locked and we cant make shares + await expect(page.getByTestId('cuesheet')).toBeVisible(); + await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden(); + + // check that we are locked and cannot edit + await page.getByRole('button', { name: 'Edit' }).click(); + + // Verify that the title is visible and editable + await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible(); + + // other elements are not there + await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden(); + }); }); diff --git a/e2e/tests/features/207-view-params.spec.ts b/e2e/tests/features/207-view-params.spec.ts index 3f2a54570..f738e90a5 100644 --- a/e2e/tests/features/207-view-params.spec.ts +++ b/e2e/tests/features/207-view-params.spec.ts @@ -8,7 +8,8 @@ test('View params configures timer view', async ({ page }) => { await page.mouse.move(Math.random() * 100, Math.random() * 100); await page.getByTestId('navigation__toggle-settings').click(); await page.locator('label').filter({ hasText: 'Hide Time NowHides the Time' }).locator('span').nth(2).click(); - await page.getByRole('button', { name: 'Apply' }).click(); + await page.getByTestId('apply-view-params').click(); + await page.getByTestId('close-view-params').click(); await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport(); await expect(page).toHaveURL(/.*hideClock=true/); diff --git a/packages/types/src/api/session-controller/BackendResponse.type.ts b/packages/types/src/api/session-controller/BackendResponse.type.ts new file mode 100644 index 000000000..5d370c55f --- /dev/null +++ b/packages/types/src/api/session-controller/BackendResponse.type.ts @@ -0,0 +1,8 @@ +export type LinkOptions = { + authenticate: boolean; + lockConfig: boolean; + lockNav: boolean; + preset?: string; + prefix?: string; + hash?: string; +}; diff --git a/packages/types/src/definitions/core/UrlPreset.type.ts b/packages/types/src/definitions/core/UrlPreset.type.ts index 474bdeb29..326e9f82b 100644 --- a/packages/types/src/definitions/core/UrlPreset.type.ts +++ b/packages/types/src/definitions/core/UrlPreset.type.ts @@ -13,10 +13,25 @@ export enum OntimeView { ProjectInfo = 'info', } -export type URLPreset = { - // presets cannot target the editor view - target: Omit; +export type OntimeViewPresettable = Exclude; + +type BaseURLPreset = { + target: OntimeViewPresettable; enabled: boolean; alias: string; search: string; + options?: Record; }; + +type CuesheetUrlPreset = { + target: OntimeView.Cuesheet; + enabled: boolean; + alias: string; + search: string; + options: { + read: string; + write: string; + }; +}; + +export type URLPreset = BaseURLPreset | CuesheetUrlPreset; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82ad05399..83a45038c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -52,7 +52,7 @@ export type { ViewSettings } from './definitions/core/Views.type.js'; export type { TimeFormat } from './definitions/core/TimeFormat.type.js'; // ---> URL Presets -export { OntimeView, type URLPreset } from './definitions/core/UrlPreset.type.js'; +export { OntimeView, type URLPreset, type OntimeViewPresettable } from './definitions/core/UrlPreset.type.js'; // ---> Custom Fields export type { @@ -63,6 +63,7 @@ export type { } from './definitions/core/CustomFields.type.js'; // SERVER RESPONSES +export type { QuickStartData } from './api/db/db.type.js'; export type { AuthenticationStatus, NetworkInterface, @@ -76,13 +77,13 @@ export type { SessionStats, ProjectLogoResponse, } from './api/ontime-controller/BackendResponse.type.js'; -export type { QuickStartData } from './api/db/db.type.js'; export type { EventPostPayload, PatchWithId, ProjectRundownsList, TransientEventPayload, } from './api/rundown-controller/BackendResponse.type.js'; +export type { LinkOptions } from './api/session-controller/BackendResponse.type.js'; // web socket export { MessageTag } from './api/websocket/data.type.js';