diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 6fe048cc0..7e787756c 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -1,8 +1,8 @@ import { lazy, Suspense } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; -import withAlias from './features/AliasWrapper'; import Log from './features/log/Log'; +import withPreset from './features/PresetWrapper'; import withData from './features/viewers/ViewWrapper'; const Editor = lazy(() => import('./features/editors/ProtectedEditor')); @@ -19,14 +19,14 @@ const Public = lazy(() => import('./features/viewers/public/Public')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); -const STimer = withAlias(withData(TimerView)); -const SMinimalTimer = withAlias(withData(MinimalTimerView)); -const SClock = withAlias(withData(ClockView)); -const SCountdown = withAlias(withData(Countdown)); -const SBackstage = withAlias(withData(Backstage)); -const SPublic = withAlias(withData(Public)); -const SLowerThird = withAlias(withData(Lower)); -const SStudio = withAlias(withData(StudioClock)); +const STimer = withPreset(withData(TimerView)); +const SMinimalTimer = withPreset(withData(MinimalTimerView)); +const SClock = withPreset(withData(ClockView)); +const SCountdown = withPreset(withData(Countdown)); +const SBackstage = withPreset(withData(Backstage)); +const SPublic = withPreset(withData(Public)); +const SLowerThird = withPreset(withData(Lower)); +const SStudio = withPreset(withData(StudioClock)); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); diff --git a/apps/client/src/common/api/aliases.ts b/apps/client/src/common/api/aliases.ts deleted file mode 100644 index 7eacb2e29..000000000 --- a/apps/client/src/common/api/aliases.ts +++ /dev/null @@ -1,21 +0,0 @@ -import axios from 'axios'; -import { Alias } from 'ontime-types'; - -import { apiEntryUrl } from './constants'; - -const aliasesPath = `${apiEntryUrl}/aliases`; - -/** - * HTTP request to retrieve aliases - */ -export async function getAliases(): Promise { - const res = await axios.get(aliasesPath); - return res.data; -} - -/** - * HTTP request to mutate aliases - */ -export async function postAliases(data: Alias[]): Promise { - return axios.post(aliasesPath, data); -} diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 9b437eb37..ea06255b5 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -1,7 +1,7 @@ // keys in tanstack store -export const ALIASES = ['aliases']; export const APP_INFO = ['appinfo']; export const APP_SETTINGS = ['appSettings']; +export const CUSTOM_FIELDS = ['customFields']; export const HTTP_SETTINGS = ['httpSettings']; export const OSC_SETTINGS = ['oscSettings']; export const PROJECT_DATA = ['project']; @@ -9,7 +9,7 @@ export const PROJECT_LIST = ['projectList']; export const RUNDOWN = ['rundown']; export const RUNTIME = ['runtimeStore']; export const SHEET_STATE = ['sheetState']; -export const CUSTOM_FIELDS = ['customFields']; +export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; // resolve location diff --git a/apps/client/src/common/api/urlPresets.ts b/apps/client/src/common/api/urlPresets.ts new file mode 100644 index 000000000..68820a155 --- /dev/null +++ b/apps/client/src/common/api/urlPresets.ts @@ -0,0 +1,21 @@ +import axios from 'axios'; +import { URLPreset } from 'ontime-types'; + +import { apiEntryUrl } from './constants'; + +const urlPresetsPath = `${apiEntryUrl}/url-presets`; + +/** + * HTTP request to retrieve aliases + */ +export async function getUrlPresets(): Promise { + const res = await axios.get(urlPresetsPath); + return res.data; +} + +/** + * HTTP request to mutate aliases + */ +export async function postUrlPresets(data: URLPreset[]): Promise { + return axios.post(urlPresetsPath, data); +} diff --git a/apps/client/src/common/hooks-query/useAliases.ts b/apps/client/src/common/hooks-query/useAliases.ts deleted file mode 100644 index 4503ffd7d..000000000 --- a/apps/client/src/common/hooks-query/useAliases.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { getAliases } from '../api/aliases'; -import { ALIASES } from '../api/constants'; - -export default function useAliases() { - const { data, status, isFetching, isError, refetch } = useQuery({ - queryKey: ALIASES, - queryFn: getAliases, - placeholderData: [], - retry: 5, - retryDelay: (attempt) => attempt * 2500, - refetchInterval: queryRefetchIntervalSlow, - networkMode: 'always', - }); - - return { data, status, isFetching, isError, refetch }; -} diff --git a/apps/client/src/common/hooks-query/useUrlPresets.ts b/apps/client/src/common/hooks-query/useUrlPresets.ts new file mode 100644 index 000000000..45f3f242e --- /dev/null +++ b/apps/client/src/common/hooks-query/useUrlPresets.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { URL_PRESETS } from '../api/constants'; +import { getUrlPresets } from '../api/urlPresets'; + +export default function useUrlPresets() { + const { data, status, isError, refetch } = useQuery({ + queryKey: URL_PRESETS, + queryFn: getUrlPresets, + placeholderData: [], + retry: 5, + retryDelay: (attempt) => attempt * 2500, + refetchInterval: queryRefetchIntervalSlow, + networkMode: 'always', + }); + + return { data: data ?? [], status, isError, refetch }; +} diff --git a/apps/client/src/common/utils/__tests__/aliases.test.js b/apps/client/src/common/utils/__tests__/urlPresets.test.js similarity index 65% rename from apps/client/src/common/utils/__tests__/aliases.test.js rename to apps/client/src/common/utils/__tests__/urlPresets.test.js index ab75ce8df..d274c62cd 100644 --- a/apps/client/src/common/utils/__tests__/aliases.test.js +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.js @@ -1,8 +1,8 @@ import { resolvePath } from 'react-router-dom'; -import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases'; +import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets'; -describe('An alias fails if incorrect', () => { +describe('A preset fails if incorrect', () => { const testsToFail = [ // no empty '', @@ -21,11 +21,11 @@ describe('An alias fails if incorrect', () => { testsToFail.forEach((t) => it(`${t}`, () => { - expect(validateAlias(t).status).toBeFalsy(); + expect(validateUrlPresetPath(t).isValid).toBeFalsy(); }), ); }); -describe('generateURLFromAlias and getAliasRoute function', () => { +describe('generateUrlFromPreset and getRouteFromPreset function', () => { test('generate the expected url from an alias', () => { const testData = [ { @@ -41,10 +41,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => { }, ]; - expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url); + expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url); }); test('generate the url to redirect to when the current URL is just the alias', () => { - const aliases = [ + const presets = [ { enabled: true, alias: 'demopage', @@ -52,7 +52,7 @@ describe('generateURLFromAlias and getAliasRoute function', () => { }, ]; // let current location be the alias - const location = resolvePath(aliases[0].alias); + const location = resolvePath(presets[0].alias); const expected = [ { @@ -60,10 +60,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => { }, ]; - expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url); + expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url); }); test('generate the url to redirect to when the current URL the same url but with a change of params', () => { - const aliases = [ + const presets = [ { enabled: true, alias: 'demopage', @@ -71,22 +71,22 @@ describe('generateURLFromAlias and getAliasRoute function', () => { }, ]; // let current location be the actual url with alias attached to it - const location = resolvePath(aliases[0].pathAndParams); + const location = resolvePath(presets[0].pathAndParams); const urlSearchParams = new URLSearchParams(location.search); - urlSearchParams.append('alias', aliases[0].alias); // + urlSearchParams.append('alias', presets[0].alias); // // update current alias with extra param - aliases[0].pathAndParams += '&eventId=674'; + presets[0].pathAndParams += '&eventId=674'; const expected = [ { url: '/timer?user=guest&eventId=674&alias=demopage', }, ]; - expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url); + expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url); }); test('generate no url to redirect to when the current URL the same url', () => { - const aliases = [ + const presets = [ { enabled: true, alias: 'demopage', @@ -94,10 +94,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => { }, ]; // let current location be the actual url with alias attached to it - const location = resolvePath(aliases[0].pathAndParams); + const location = resolvePath(presets[0].pathAndParams); const urlSearchParams = new URLSearchParams(location.search); - urlSearchParams.append('alias', aliases[0].alias); // + urlSearchParams.append('alias', presets[0].alias); // - expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull(); + expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull(); }); }); diff --git a/apps/client/src/common/utils/aliases.ts b/apps/client/src/common/utils/aliases.ts deleted file mode 100644 index d3681b592..000000000 --- a/apps/client/src/common/utils/aliases.ts +++ /dev/null @@ -1,77 +0,0 @@ -import isEqual from 'react-fast-compare'; -import { Location, resolvePath } from 'react-router-dom'; -import { Alias } from 'ontime-types'; - -/** - * Validates an alias against defined parameters - * @param {string} alias - * @returns {{message: string, status: boolean}} - */ -export const validateAlias = (alias: string) => { - const valid = { status: true, message: 'ok' }; - - if (alias === '' || alias == null) { - // cannot be empty - valid.status = false; - valid.message = 'should not be empty'; - } else if (alias.includes('http') || alias.includes('https') || alias.includes('www')) { - // cannot contain http, https or www - valid.status = false; - valid.message = 'should not include http, https, www'; - } else if (alias.includes('127.0.0.1') || alias.includes('localhost') || alias.includes('0.0.0.0')) { - // aliases cannot contain hostname - valid.status = false; - valid.message = 'should not include hostname'; - } else if (alias.includes('editor')) { - // no editor - valid.status = false; - valid.message = 'No aliases to editor page allowed'; - } - - return valid; -}; - -/** - * Gets the URL to send an alias to - * @param location - * @param data - * @param searchParams - */ -export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => { - const currentURL = location.pathname.substring(1); - // we need to check if the whole url here is an alias, so we can redirect - const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0]; - if (foundAlias) { - return generateURLFromAlias(foundAlias); - } - const aliasOnPage = searchParams.get('alias'); - for (const d of data) { - if (aliasOnPage) { - // if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL - // if we have the same alias and its enabled and its not empty - if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) { - const newAliasPath = resolvePath(d.pathAndParams); - const urlParams = new URLSearchParams(newAliasPath.search); - urlParams.set('alias', d.alias); - // we confirm either the url parameters does not match or the url path doesnt - if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) { - // we then redirect to the alias route, since the view listening to this alias has an outdated URL - return `${newAliasPath.pathname}?${urlParams}`; - } - } - } - } - return null; -}; - -/** - * Generate URL from an alias - * @param aliasData - */ -export const generateURLFromAlias = (aliasData: Alias) => { - const newAliasPath = resolvePath(aliasData.pathAndParams); - const urlParams = new URLSearchParams(newAliasPath.search); - urlParams.set('alias', aliasData.alias); - - return `${newAliasPath.pathname}?${urlParams}`; -}; diff --git a/apps/client/src/common/utils/urlPresets.ts b/apps/client/src/common/utils/urlPresets.ts new file mode 100644 index 000000000..18f17efe8 --- /dev/null +++ b/apps/client/src/common/utils/urlPresets.ts @@ -0,0 +1,76 @@ +import isEqual from 'react-fast-compare'; +import { Location, resolvePath } from 'react-router-dom'; +import { URLPreset } from 'ontime-types'; + +/** + * Validates a preset against defined parameters + * @param {string} preset + * @returns {{message: string, isValid: boolean}} + */ +export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => { + if (preset === '' || preset == null) { + return { isValid: false, message: 'Path cannot be empty' }; + } + + if (preset.includes('http') || preset.includes('https') || preset.includes('www')) { + return { isValid: false, message: 'Path should not include http, https, www' }; + } + + if (preset.includes('127.0.0.1') || preset.includes('localhost') || preset.includes('0.0.0.0')) { + return { isValid: false, message: 'Path should not include hostname' }; + } + + if (preset.includes('editor')) { + // no editor + return { isValid: false, message: 'No path to editor page allowed' }; + } + + return { isValid: true, message: 'ok' }; +}; + +/** + * Gets the URL to send a preset to + * @param location + * @param data + * @param searchParams + */ +export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => { + const currentURL = location.pathname.substring(1); + + // we need to check if the whole url here is an alias, so we can redirect + const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0]; + if (foundPreset) { + return generateUrlFromPreset(foundPreset); + } + + const presetOnPage = searchParams.get('alias'); + for (const d of data) { + if (presetOnPage) { + // if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL + // if we have the same alias and its enabled and its not empty + if (d.alias !== '' && d.enabled && d.alias === presetOnPage) { + const newPath = resolvePath(d.pathAndParams); + const urlParams = new URLSearchParams(newPath.search); + urlParams.set('alias', d.alias); + // we confirm either the url parameters does not match or the url path doesnt + if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) { + // we then redirect to the alias route, since the view listening to this alias has an outdated URL + return `${newPath.pathname}?${urlParams}`; + } + } + } + } + return null; +}; + +/** + * Generate URL from an preset + * @param presetData + */ +export const generateUrlFromPreset = (presetData: URLPreset) => { + const newPresetPath = resolvePath(presetData.pathAndParams); + const urlParams = new URLSearchParams(newPresetPath.search); + urlParams.set('alias', presetData.alias); + + return `${newPresetPath.pathname}?${urlParams}`; +}; diff --git a/apps/client/src/features/AliasWrapper.tsx b/apps/client/src/features/PresetWrapper.tsx similarity index 64% rename from apps/client/src/features/AliasWrapper.tsx rename to apps/client/src/features/PresetWrapper.tsx index 9f0422ea4..e46ad8af3 100644 --- a/apps/client/src/features/AliasWrapper.tsx +++ b/apps/client/src/features/PresetWrapper.tsx @@ -2,12 +2,12 @@ import { ComponentType, useEffect } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import useAliases from '../common/hooks-query/useAliases'; -import { getAliasRoute } from '../common/utils/aliases'; +import useUrlPresets from '../common/hooks-query/useUrlPresets'; +import { getRouteFromPreset } from '../common/utils/urlPresets'; -const withAlias =

(Component: ComponentType

) => { +const withPreset =

(Component: ComponentType

) => { return (props: Partial

) => { - const { data } = useAliases(); + const { data } = useUrlPresets(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); @@ -15,7 +15,7 @@ const withAlias =

(Component: ComponentType

) => { // navigate if is alias route useEffect(() => { if (!data) return; - const url = getAliasRoute(location, data, searchParams); + const url = getRouteFromPreset(location, data, searchParams); // navigate to this route if its not empty if (url) { navigate(url); @@ -26,4 +26,4 @@ const withAlias =

(Component: ComponentType

) => { }; }; -export default withAlias; +export default withPreset; diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 8d4ed1fe7..5554948c0 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -2,6 +2,7 @@ position: absolute; top: 1rem; right: 1rem; + z-index: 100; } .contentWrapper { diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.tsx b/apps/client/src/features/app-settings/panel-list/PanelList.tsx index 3a95b9bce..90799b91c 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.tsx +++ b/apps/client/src/features/app-settings/panel-list/PanelList.tsx @@ -41,7 +41,7 @@ export default function PanelList() { {panel.secondary?.map((secondary) => { return ( -

  • handleSelect(panel)} className={style.secondary}> +
  • handleSelect(panel)} className={style.secondary} role='button'> {secondary.label}
  • ); diff --git a/apps/client/src/features/app-settings/panel/Panel.module.scss b/apps/client/src/features/app-settings/panel/Panel.module.scss index ac294bef0..5f316e0c2 100644 --- a/apps/client/src/features/app-settings/panel/Panel.module.scss +++ b/apps/client/src/features/app-settings/panel/Panel.module.scss @@ -42,8 +42,9 @@ $inner-padding: 1rem; } .card { + position: relative; padding: 2rem; - background-color: $white-1; + background-color: $white-3; border: 1px solid $gray-1100; border-radius: 3px; } @@ -55,7 +56,7 @@ $inner-padding: 1rem; } .pad { - margin: 0 2rem; + padding: 0 2rem; max-height: 550px; overflow-y: scroll; } @@ -138,7 +139,7 @@ $loader-size: 4rem; z-index: 10; width: calc(100% - 2rem); left: 1rem; - height: calc(100% - 1rem); + height: calc(80% - 1rem); display: grid; place-content: center; backdrop-filter: blur(5px); diff --git a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.module.scss b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.module.scss index bfd682999..c5a7be0a1 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.module.scss @@ -2,3 +2,23 @@ display: flex; gap: 1em; } + +.pad { + padding: 0 2rem; +} + +.fit { + width: fit-content; +} + +.aliasConstrain { + min-width: 12em; +} + +.fullWidth { + width: 100%; +} + +.flex { + display: flex; +} diff --git a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx index 0962c20ab..c43947062 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanel.tsx @@ -1,6 +1,7 @@ import * as Panel from '../PanelUtils'; import GeneralPanelForm from './GeneralPanelForm'; +import UrlPresetsForm from './UrlPresetsForm'; import ViewSettingsForm from './ViewSettingsForm'; export default function GeneralPanel() { @@ -9,6 +10,7 @@ export default function GeneralPanel() { Settings + ); } diff --git a/apps/client/src/features/app-settings/panel/general-panel/UrlPresetsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/UrlPresetsForm.tsx new file mode 100644 index 000000000..27817e36e --- /dev/null +++ b/apps/client/src/features/app-settings/panel/general-panel/UrlPresetsForm.tsx @@ -0,0 +1,207 @@ +import { useEffect } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { URLPreset } from 'ontime-types'; + +import { postUrlPresets } from '../../../../common/api/urlPresets'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn'; +import ExternalLink from '../../../../common/components/external-link/ExternalLink'; +import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; +import { handleLinks } from '../../../../common/utils/linkUtils'; +import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; +import * as Panel from '../PanelUtils'; + +import style from './GeneralPanel.module.scss'; + +const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/'; + +type FormData = { + data: URLPreset[]; +}; + +export default function UrlPresetsForm() { + const { data, status, refetch } = useUrlPresets(); + const { + control, + handleSubmit, + register, + reset, + setError, + formState: { isSubmitting, isDirty, isValid, errors }, + } = useForm({ + mode: 'onBlur', + defaultValues: { data }, + values: { data }, + resetOptions: { + keepDirtyValues: true, + }, + }); + const { fields, prepend, remove } = useFieldArray({ + name: 'data', + control, + }); + + // reset form if we get new data from backend + useEffect(() => { + if (data) { + reset({ data }); + } + }, [data, reset]); + + const onSubmit = async (formData: FormData) => { + for (let i = 0; i < formData.data.length; i++) { + const preset = formData.data[i]; + const { isValid, message } = validateUrlPresetPath(preset.pathAndParams); + if (!isValid) { + setError(`data.${i}.pathAndParams`, { message }); + return; + } + } + + try { + await postUrlPresets(formData.data); + } catch (error) { + const message = maybeAxiosError(error); + setError('root', { message }); + } finally { + await refetch(); + } + }; + + const onReset = () => { + reset({ data }); + }; + + const addNew = () => { + prepend({ + enabled: false, + alias: '', + pathAndParams: '', + }); + }; + + const isPending = status === 'pending'; + const canSubmit = !isSubmitting && isDirty && isValid; + + return ( + + + + URL Presets +
    + + +
    +
    + + {isPending && } + + + + + URL Presets +
    +
    + Custom presets allow providing a short name for any ontime URL.
    + - Providing dynamic URLs for automation or unattended screens
    - Simplifying complex URLs +
    +
    + See the docs +
    +
    + + Manage presets + + + {errors?.root && {errors.root.message}} + {errors?.data && {errors.data.message}} + + + + + Active + Preset + URL + + + + + {fields.map((preset, index) => { + const maybeAliasError = errors.data?.[index]?.alias?.message; + const maybeUrlError = errors.data?.[index]?.pathAndParams?.message; + return ( + + + + + + + {maybeAliasError} + + + + {maybeUrlError} + + + handleLinks(event, preset.alias)} + tooltip='Test preset' + aria-label='Test preset' + variant='ontime-ghosted' + color='#e2e2e2' // $gray-200 + icon={} + data-testid={`field__test_${index}`} + /> + remove(index)} + variant='ontime-ghosted' + color='#FA5656' // $red-500 + icon={} + aria-label='Delete entry' + data-testid={`field__delete_${index}`} + /> + + + ); + })} + + +
    +
    +
    + ); +} diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx index 724221a3b..bb2f18b1c 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx @@ -77,7 +77,7 @@ export default function HttpIntegrations() { HTTP
    diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx index 955aa62d0..fae2dbde0 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx @@ -82,7 +82,7 @@ export default function OscIntegrations() { Open Sound Control
    diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 016533297..b830742f7 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -12,7 +12,6 @@ import styles from './Editor.module.scss'; const Rundown = lazy(() => import('../rundown/RundownExport')); const TimerControl = lazy(() => import('../control/playback/TimerControlExport')); const MessageControl = lazy(() => import('../control/message/MessageControlExport')); -const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal')); export default function Editor() { const showSettings = useSettingsStore((state) => state.showSettings); @@ -32,33 +31,28 @@ export default function Editor() { const isSettingsOpen = Boolean(showSettings); return ( - <> +
    - + -
    - - - - {showSettings ? ( - - ) : ( -
    -
    - - -
    - + {showSettings ? ( + + ) : ( +
    +
    + +
    - )} - -
    - + +
    + )} + +
    ); } diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 3a1f96754..7accb71aa 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -29,7 +29,7 @@ const buttonStyle = { }; const MenuBar = (props: MenuBarProps) => { - const { isOldSettingsOpen, onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props; + const { onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props; const { isElectron, sendToElectron } = useElectronEvent(); const sendShutdown = () => { @@ -50,6 +50,8 @@ const MenuBar = (props: MenuBarProps) => { if (event.key === ',') { // open if not open isSettingsOpen ? onSettingsClose() : onSettingsOpen(); + event.preventDefault(); + event.stopPropagation(); } } }, @@ -70,18 +72,6 @@ const MenuBar = (props: MenuBarProps) => { return (
    -
    - -
    - } - className={isOldSettingsOpen ? style.open : ''} - clickHandler={onSettingsOpen} - tooltip='Settings deprecated' - aria-label='Settings deprecated' - /> - ) { - const { field, title, description, error, children } = props; - - return ( - - - {children} - - ); -} diff --git a/apps/client/src/features/modals/ModalLink.module.scss b/apps/client/src/features/modals/ModalLink.module.scss deleted file mode 100644 index 94523ccfc..000000000 --- a/apps/client/src/features/modals/ModalLink.module.scss +++ /dev/null @@ -1,17 +0,0 @@ -.link { - display: flex; - align-items: center; - gap: 4px; - - color: $blue-500; - transition-property: color; - transition-duration: $transition-time-action; - - &.inline { - display: inline-flex; - } - - &:hover { - color: $ontime-color; - } -} diff --git a/apps/client/src/features/modals/ModalLink.tsx b/apps/client/src/features/modals/ModalLink.tsx deleted file mode 100644 index 3e6a6b6a6..000000000 --- a/apps/client/src/features/modals/ModalLink.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { MouseEvent, ReactNode } from 'react'; -import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; - -import { openLink } from '../../common/utils/linkUtils'; -import { cx } from '../../common/utils/styleUtils'; - -import style from './ModalLink.module.scss'; - -interface ModalLinkProps { - href: string; - children: ReactNode; - inline?: boolean; -} - -export default function ModalLink(props: ModalLinkProps) { - const { href, inline, children } = props; - const classes = cx([style.link, inline ? style.inline : null]); - - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - openLink(href); - }; - - return ( - - {children} - - ); -} diff --git a/apps/client/src/features/modals/ModalSplitInput.tsx b/apps/client/src/features/modals/ModalSplitInput.tsx deleted file mode 100644 index 6e0b0f181..000000000 --- a/apps/client/src/features/modals/ModalSplitInput.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { PropsWithChildren } from 'react'; -import { FormControl } from '@chakra-ui/react'; - -import style from './settings-modal/SettingsModal.module.scss'; - -interface ModalSplitInputProps { - field: string; - title: string; - description: string; - error?: string; -} - -export default function ModalSplitInput(props: PropsWithChildren) { - const { field, title, description, error, children } = props; - - return ( - - - {children} - - ); -} diff --git a/apps/client/src/features/modals/ModalWrapper.tsx b/apps/client/src/features/modals/ModalWrapper.tsx deleted file mode 100644 index 5b81ac1d0..000000000 --- a/apps/client/src/features/modals/ModalWrapper.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { PropsWithChildren } from 'react'; -import { Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react'; - -interface ModalWrapperProps { - isOpen: boolean; - onClose: () => void; - title: string; - size?: string; -} - -export default function ModalWrapper(props: PropsWithChildren) { - const { isOpen, onClose, title, size = 'xl', children } = props; - return ( - - - - {title} - - {children} - - - ); -} diff --git a/apps/client/src/features/modals/OntimeModalFooter.tsx b/apps/client/src/features/modals/OntimeModalFooter.tsx deleted file mode 100644 index ea9110863..000000000 --- a/apps/client/src/features/modals/OntimeModalFooter.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Button, ModalFooter } from '@chakra-ui/react'; - -import styles from './Modal.module.scss'; - -interface OntimeModalFooterProps { - formId: string; - handleRevert: () => void; - isDirty: boolean; - isValid: boolean; - isSubmitting: boolean; -} - -export default function OntimeModalFooter(props: OntimeModalFooterProps) { - const { formId, handleRevert, isDirty, isValid, isSubmitting } = props; - - const disableRevert = !isDirty; - const disableSubmit = isSubmitting || !isDirty || !isValid; - - return ( - - - - - ); -} diff --git a/apps/client/src/features/modals/modal-loader/ModalLoader.module.scss b/apps/client/src/features/modals/modal-loader/ModalLoader.module.scss deleted file mode 100644 index 9178648d5..000000000 --- a/apps/client/src/features/modals/modal-loader/ModalLoader.module.scss +++ /dev/null @@ -1,44 +0,0 @@ -.screenLoader { - position: absolute; - z-index: 10; - width: 100%; - height: 100%; - display: grid; - place-content: center; - top: 0; - background-color: $white-60; -} - -$loader-size: 48px; - -.loader { - width: $loader-size; - height: $loader-size; - background: $blue-500; - display: inline-block; - border-radius: 50%; - box-sizing: border-box; - animation: animloader 1s ease-in infinite; -} - -@keyframes animloader { - 0% { - transform: scale(0); - opacity: 0.6; - } - 100% { - transform: scale(1); - opacity: 0; - } -} - -@keyframes animloader { - 0% { - transform: scale(0); - opacity: 0.6; - } - 100% { - transform: scale(1); - opacity: 0; - } -} diff --git a/apps/client/src/features/modals/modal-loader/ModalLoader.tsx b/apps/client/src/features/modals/modal-loader/ModalLoader.tsx deleted file mode 100644 index ed00a3b88..000000000 --- a/apps/client/src/features/modals/modal-loader/ModalLoader.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import style from './ModalLoader.module.scss'; - -export default function ModalLoader() { - return ( -
    - -
    - ); -} diff --git a/apps/client/src/features/modals/modalHelper.js b/apps/client/src/features/modals/modalHelper.js deleted file mode 100644 index 5b47f35de..000000000 --- a/apps/client/src/features/modals/modalHelper.js +++ /dev/null @@ -1,4 +0,0 @@ -export const inputProps = { - size: 'sm', - autoComplete: 'off', -}; diff --git a/apps/client/src/features/modals/settings-modal/AliasesForm.tsx b/apps/client/src/features/modals/settings-modal/AliasesForm.tsx deleted file mode 100644 index 13a0aea5e..000000000 --- a/apps/client/src/features/modals/settings-modal/AliasesForm.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useEffect } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, IconButton, Input, Switch } from '@chakra-ui/react'; -import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; -import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; -import { Alias } from 'ontime-types'; - -import { postAliases } from '../../../common/api/aliases'; -import { logAxiosError } from '../../../common/api/utils'; -import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; -import useAliases from '../../../common/hooks-query/useAliases'; -import { useEmitLog } from '../../../common/stores/logger'; -import { handleLinks } from '../../../common/utils/linkUtils'; -import ModalLoader from '../modal-loader/ModalLoader'; -import { inputProps } from '../modalHelper'; -import ModalLink from '../ModalLink'; -import OntimeModalFooter from '../OntimeModalFooter'; - -import style from './SettingsModal.module.scss'; - -const aliasesDocsUrl = 'https://docs.getontime.no/features/url-presets/'; - -// we wrap the array in an object to be simplify react-hook-form -type Aliases = { - aliases: Alias[]; -}; - -export default function AliasesForm() { - const { data, status, isFetching, refetch } = useAliases(); - const { emitError } = useEmitLog(); - const { - control, - handleSubmit, - register, - reset, - formState: { isSubmitting, isDirty, isValid }, - } = useForm({ - defaultValues: { aliases: data }, - values: { aliases: data || [] }, - resetOptions: { - keepDirtyValues: true, - }, - }); - const { fields, append, remove } = useFieldArray({ - name: 'aliases', - control, - }); - - useEffect(() => { - if (data) { - reset({ aliases: data }); - } - }, [data, reset]); - - const onSubmit = async (formData: Aliases) => { - try { - await postAliases(formData.aliases); - } catch (error) { - logAxiosError('Error saving aliases', error); - } finally { - await refetch(); - } - }; - - const onReset = () => { - reset({ aliases: data }); - }; - - const addNew = () => { - if (fields.length > 20) { - emitError('Maximum amount of aliases reached (20)'); - return; - } - append({ - enabled: false, - alias: '', - pathAndParams: '', - }); - }; - - const disableInputs = status === 'pending'; - const hasTooManyOptions = fields.length >= 20; - - if (isFetching) { - return ; - } - - return ( -
    -
    - - -
    - URL Aliases - - Custom aliases allow providing a short name for any ontime URL.
    - It serves two primary purposes:
    - - Providing dynamic URLs for automation or unattended screens
    - Simplifying complex URLs - For more information, see the docs -
    -
    -
    -
    -
      - {fields.map((alias, index) => { - return ( -
    • - remove(index)} - aria-label='delete' - size='xs' - icon={} - colorScheme='red' - isDisabled={disableInputs} - data-testid={`field__delete_${index}`} - /> - - - handleLinks(event, alias.alias)} - tooltip='Test alias' - aria-label='Test alias' - size='xs' - variant='ontime-ghost-on-light' - icon={} - colorScheme='red' - isDisabled={disableInputs} - data-testid={`field__test_${index}`} - /> - -
    • - ); - })} -
    - - - - ); -} diff --git a/apps/client/src/features/modals/settings-modal/ProjectDataForm.tsx b/apps/client/src/features/modals/settings-modal/ProjectDataForm.tsx deleted file mode 100644 index 744af1777..000000000 --- a/apps/client/src/features/modals/settings-modal/ProjectDataForm.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { Input, Textarea } from '@chakra-ui/react'; -import { ProjectData } from 'ontime-types'; - -import { postProjectData } from '../../../common/api/project'; -import { logAxiosError } from '../../../common/api/utils'; -import useProjectData from '../../../common/hooks-query/useProjectData'; -import ModalLoader from '../modal-loader/ModalLoader'; -import { inputProps } from '../modalHelper'; -import ModalInput from '../ModalInput'; -import OntimeModalFooter from '../OntimeModalFooter'; - -import style from './SettingsModal.module.scss'; - -export default function ProjectDataForm() { - const { data, status, isFetching, refetch } = useProjectData(); - const { - handleSubmit, - register, - reset, - formState: { errors, isSubmitting, isDirty, isValid }, - } = useForm({ - defaultValues: data, - values: data, - resetOptions: { - keepDirtyValues: true, - }, - }); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); - - const onSubmit = async (formData: ProjectData) => { - try { - await postProjectData(formData); - } catch (error) { - logAxiosError('Error saving project data', error); - } finally { - await refetch(); - } - }; - - const onReset = () => { - reset(data); - }; - - const disableInputs = status === 'pending'; - - if (isFetching) { - return ; - } - - return ( -
    - - - - - - -
    - -