Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc3f5a56c | |||
| acf1afb5e9 | |||
| d8e2a8d092 | |||
| 8e587128a1 | |||
| 607bc40673 | |||
| e6aa7404f7 | |||
| d622a7738f | |||
| b9ba416366 | |||
| b3ce247f54 | |||
| 82a2660fbc | |||
| 1e8706aa73 | |||
| 7dec32aea7 | |||
| 7e4cf6ffce | |||
| e6dfe9c363 | |||
| 29e682ee06 | |||
| 55d349877b | |||
| 8f2a93d6a3 | |||
| 6529a05e2a | |||
| 0f5c47bc82 | |||
| f561e52bb5 | |||
| 35f4472daa | |||
| 272f788c15 | |||
| 85b71627a8 | |||
| 6e51e5083b | |||
| 1f998fda74 | |||
| e7b5baa49a | |||
| f3f4cdb19f | |||
| b489214004 | |||
| cc55414b57 | |||
| e694cb7556 | |||
| 988100c1d9 | |||
| b4eabfae72 | |||
| 22fe65c655 | |||
| 7bf90d669a | |||
| a518dc4882 | |||
| dd5ffb2f47 | |||
| c5a5eab225 | |||
| 5d6345717f | |||
| 4c88f90a15 | |||
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 |
@@ -39,6 +39,7 @@ dist/
|
|||||||
# docker utils
|
# docker utils
|
||||||
ontime-db
|
ontime-db
|
||||||
ontime-external/
|
ontime-external/
|
||||||
|
ontime-data/
|
||||||
|
|
||||||
# versioning file
|
# versioning file
|
||||||
**/ONTIME_VERSION.js
|
**/ONTIME_VERSION.js
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
|||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
- __Build docker image from__ by running `docker build -t getontime/ontime .`
|
||||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||||
|
|
||||||
Other useful commands
|
Other useful commands
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.7.1",
|
"version": "3.9.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,37 +1,27 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang='en'>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset='utf-8' />
|
<meta charset="utf-8" />
|
||||||
<link rel='icon' href='/favicon.ico' />
|
<base href="/" />
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name='theme-color' content='#121212' />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name='ontime' content='ontime - event timer manager' />
|
<meta name="theme-color" content="#101010" />
|
||||||
<link rel='apple-touch-icon' href='/logo192.png' />
|
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||||
<link
|
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||||
rel='icon'
|
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||||
type='image/png'
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
sizes='32x32'
|
<link rel="manifest" href="/manifest.json" />
|
||||||
href='/favicon-32x32.png'
|
<title>ontime</title>
|
||||||
/>
|
<style>
|
||||||
<link
|
body,
|
||||||
rel='icon'
|
html {
|
||||||
type='image/png'
|
background-color: rgba(0, 0, 0, 0) !important;
|
||||||
sizes='16x16'
|
}
|
||||||
href='/favicon-16x16.png'
|
</style>
|
||||||
/>
|
</head>
|
||||||
<link rel='manifest' href='/site.webmanifest' />
|
<body>
|
||||||
<link rel='manifest' href='/manifest.json' />
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<title>ontime</title>
|
<div id="root"></div>
|
||||||
<style>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
body,
|
</body>
|
||||||
html {
|
|
||||||
background-color: rgba(0, 0, 0, 0) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id='root'></div>
|
|
||||||
<script type='module' src='/src/index.tsx'></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.7.1",
|
"version": "3.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:local": "cross-env NODE_ENV=local vite build",
|
"build:local": "cross-env NODE_ENV=local vite build",
|
||||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||||
"build:docker": "vite build",
|
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
@@ -87,9 +87,9 @@
|
|||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"vite-plugin-compression2": "^0.12.0",
|
"vite-plugin-compression2": "^1.3.3",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "^4.3.1",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -4,18 +4,11 @@
|
|||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "ontime-logo.png",
|
||||||
"type": "image/png",
|
"type": "image/png"
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
|
|||||||
import theme from './theme/theme';
|
import theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
import { baseURI } from './externals';
|
||||||
|
|
||||||
connectSocket();
|
connectSocket();
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ function App() {
|
|||||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||||
<QueryClientProvider client={ontimeQueryClient}>
|
<QueryClientProvider client={ontimeQueryClient}>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={baseURI}>
|
||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
|||||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
|
|
||||||
const STimer = withPreset(withData(TimerView));
|
const STimer = withPreset(withData(TimerView));
|
||||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||||
const SClock = withPreset(withData(ClockView));
|
const SClock = withPreset(withData(ClockView));
|
||||||
const SCountdown = withPreset(withData(Countdown));
|
const SCountdown = withPreset(withData(Countdown));
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
const SBackstage = withPreset(withData(Backstage));
|
||||||
|
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
@@ -142,6 +144,14 @@ export default function AppRouter() {
|
|||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/info'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SProjectInfo />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { serverURL } from '../../externals';
|
||||||
|
|
||||||
// keys in tanstack store
|
// keys in tanstack store
|
||||||
export const APP_INFO = ['appinfo'];
|
export const APP_INFO = ['appinfo'];
|
||||||
export const APP_SETTINGS = ['appSettings'];
|
export const APP_SETTINGS = ['appSettings'];
|
||||||
@@ -14,19 +16,7 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// API URLs
|
||||||
const location = window.location;
|
|
||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
|
||||||
export const isDev = !isProduction;
|
|
||||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
// resolve port
|
|
||||||
const STATIC_PORT = 4001;
|
|
||||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
|
||||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
|
||||||
|
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
export const projectDataURL = `${serverURL}/project`;
|
export const projectDataURL = `${serverURL}/project`;
|
||||||
@@ -36,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
|
|||||||
export const userAssetsPath = 'user';
|
export const userAssetsPath = 'user';
|
||||||
export const cssOverridePath = 'styles/override.css';
|
export const cssOverridePath = 'styles/override.css';
|
||||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to load the demo project file
|
||||||
|
*/
|
||||||
|
export async function loadDemo(): Promise<MessageResponse> {
|
||||||
|
const res = await axios.post(`${dbPath}/demo`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to duplicate a project file
|
* HTTP request to duplicate a project file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { ProjectData } from 'ontime-types';
|
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
|||||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||||
return axios.post(projectPath, data);
|
return axios.post(projectPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to upload a project logo
|
||||||
|
*/
|
||||||
|
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
|||||||
import { useEmitLog } from '../../../stores/logger';
|
import { useEmitLog } from '../../../stores/logger';
|
||||||
|
|
||||||
interface TimeInputProps<T extends string> {
|
interface TimeInputProps<T extends string> {
|
||||||
|
id?: T;
|
||||||
name: T;
|
name: T;
|
||||||
submitHandler: (field: T, value: string) => void;
|
submitHandler: (field: T, value: string) => void;
|
||||||
time?: number;
|
time?: number;
|
||||||
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||||
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
@@ -117,6 +118,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
id={id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='sm'
|
size='sm'
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
|
|
||||||
import { debounce } from '../../utils/debounce';
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -13,32 +12,10 @@ interface FloatingNavigationProps {
|
|||||||
|
|
||||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||||
const { toggleMenu, toggleSettings } = props;
|
const { toggleMenu, toggleSettings } = props;
|
||||||
const [showButton, setShowButton] = useState(false);
|
const isButtonShown = useFadeOutOnInactivity();
|
||||||
|
|
||||||
// show on mouse move
|
|
||||||
useEffect(() => {
|
|
||||||
let fadeOut: NodeJS.Timeout | null = null;
|
|
||||||
const setShowMenuTrue = () => {
|
|
||||||
setShowButton(true);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', debouncedShowMenu);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
aria-label='toggle menu'
|
aria-label='toggle menu'
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ $button-size: 3rem;
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lockIcon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $active-indicator;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.navButton {
|
.navButton {
|
||||||
@extend .button;
|
@extend .button;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
|
import { isLocalhost } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import { isLocalhost, serverPort } from '../../api/constants';
|
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||||
import useInfo from '../../hooks-query/useInfo';
|
import useInfo from '../../hooks-query/useInfo';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import CopyTag from '../copy-tag/CopyTag';
|
import CopyTag from '../copy-tag/CopyTag';
|
||||||
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag
|
||||||
key={nif.name}
|
key={nif.name}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
|
|
||||||
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
|
||||||
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
|
export default function ViewLockedIcon() {
|
||||||
|
const isLockIconShown = useFadeOutOnInactivity();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
|
||||||
|
<IoLockClosedOutline className={style.lockIcon} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,18 @@ import { memo, useCallback } from 'react';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useDisclosure } from '@chakra-ui/react';
|
import { useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
import FloatingNavigation from './FloatingNavigation';
|
import FloatingNavigation from './FloatingNavigation';
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from './NavigationMenu';
|
||||||
|
import ViewLockedIcon from './ViewLockedIcon';
|
||||||
|
|
||||||
function ViewNavigationMenu() {
|
function ViewNavigationMenu() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||||
|
|
||||||
|
const isViewLocked = isStringBoolean(searchParams.get('locked'));
|
||||||
|
|
||||||
const showEditFormDrawer = useCallback(() => {
|
const showEditFormDrawer = useCallback(() => {
|
||||||
searchParams.set('edit', 'true');
|
searchParams.set('edit', 'true');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
@@ -16,6 +21,10 @@ function ViewNavigationMenu() {
|
|||||||
|
|
||||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||||
|
|
||||||
|
if (isViewLocked) {
|
||||||
|
return <ViewLockedIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../api/constants';
|
import { isLocalhost } from '../../../externals';
|
||||||
import { AppContext } from '../../context/AppContext';
|
import { AppContext } from '../../context/AppContext';
|
||||||
|
|
||||||
import PinPage from './PinPage';
|
import PinPage from './PinPage';
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
interface ViewLogoProps {
|
||||||
|
name: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
|
const { name, className } = props;
|
||||||
|
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
|||||||
// unfortunately this means we run all the strings through the sanitation
|
// unfortunately this means we run all the strings through the sanitation
|
||||||
const valueWithoutHash = sanitiseColour(value);
|
const valueWithoutHash = sanitiseColour(value);
|
||||||
if (defaultValues[id] !== valueWithoutHash) {
|
if (defaultValues[id] !== valueWithoutHash) {
|
||||||
handleValueString(id, value);
|
handleValueString(id, valueWithoutHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { version } from '../../../../../package.json';
|
import { version } from '../../../../../package.json';
|
||||||
import { APP_VERSION, isLocalhost } from '../api/constants';
|
import { isLocalhost } from '../../externals';
|
||||||
|
import { APP_VERSION } from '../api/constants';
|
||||||
import { getLatestVersion, HasUpdate } from '../api/external';
|
import { getLatestVersion, HasUpdate } from '../api/external';
|
||||||
|
|
||||||
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ProjectFileListResponse } from 'ontime-types';
|
import { ProjectFileListResponse } from 'ontime-types';
|
||||||
|
|
||||||
@@ -22,3 +23,23 @@ export function useProjectList() {
|
|||||||
});
|
});
|
||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOrderedProjectList() {
|
||||||
|
const response = useProjectList();
|
||||||
|
const { files, lastLoadedProject } = response.data;
|
||||||
|
|
||||||
|
const reorderedProjectFiles = useMemo(() => {
|
||||||
|
if (!files.length) return [];
|
||||||
|
|
||||||
|
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
||||||
|
|
||||||
|
if (currentlyLoadedIndex === -1) return files;
|
||||||
|
|
||||||
|
const projectFiles = [...files];
|
||||||
|
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
||||||
|
|
||||||
|
return [current, ...projectFiles];
|
||||||
|
}, [files, lastLoadedProject]);
|
||||||
|
|
||||||
|
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { addDialog } from '../stores/dialogStore';
|
||||||
|
|
||||||
const isElectron = window.process?.type === 'renderer';
|
const isElectron = window.process?.type === 'renderer';
|
||||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||||
|
|
||||||
@@ -24,6 +26,12 @@ export function useElectronListener() {
|
|||||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||||
navigate(location, { relative: 'route' });
|
navigate(location, { relative: 'route' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
|
||||||
|
if (dialog === 'welcome') {
|
||||||
|
addDialog('welcome');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the listener after the component is dismounted
|
// Clean the listener after the component is dismounted
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { debounce } from '../../common/utils/debounce';
|
||||||
|
|
||||||
|
export const useFadeOutOnInactivity = () => {
|
||||||
|
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
||||||
|
|
||||||
|
// show on mouse move
|
||||||
|
useEffect(() => {
|
||||||
|
let fadeOut: NodeJS.Timeout | null = null;
|
||||||
|
const setShowMenuTrue = () => {
|
||||||
|
setIsMouseMoved(true);
|
||||||
|
if (fadeOut) {
|
||||||
|
clearTimeout(fadeOut);
|
||||||
|
}
|
||||||
|
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', debouncedShowMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||||
|
if (fadeOut) {
|
||||||
|
clearTimeout(fadeOut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMouseMoved;
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { isDev } from '../api/constants';
|
import { isDev } from '../../externals';
|
||||||
|
|
||||||
type noop = (this: any, ...args: any[]) => any;
|
type noop = (this: any, ...args: any[]) => any;
|
||||||
|
|
||||||
|
|||||||
@@ -230,3 +230,11 @@ export const useTimelineStatus = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const usePing = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
ping: state.ping,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
|
|||||||
publicInfo: '',
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
|
projectLogo: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface DialogStore {
|
||||||
|
showDialog: MaybeString;
|
||||||
|
setDialog: (name: string) => void;
|
||||||
|
clearDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to allow the server to show a dialog in the client
|
||||||
|
*/
|
||||||
|
export const useDialogStore = create<DialogStore>((set) => ({
|
||||||
|
showDialog: null,
|
||||||
|
clearDialog: () => set({ showDialog: null }),
|
||||||
|
setDialog: (name) => set({ showDialog: name }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting a dialog to show (outside react)
|
||||||
|
*/
|
||||||
|
export function addDialog(name: string): void {
|
||||||
|
useDialogStore.getState().setDialog(name);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Returns hostname
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
export const host = window?.location?.host;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open an external URLs: specifically for a electron / browser case
|
|
||||||
* If electron: ask main process to call a new browser window
|
|
||||||
* If browser: open in new tab
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
export function openLink(url) {
|
|
||||||
if (window.process?.type === 'renderer') {
|
|
||||||
window.ipcRenderer.send('send-to-link', url);
|
|
||||||
} else {
|
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening external links
|
|
||||||
* @param event
|
|
||||||
* @param location
|
|
||||||
*/
|
|
||||||
export function handleLinks(event, location) {
|
|
||||||
// we handle the link manually
|
|
||||||
event.preventDefault();
|
|
||||||
openLink(`http://${host}/${location}`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
|
||||||
|
import { baseURI, serverURL } from '../../externals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an external URLs: specifically for a electron / browser case
|
||||||
|
* If electron: ask main process to call a new browser window
|
||||||
|
* If browser: open in new tab
|
||||||
|
* @param url
|
||||||
|
*/
|
||||||
|
export function openLink(url: string) {
|
||||||
|
if (window.process?.type === 'renderer') {
|
||||||
|
window.ipcRenderer.send('send-to-link', url);
|
||||||
|
} else {
|
||||||
|
window.open(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles opening external links
|
||||||
|
* @param event
|
||||||
|
* @param location
|
||||||
|
*/
|
||||||
|
export function handleLinks(event: MouseEvent, location: string) {
|
||||||
|
// we handle the link manually
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const destination = new URL(serverURL);
|
||||||
|
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
|
||||||
|
openLink(destination.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkToOtherHost(host: string, path?: string) {
|
||||||
|
const destination = new URL(serverURL);
|
||||||
|
destination.hostname = host;
|
||||||
|
if (path) {
|
||||||
|
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||||
|
}
|
||||||
|
return destination.toString();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
|
import { isProduction, websocketUrl } from '../../externals';
|
||||||
|
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
||||||
import { invalidateAllCaches } from '../api/utils';
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
setClientRedirect,
|
setClientRedirect,
|
||||||
setClients,
|
setClients,
|
||||||
} from '../stores/clientStore';
|
} from '../stores/clientStore';
|
||||||
|
import { addDialog } from '../stores/dialogStore';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||||
|
|
||||||
@@ -69,6 +71,12 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'pong': {
|
||||||
|
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||||
|
patchRuntime('ping', offset);
|
||||||
|
updateDevTools({ ping: offset }, ['PING']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'client-id': {
|
case 'client-id': {
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'string') {
|
||||||
setClientId(payload);
|
setClientId(payload);
|
||||||
@@ -111,6 +119,13 @@ export const connectSocket = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'dialog': {
|
||||||
|
if (payload.dialog === 'welcome') {
|
||||||
|
addDialog('welcome');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ontime-log': {
|
case 'ontime-log': {
|
||||||
addLog(payload as Log);
|
addLog(payload as Log);
|
||||||
break;
|
break;
|
||||||
@@ -217,9 +232,9 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateDevTools(newData: Partial<RuntimeStore>) {
|
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
||||||
...oldData,
|
...oldData,
|
||||||
...newData,
|
...newData,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
|
|||||||
|
|
||||||
// Limit file size of a project file to around 1MB
|
// Limit file size of a project file to around 1MB
|
||||||
if (file.size > 1_000_000) {
|
if (file.size > 1_000_000) {
|
||||||
throw new Error('File size limit (10MB) exceeded');
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
|
|||||||
export function isOntimeFile(file: File | null) {
|
export function isOntimeFile(file: File | null) {
|
||||||
return file?.name.endsWith('.json');
|
return file?.name.endsWith('.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of rules for pre-validating a project file
|
||||||
|
* @param file
|
||||||
|
*/
|
||||||
|
export function validateLogo(file: File) {
|
||||||
|
// Check if file is empty
|
||||||
|
if (file.size === 0) {
|
||||||
|
throw new Error('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit file size of a project file to around 1MB
|
||||||
|
if (file.size > 1_000_000) {
|
||||||
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* This file contains a list of constants that may need to be resolved at runtime
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { version } from '../../../package.json';
|
||||||
|
|
||||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
|
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|
||||||
|
// resolve environment
|
||||||
|
export const appVersion = version;
|
||||||
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
export const isDev = !isProduction;
|
||||||
|
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||||
|
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||||
|
|
||||||
|
// resolve entrypoint URLs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve base
|
||||||
|
* @example '' for electron
|
||||||
|
* @example '/client-hash' for cloud
|
||||||
|
*/
|
||||||
|
export const baseURI = resolveBaseURI();
|
||||||
|
export const serverURL = resolveUrl('http', '');
|
||||||
|
export const websocketUrl = resolveUrl('ws', 'ws');
|
||||||
|
|
||||||
|
function resolveUrl(protocol: 'http' | 'ws', path: string) {
|
||||||
|
const url = new URL(window.location.origin);
|
||||||
|
|
||||||
|
// check if protocol URL is secure
|
||||||
|
url.protocol = protocol === 'http' ? 'http' : 'ws';
|
||||||
|
url.protocol += window.location.protocol === 'https:' ? 's' : '';
|
||||||
|
|
||||||
|
// make path name relative to the base URI
|
||||||
|
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||||
|
|
||||||
|
// in development mode, we use the React port for UI, but need the requests to target the server
|
||||||
|
if (isDev) {
|
||||||
|
// this is used as a fallback port for development
|
||||||
|
url.port = '4001';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = url.toString();
|
||||||
|
|
||||||
|
// prevent trailing slash
|
||||||
|
return result.endsWith('/') ? result.slice(0, -1) : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a base URI for a client that is not at the root segment
|
||||||
|
* ie: https://cloud.getontime.com/client-hash/timer
|
||||||
|
* This is necessary for ontime cloud and should otherwise not affect the client
|
||||||
|
*/
|
||||||
|
function resolveBaseURI(): string {
|
||||||
|
// in ontime cloud, the base tag is set by the server
|
||||||
|
const baseHref = document.querySelector('base')?.getAttribute('href');
|
||||||
|
const base = baseHref ?? '';
|
||||||
|
|
||||||
|
// prevent a trailing slash from either an empty base or a base with a trailing slash
|
||||||
|
if (base.endsWith('/')) {
|
||||||
|
return base.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
|
|||||||
return <p className={style.paragraph}>{children}</p>;
|
return <p className={style.paragraph}>{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||||
return (
|
return (
|
||||||
<div className={style.card} {...props}>
|
<div className={cx([style.card, className])} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
@@ -19,6 +19,7 @@ export default function AboutPanel() {
|
|||||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { version } from '../../../../../package.json';
|
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
|
import { appVersion } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function AppVersion() {
|
export default function AppVersion() {
|
||||||
@@ -9,7 +9,7 @@ export default function AppVersion() {
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`You are currently using Ontime version ${version}`}
|
{`You are currently using Ontime version ${appVersion}`}
|
||||||
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
||||||
</Panel.Paragraph>
|
</Panel.Paragraph>
|
||||||
);
|
);
|
||||||
@@ -18,7 +18,7 @@ export default function AppVersion() {
|
|||||||
if (data.hasUpdates) {
|
if (data.hasUpdates) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Paragraph>{`You are currently using Ontime version ${version}.`}</Panel.Paragraph>
|
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`A new version ${data.version} is available.`}
|
{`A new version ${data.version} is available.`}
|
||||||
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
||||||
@@ -27,5 +27,5 @@ export default function AppVersion() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${version}`}</Panel.Paragraph>;
|
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export default function GeneralPanelForm() {
|
|||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
<option value='pl'>Polish</option>
|
<option value='pl'>Polish</option>
|
||||||
|
<option value='zh'>Chinese (Simplified)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
|
|
||||||
import { serverPort } from '../../../../common/api/constants';
|
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import { openLink } from '../../../../common/utils/linkUtils';
|
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||||
|
import { isLocalhost } from '../../../../externals';
|
||||||
|
|
||||||
import style from './NetworkInterfaces.module.scss';
|
import style from './NetworkInterfaces.module.scss';
|
||||||
|
|
||||||
@@ -14,8 +14,10 @@ export default function InfoNif() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data?.networkInterfaces?.map((nif) => {
|
{data.networkInterfaces.map((nif) => {
|
||||||
const address = `http://${nif.address}:${serverPort}`;
|
// interfaces outside localhost wont have access
|
||||||
|
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||||
|
const address = linkToOtherHost(nif.address);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
|
import { socketSendJson } from '../../../../common/utils/socket';
|
||||||
|
import { isDockerImage } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
@@ -14,6 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
{isDockerImage && <OntimeCloudStats />}
|
||||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<InfoNif />
|
<InfoNif />
|
||||||
@@ -26,3 +32,29 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OntimeCloudStats() {
|
||||||
|
const { ping } = usePing();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send immediate ping request, and keep sending on an interval
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
|
||||||
|
const doPing = setInterval(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(doPing);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Ontime cloud
|
||||||
|
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||||
import { createProject } from '../../../../common/api/db';
|
import { createProject } from '../../../../common/api/db';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='www.getontime.no'
|
placeholder={websiteUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('publicUrl')}
|
{...register('publicUrl')}
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='http://docs.getontime.no'
|
placeholder={documentationUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect } from 'react';
|
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
|
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||||
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { type ProjectData } from 'ontime-types';
|
import { type ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { postProjectData } from '../../../../common/api/project';
|
import { projectLogoPath } from '../../../../common/api/constants';
|
||||||
|
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||||
|
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||||
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
@@ -17,8 +22,10 @@ export default function ProjectData() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
reset,
|
reset,
|
||||||
formState: { isSubmitting, isValid, isDirty },
|
formState: { isSubmitting, isValid, isDirty, errors },
|
||||||
setError,
|
setError,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
@@ -34,6 +41,42 @@ export default function ProjectData() {
|
|||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateLogo(file);
|
||||||
|
const response = await uploadProjectLogo(file);
|
||||||
|
|
||||||
|
setValue('projectLogo', response.data.logoFilename, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('projectLogo', { message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { ref, ...projectLogoRest } = register('projectLogo');
|
||||||
|
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleClickUpload = () => {
|
||||||
|
uploadInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setValue('projectLogo', null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (formData: ProjectData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postProjectData(formData);
|
await postProjectData(formData);
|
||||||
@@ -86,6 +129,53 @@ export default function ProjectData() {
|
|||||||
{...register('title')}
|
{...register('title')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<Panel.Section style={{ marginTop: 0 }}>
|
||||||
|
<label>
|
||||||
|
Project logo
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
type='file'
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept='image/*'
|
||||||
|
{...projectLogoRest}
|
||||||
|
ref={(e) => {
|
||||||
|
ref(e);
|
||||||
|
uploadInputRef.current = e;
|
||||||
|
}}
|
||||||
|
onChange={handleUploadProjectLogo}
|
||||||
|
/>
|
||||||
|
<Panel.Card className={style.uploadLogoCard}>
|
||||||
|
{watch('projectLogo') ? (
|
||||||
|
<>
|
||||||
|
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
isDisabled={isSubmitting || !watch('projectLogo')}
|
||||||
|
leftIcon={<IoTrash />}
|
||||||
|
onClick={handleDeleteLogo}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
leftIcon={<IoDownloadOutline />}
|
||||||
|
onClick={handleClickUpload}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Upload logo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
|
||||||
|
</Panel.Card>
|
||||||
|
</label>
|
||||||
|
</Panel.Section>
|
||||||
<label>
|
<label>
|
||||||
Project description
|
Project description
|
||||||
<Input
|
<Input
|
||||||
@@ -114,7 +204,7 @@ export default function ProjectData() {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='www.getontime.no'
|
placeholder={websiteUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('publicUrl')}
|
{...register('publicUrl')}
|
||||||
/>
|
/>
|
||||||
@@ -136,7 +226,7 @@ export default function ProjectData() {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='http://docs.getontime.no'
|
placeholder={documentationUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||||
@@ -8,8 +8,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
|
|||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const { data, refetch } = useProjectList();
|
const { data, refetch } = useOrderedProjectList();
|
||||||
const { files, lastLoadedProject } = data;
|
|
||||||
|
|
||||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
@@ -28,16 +27,6 @@ export default function ProjectList() {
|
|||||||
await refetch();
|
await refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderedProjectFiles = useMemo(() => {
|
|
||||||
if (!data.files?.length) return [];
|
|
||||||
|
|
||||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
|
||||||
const projectFiles = [...files];
|
|
||||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
|
|
||||||
|
|
||||||
return [current, ...projectFiles];
|
|
||||||
}, [data.files?.length, files, lastLoadedProject]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -48,7 +37,7 @@ export default function ProjectList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reorderedProjectFiles.map((project) => (
|
{data.reorderedProjectFiles.map((project) => (
|
||||||
<ProjectListItem
|
<ProjectListItem
|
||||||
key={project.filename}
|
key={project.filename}
|
||||||
filename={project.filename}
|
filename={project.filename}
|
||||||
@@ -58,7 +47,7 @@ export default function ProjectList() {
|
|||||||
onRefetch={handleRefetch}
|
onRefetch={handleRefetch}
|
||||||
editingFilename={editingFilename}
|
editingFilename={editingFilename}
|
||||||
editingMode={editingMode}
|
editingMode={editingMode}
|
||||||
current={project.filename === lastLoadedProject}
|
current={project.filename === data.lastLoadedProject}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -61,3 +61,26 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadLogoCard {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: $gray-1350;
|
||||||
|
border: 1px solid $white-10;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 250px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../../common/api/constants';
|
|
||||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||||
|
import { isLocalhost } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function ShutdownPanel() {
|
export default function ShutdownPanel() {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function QuickStart(props: QuickStartProps) {
|
|||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
<option value='pl'>Polish</option>
|
<option value='pl'>Polish</option>
|
||||||
|
<option value='zh'>Chinese (Simplified)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
import { dayInMs, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
import style from './PlaybackTimer.module.scss';
|
import style from './PlaybackTimer.module.scss';
|
||||||
@@ -13,22 +14,12 @@ interface PlaybackTimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAddedTimeLabel(addedTime: number) {
|
function resolveAddedTimeLabel(addedTime: number) {
|
||||||
function resolveClosestUnit(ms: number) {
|
|
||||||
if (ms < 6000) {
|
|
||||||
return `${millisToSeconds(ms)} seconds`;
|
|
||||||
} else if (ms < 12000) {
|
|
||||||
return '1 minute';
|
|
||||||
} else {
|
|
||||||
return `${millisToMinutes(ms)} minutes`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedTime > 0) {
|
if (addedTime > 0) {
|
||||||
return `Added ${resolveClosestUnit(addedTime)}`;
|
return `Added ${formatDuration(addedTime, false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addedTime < 0) {
|
if (addedTime < 0) {
|
||||||
return `Removed ${resolveClosestUnit(addedTime)}`;
|
return `Removed ${formatDuration(Math.abs(addedTime), false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: -1px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
@@ -54,6 +54,7 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.resizer {
|
.resizer {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
|||||||
import { EditorOverview } from '../overview/Overview';
|
import { EditorOverview } from '../overview/Overview';
|
||||||
|
|
||||||
import Finder from './finder/Finder';
|
import Finder from './finder/Finder';
|
||||||
|
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export default function Editor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
|
<WelcomePlacement />
|
||||||
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||||
<EditorOverview>
|
<EditorOverview>
|
||||||
|
|||||||
@@ -20,3 +20,20 @@
|
|||||||
outline: 2px solid $gray-1000;
|
outline: 2px solid $gray-1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
color: $label-gray;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
max-width: max-content; // prevent label from taking entire row
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { LabelHTMLAttributes, ReactNode } from 'react';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
import { type IconBaseProps } from '@react-icons/all-files/lib';
|
import { type IconBaseProps } from '@react-icons/all-files/lib';
|
||||||
|
|
||||||
@@ -8,3 +9,16 @@ import style from './EditorUtils.module.scss';
|
|||||||
export function Corner({ className, ...elementProps }: IconBaseProps) {
|
export function Corner({ className, ...elementProps }: IconBaseProps) {
|
||||||
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Title({ children }: { children: ReactNode }) {
|
||||||
|
return <h3 className={style.title}>{children}</h3>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
const classes = cx([style.label, className]);
|
||||||
|
return (
|
||||||
|
<label className={classes} {...elementProps}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KeyboardEvent, useState } from 'react';
|
import { KeyboardEvent, useState } from 'react';
|
||||||
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
|
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
|
||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
|
import { SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useEventSelection } from '../../rundown/useEventSelection';
|
import { useEventSelection } from '../../rundown/useEventSelection';
|
||||||
|
|
||||||
@@ -65,14 +65,15 @@ export default function Finder(props: FinderProps) {
|
|||||||
{error && <li className={style.error}>{error}</li>}
|
{error && <li className={style.error}>{error}</li>}
|
||||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
{results.length === 0 && <li className={style.empty}>No results</li>}
|
||||||
{results.length > 0 &&
|
{results.length > 0 &&
|
||||||
results.map((event, index) => {
|
results.map((entry, index) => {
|
||||||
const isSelected = selected === index;
|
const isSelected = selected === index;
|
||||||
const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index;
|
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-';
|
||||||
const colour = event.type === SupportedEvent.Event ? event.colour : '';
|
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : '';
|
||||||
|
const colour = entry.type === SupportedEvent.Event ? entry.colour : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={event.id}
|
key={entry.id}
|
||||||
className={style.entry}
|
className={style.entry}
|
||||||
data-selected={isSelected}
|
data-selected={isSelected}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
@@ -82,8 +83,8 @@ export default function Finder(props: FinderProps) {
|
|||||||
<div className={style.index} style={{ '--color': colour }}>
|
<div className={style.index} style={{ '--color': colour }}>
|
||||||
{displayIndex}
|
{displayIndex}
|
||||||
</div>
|
</div>
|
||||||
{isOntimeEvent(event) && <div className={style.cue}>{event.cue}</div>}
|
<div className={style.cue}>{displayCue}</div>
|
||||||
<div className={style.title}>{event.title}</div>
|
<div className={style.title}>{entry.title}</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && <span>Go ⏎</span>}
|
{isSelected && <span>Go ⏎</span>}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeEvent, useState } from 'react';
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
|
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||||
@@ -28,6 +28,17 @@ export default function useFinder() {
|
|||||||
const { data } = useFlatRundown();
|
const { data } = useFlatRundown();
|
||||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||||
const [error, setError] = useState<MaybeString>(null);
|
const [error, setError] = useState<MaybeString>(null);
|
||||||
|
const lastSearchString = useRef('');
|
||||||
|
|
||||||
|
/** clear results when source data changes */
|
||||||
|
useEffect(() => {
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
// fake a submit event to re-run the search
|
||||||
|
if (lastSearchString.current) {
|
||||||
|
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
/** Returns a single item with a matching index */
|
/** Returns a single item with a matching index */
|
||||||
const searchByIndex = (searchString: string) => {
|
const searchByIndex = (searchString: string) => {
|
||||||
@@ -155,6 +166,7 @@ export default function useFinder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchValue = event.target.value.toLowerCase();
|
const searchValue = event.target.value.toLowerCase();
|
||||||
|
lastSearchString.current = searchValue;
|
||||||
|
|
||||||
if (searchValue.startsWith('index ')) {
|
if (searchValue.startsWith('index ')) {
|
||||||
const searchString = searchValue.replace('index ', '').trim();
|
const searchString = searchValue.replace('index ', '').trim();
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.sections {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 5fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonRow {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableContainer {
|
||||||
|
height: 350px;
|
||||||
|
max-height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
background-color: $gray-1300;
|
||||||
|
tr {
|
||||||
|
&:has(:hover) {
|
||||||
|
background-color: $gray-1350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
background-color: $blue-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||||
|
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||||
|
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||||
|
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||||
|
|
||||||
|
import ImportProjectButton from './composite/ImportProjectButton';
|
||||||
|
import WelcomeProjectList from './composite/WelcomeProjectList';
|
||||||
|
|
||||||
|
import style from './Welcome.module.scss';
|
||||||
|
|
||||||
|
interface WelcomeProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Welcome(props: WelcomeProps) {
|
||||||
|
const { onClose } = props;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
/** handle cleanup actions before request closing the modal */
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** handle loading a selected project */
|
||||||
|
const handleLoadProject = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
await loadProject(filename);
|
||||||
|
await invalidateAllCaches();
|
||||||
|
handleClose();
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** handle loading the demo project */
|
||||||
|
const handleLoadDemo = async () => {
|
||||||
|
try {
|
||||||
|
await loadDemo();
|
||||||
|
await invalidateAllCaches();
|
||||||
|
handleClose();
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** handle redirect to create modal */
|
||||||
|
const handleCallCreate = () => {
|
||||||
|
navigate('/editor?settings=project__create');
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen onClose={handleClose} variant='ontime'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxWidth='max(640px, 40vw)'>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<div className={style.sections}>
|
||||||
|
<div className={style.column}>
|
||||||
|
<img src='ontime-logo.png' alt='ontime' className={style.logo} />
|
||||||
|
<div>Ontime v{appVersion}</div>
|
||||||
|
<ExternalLink href={websiteUrl}>Website</ExternalLink>
|
||||||
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<div className={style.column}>
|
||||||
|
<div className={style.header}>Welcome to Ontime</div>
|
||||||
|
<div className={style.tableContainer}>
|
||||||
|
<table className={style.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project Name</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.buttonRow}>
|
||||||
|
<ImportProjectButton onFinish={handleClose} />
|
||||||
|
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
|
||||||
|
Load demo project
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
|
||||||
|
Create new...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useDialogStore } from '../../../common/stores/dialogStore';
|
||||||
|
|
||||||
|
import Welcome from './Welcome';
|
||||||
|
|
||||||
|
export default function WelcomePlacement() {
|
||||||
|
const showDialog = useDialogStore((state) => state.showDialog);
|
||||||
|
const clearDialog = useDialogStore((state) => state.clearDialog);
|
||||||
|
|
||||||
|
if (!showDialog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Welcome onClose={clearDialog} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Handles importing of a project in the welcome modal
|
||||||
|
* the logic is mostly duplicated from ManageProjects.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChangeEvent, useRef } from 'react';
|
||||||
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { uploadProjectFile } from '../../../../common/api/db';
|
||||||
|
import { invalidateAllCaches } from '../../../../common/api/utils';
|
||||||
|
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||||
|
|
||||||
|
interface ImportProjectButtonProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportProjectButton(props: ImportProjectButtonProps) {
|
||||||
|
const { onFinish } = props;
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleSelectFile = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = event.target?.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateProjectFile(selectedFile);
|
||||||
|
await uploadProjectFile(selectedFile);
|
||||||
|
} catch (error) {
|
||||||
|
/** we do not handle errors here */
|
||||||
|
} finally {
|
||||||
|
await invalidateAllCaches();
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleImport}
|
||||||
|
accept='.json'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button size='sm' variant='ontime-subtle' onClick={handleSelectFile}>
|
||||||
|
Import project
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
|
|
||||||
|
import style from '../Welcome.module.scss';
|
||||||
|
|
||||||
|
interface WelcomeProjectListProps {
|
||||||
|
loadProject: (filename: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WelcomeProjectList(props: WelcomeProjectListProps) {
|
||||||
|
const { loadProject, onClose } = props;
|
||||||
|
const { data } = useOrderedProjectList();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{data.reorderedProjectFiles.map((project) => {
|
||||||
|
if (project.filename === data.lastLoadedProject) {
|
||||||
|
return (
|
||||||
|
<tr className={style.current} key={project.filename}>
|
||||||
|
<td>{project.filename}</td>
|
||||||
|
<td>Loaded from last session</td>
|
||||||
|
<td>
|
||||||
|
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={project.filename}>
|
||||||
|
<td>{project.filename}</td>
|
||||||
|
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -214,8 +214,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||||
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||||
|
|
||||||
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
|
['mod + C', () => setEntryCopyId(cursor)],
|
||||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
|
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||||
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
|
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
|
||||||
|
|
||||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||||
@@ -256,10 +256,12 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastEntry: PlayableEvent | undefined; // used by indicators
|
// last event is used to calculate relative timings
|
||||||
let thisEntry: PlayableEvent | undefined;
|
let lastEvent: PlayableEvent | undefined; // used by indicators
|
||||||
let previousEventId: string | undefined;
|
let thisEvent: PlayableEvent | undefined;
|
||||||
let thisId = previousEventId;
|
// previous entry is used to infer position in the rundown for new events
|
||||||
|
let previousEntryId: string | undefined;
|
||||||
|
let thisId = previousEntryId;
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// all events before the current selected are in the past
|
// all events before the current selected are in the past
|
||||||
@@ -272,63 +274,64 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||||
<div className={style.list}>
|
<div className={style.list}>
|
||||||
{statefulEntries.map((eventId, index) => {
|
{statefulEntries.map((entryId, index) => {
|
||||||
// we iterate through a stateful copy of order to make the operations smoother
|
// we iterate through a stateful copy of order to make the operations smoother
|
||||||
// this means that this can be out of sync with order until the useEffect runs
|
// this means that this can be out of sync with order until the useEffect runs
|
||||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||||
const event = rundown[eventId];
|
const entry = rundown[entryId];
|
||||||
if (!event) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = 0;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
if (isOntimeEvent(event)) {
|
previousEntryId = thisId;
|
||||||
|
thisId = entryId;
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
// event indexes are 1 based in frontend
|
// event indexes are 1 based in frontend
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
previousEventId = thisId;
|
lastEvent = thisEvent;
|
||||||
lastEntry = thisEntry;
|
|
||||||
|
|
||||||
if (isPlayableEvent(event)) {
|
if (isPlayableEvent(entry)) {
|
||||||
// populate previous entry
|
// populate previous entry
|
||||||
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
|
||||||
thisEntry = event;
|
thisEvent = entry;
|
||||||
}
|
}
|
||||||
thisId = eventId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === order.length - 1;
|
const isLast = index === order.length - 1;
|
||||||
const isLoaded = featureData?.selectedEventId === event.id;
|
const isLoaded = featureData?.selectedEventId === entry.id;
|
||||||
const isNext = featureData?.nextEventId === event.id;
|
const isNext = featureData?.nextEventId === entry.id;
|
||||||
const hasCursor = event.id === cursor;
|
const hasCursor = entry.id === cursor;
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
isPast = false;
|
isPast = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={event.id}>
|
<Fragment key={entry.id}>
|
||||||
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEventId} />}
|
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
|
||||||
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||||
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
|
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||||
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
<RundownEntry
|
<RundownEntry
|
||||||
type={event.type}
|
type={entry.type}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
data={event}
|
data={entry}
|
||||||
loaded={isLoaded}
|
loaded={isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousStart={lastEntry?.timeStart}
|
previousStart={lastEvent?.timeStart}
|
||||||
previousEnd={lastEntry?.timeEnd}
|
previousEnd={lastEvent?.timeEnd}
|
||||||
previousEventId={previousEventId}
|
previousEntryId={previousEntryId}
|
||||||
|
previousEventId={lastEvent?.id}
|
||||||
playback={isLoaded ? featureData.playback : undefined}
|
playback={isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={event.id} />}
|
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface RundownEntryProps {
|
|||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
previousStart?: number;
|
previousStart?: number;
|
||||||
previousEnd?: number;
|
previousEnd?: number;
|
||||||
|
previousEntryId?: string;
|
||||||
previousEventId?: string;
|
previousEventId?: string;
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
isRolling: boolean; // we need to know even if not related to this event
|
isRolling: boolean; // we need to know even if not related to this event
|
||||||
@@ -48,6 +49,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isNext,
|
isNext,
|
||||||
previousStart,
|
previousStart,
|
||||||
previousEnd,
|
previousEnd,
|
||||||
|
previousEntryId,
|
||||||
previousEventId,
|
previousEventId,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
@@ -84,7 +86,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
case 'event-before': {
|
case 'event-before': {
|
||||||
const newEvent = { type: SupportedEvent.Event };
|
const newEvent = { type: SupportedEvent.Event };
|
||||||
const options = {
|
const options = {
|
||||||
after: previousEventId,
|
after: previousEntryId,
|
||||||
};
|
};
|
||||||
return addEvent(newEvent, options);
|
return addEvent(newEvent, options);
|
||||||
}
|
}
|
||||||
@@ -92,13 +94,13 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||||
}
|
}
|
||||||
case 'delay-before': {
|
case 'delay-before': {
|
||||||
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
|
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId });
|
||||||
}
|
}
|
||||||
case 'block': {
|
case 'block': {
|
||||||
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||||
}
|
}
|
||||||
case 'block-before': {
|
case 'block-before': {
|
||||||
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
|
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId });
|
||||||
}
|
}
|
||||||
case 'swap': {
|
case 'swap': {
|
||||||
const { value } = payload as FieldValue;
|
const { value } = payload as FieldValue;
|
||||||
|
|||||||
@@ -40,13 +40,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputLabel {
|
|
||||||
display: block;
|
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
color: $label-gray;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decorated {
|
.decorated {
|
||||||
color: var(--decorator-color, $ui-white);
|
color: var(--decorator-color, $ui-white);
|
||||||
background-color: var(--decorator-bg, $gray-1100);
|
background-color: var(--decorator-bg, $gray-1100);
|
||||||
@@ -58,7 +51,6 @@
|
|||||||
.delayLabel {
|
.delayLabel {
|
||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
margin-top: 0.25rem;
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '\200b';
|
content: '\200b';
|
||||||
@@ -66,8 +58,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.switchLabel {
|
.switchLabel {
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
color: $label-gray;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useEventAction } from '../../../common/hooks/useEventAction';
|
|||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
@@ -100,7 +101,7 @@ export default function EventEditor() {
|
|||||||
/>
|
/>
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span>Custom Fields</span>
|
<Editor.Title>Custom Fields</Editor.Title>
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
|||||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
|
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
@@ -72,7 +73,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div>
|
<div>
|
||||||
<div className={style.inputLabel}>Event schedule</div>
|
<Editor.Label>Event schedule</Editor.Label>
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<TimeInputFlow
|
<TimeInputFlow
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
@@ -90,15 +91,20 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
|
|
||||||
<div className={style.splitTwo}>
|
<div className={style.splitTwo}>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel} htmlFor='timeWarning'>
|
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
|
||||||
Warning Time
|
<TimeInput
|
||||||
</label>
|
id='timeWarning'
|
||||||
<TimeInput name='timeWarning' submitHandler={handleSubmit} time={timeWarning} placeholder='Duration' />
|
name='timeWarning'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
time={timeWarning}
|
||||||
|
placeholder='Duration'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel}>Timer Type</label>
|
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
||||||
<Select
|
<Select
|
||||||
size='sm'
|
size='sm'
|
||||||
|
id='timerType'
|
||||||
name='timerType'
|
name='timerType'
|
||||||
value={timerType}
|
value={timerType}
|
||||||
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
||||||
@@ -112,14 +118,19 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel} htmlFor='timeDanger'>
|
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
|
||||||
Danger Time
|
<TimeInput
|
||||||
</label>
|
id='timeDanger'
|
||||||
<TimeInput name='timeDanger' submitHandler={handleSubmit} time={timeDanger} placeholder='Duration' />
|
name='timeDanger'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
time={timeDanger}
|
||||||
|
placeholder='Duration'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel}>End Action</label>
|
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
|
||||||
<Select
|
<Select
|
||||||
|
id='endAction'
|
||||||
size='sm'
|
size='sm'
|
||||||
name='endAction'
|
name='endAction'
|
||||||
value={endAction}
|
value={endAction}
|
||||||
@@ -135,11 +146,17 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className={style.inputLabel}>Event Visibility</span>
|
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
|
||||||
<label className={style.switchLabel}>
|
<Editor.Label className={style.switchLabel}>
|
||||||
<Switch size='md' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
|
<Switch
|
||||||
|
id='isPublic'
|
||||||
|
size='md'
|
||||||
|
isChecked={isPublic}
|
||||||
|
onChange={() => handleSubmit('isPublic', isPublic)}
|
||||||
|
variant='ontime'
|
||||||
|
/>
|
||||||
{isPublic ? 'Public' : 'Private'}
|
{isPublic ? 'Public' : 'Private'}
|
||||||
</label>
|
</Editor.Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Input } from '@chakra-ui/react';
|
|||||||
import { sanitiseCue } from 'ontime-utils';
|
import { sanitiseCue } from 'ontime-utils';
|
||||||
|
|
||||||
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
|
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||||
import { type EditorUpdateFields } from '../EventEditor';
|
import { type EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
import EventTextArea from './EventTextArea';
|
import EventTextArea from './EventTextArea';
|
||||||
@@ -30,9 +31,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
|||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div className={style.splitTwo}>
|
<div className={style.splitTwo}>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel} htmlFor='eventId'>
|
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
|
||||||
Event ID (read only)
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
id='eventId'
|
id='eventId'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -45,7 +44,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
|||||||
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
|
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel}>Colour</label>
|
<Editor.Label>Colour</Editor.Label>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { CSSProperties, useCallback, useRef } from 'react';
|
import { type CSSProperties, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
|
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
|
||||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||||
import { EditorUpdateFields } from '../EventEditor';
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
|
||||||
|
|
||||||
interface CountedTextAreaProps {
|
interface CountedTextAreaProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
field: EditorUpdateFields;
|
field: EditorUpdateFields;
|
||||||
@@ -24,13 +22,12 @@ export default function EventTextArea(props: CountedTextAreaProps) {
|
|||||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
submitOnCtrlEnter: true,
|
submitOnCtrlEnter: true,
|
||||||
});
|
});
|
||||||
const classes = cx([style.inputLabel, className]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className={classes} htmlFor={field} style={givenStyles}>
|
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</Editor.Label>
|
||||||
<AutoTextArea
|
<AutoTextArea
|
||||||
id={field}
|
id={field}
|
||||||
inputref={ref}
|
inputref={ref}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useCallback, useRef } from 'react';
|
|||||||
import { Input, InputProps } from '@chakra-ui/react';
|
import { Input, InputProps } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||||
import { EditorUpdateFields } from '../EventEditor';
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
|
||||||
|
|
||||||
interface EventTextInputProps extends InputProps {
|
interface EventTextInputProps extends InputProps {
|
||||||
field: EditorUpdateFields;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,9 +23,7 @@ export default function EventTextInput(props: EventTextInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className={style.inputLabel} htmlFor={field}>
|
<Editor.Label htmlFor={field}>{label}</Editor.Label>
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
id={field}
|
id={field}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -27,19 +28,19 @@ import './Backstage.scss';
|
|||||||
export const MotionTitleCard = motion(TitleCard);
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
|
||||||
isMirrored: boolean;
|
|
||||||
eventNow: OntimeEvent | null;
|
|
||||||
eventNext: OntimeEvent | null;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
selectedId: string | null;
|
customFields: CustomFields;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
|
eventNow: OntimeEvent | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Backstage(props: BackstageProps) {
|
export default function Backstage(props: BackstageProps) {
|
||||||
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
@@ -81,6 +82,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -13,10 +13,18 @@
|
|||||||
place-content: center;
|
place-content: center;
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
font-family: var(--font-family-bold-override, $timer-bold-font-family);
|
||||||
font-size: 20vw;
|
font-size: 20vw;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--timer-color-override, $timer-color);
|
color: var(--timer-color-override, $timer-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Settings } from 'ontime-types';
|
import { ProjectData, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -13,13 +14,14 @@ import { getClockOptions } from './clock.options';
|
|||||||
import './Clock.scss';
|
import './Clock.scss';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Clock(props: ClockProps) {
|
export default function Clock(props: ClockProps) {
|
||||||
const { isMirrored, time, settings } = props;
|
const { general, isMirrored, time, settings } = props;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Clock');
|
useWindowTitle('Clock');
|
||||||
@@ -122,6 +124,7 @@ export default function Clock(props: ClockProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='clock-view'
|
data-testid='clock-view'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={clockOptions} />
|
<ViewParamsEditor viewOptions={clockOptions} />
|
||||||
<SuperscriptTime
|
<SuperscriptTime
|
||||||
time={clock}
|
time={clock}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
|
|
||||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||||
|
|
||||||
.countdown-container {
|
.countdown-container {
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
import {
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
Playback,
|
||||||
|
ProjectData,
|
||||||
|
Runtime,
|
||||||
|
Settings,
|
||||||
|
SupportedEvent,
|
||||||
|
TimerPhase,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -17,8 +27,9 @@ import CountdownSelect from './CountdownSelect';
|
|||||||
import './Countdown.scss';
|
import './Countdown.scss';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
isMirrored: boolean;
|
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
runtime: Runtime;
|
runtime: Runtime;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
@@ -26,7 +37,7 @@ interface CountdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
export default function Countdown(props: CountdownProps) {
|
||||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
const { backstageEvents, general, isMirrored, runtime, selectedId, settings, time } = props;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -105,6 +116,7 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={viewOptions} />
|
<ViewParamsEditor viewOptions={viewOptions} />
|
||||||
{follow === null ? (
|
{follow === null ? (
|
||||||
<CountdownSelect events={backstageEvents} />
|
<CountdownSelect events={backstageEvents} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
font-family: var(--font-family-bold-override, $timer-bold-font-family);
|
||||||
font-size: 20vw;
|
font-size: 20vw;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--timer-color-override, var(--phase-color));
|
color: var(--timer-color-override, var(--phase-color));
|
||||||
@@ -43,11 +43,19 @@
|
|||||||
/* =================== OVERLAY ===================*/
|
/* =================== OVERLAY ===================*/
|
||||||
|
|
||||||
.end-message {
|
.end-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12vw;
|
font-size: 12vw;
|
||||||
line-height: 0.9em;
|
line-height: 0.9em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $timer-finished-color;
|
color: $timer-finished-color;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
import { Playback, ProjectData, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -13,13 +14,14 @@ import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
|
|||||||
import './MinimalTimer.scss';
|
import './MinimalTimer.scss';
|
||||||
|
|
||||||
interface MinimalTimerProps {
|
interface MinimalTimerProps {
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||||
const { isMirrored, time, viewSettings } = props;
|
const { general, isMirrored, time, viewSettings } = props;
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -158,6 +160,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='minimal-timer'
|
data-testid='minimal-timer'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: clamp(16px, 5vw, 64px);;
|
margin-left: clamp(16px, 5vw, 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-nav-container {
|
.schedule-nav-container {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -24,26 +25,26 @@ export const MotionTitleCard = motion(TitleCard);
|
|||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
publicEventNext: OntimeEvent | null;
|
publicEventNext: OntimeEvent | null;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
general: ProjectData;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Public(props: BackstageProps) {
|
export default function Public(props: BackstageProps) {
|
||||||
const {
|
const {
|
||||||
customFields,
|
customFields,
|
||||||
|
general,
|
||||||
isMirrored,
|
isMirrored,
|
||||||
publicEventNow,
|
publicEventNow,
|
||||||
publicEventNext,
|
publicEventNext,
|
||||||
time,
|
time,
|
||||||
events,
|
events,
|
||||||
publicSelectedId,
|
publicSelectedId,
|
||||||
general,
|
|
||||||
settings,
|
settings,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export default function Public(props: BackstageProps) {
|
|||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<ViewParamsEditor viewOptions={publicOptions} />
|
<ViewParamsEditor viewOptions={publicOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ $orange-active: #f60;
|
|||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
margin-right: 2vw;
|
margin-right: 2vw;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onAir {
|
.onAir {
|
||||||
@@ -204,6 +205,13 @@ $orange-active: #f60;
|
|||||||
background-color: var(--studio-idle, $red-idle);
|
background-color: var(--studio-idle, $red-idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
right: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 1200px) {
|
@media only screen and (max-width: 1200px) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } from 'ontime-types';
|
import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types';
|
||||||
import { Playback } from 'ontime-types';
|
import { Playback } from 'ontime-types';
|
||||||
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
||||||
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -16,10 +17,11 @@ import StudioClockSchedule from './StudioClockSchedule';
|
|||||||
import './StudioClock.scss';
|
import './StudioClock.scss';
|
||||||
|
|
||||||
interface StudioClockProps {
|
interface StudioClockProps {
|
||||||
isMirrored: boolean;
|
|
||||||
eventNext: OntimeEvent | null;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
backstageEvents: OntimeRundown;
|
backstageEvents: OntimeRundown;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
selectedId: MaybeString;
|
selectedId: MaybeString;
|
||||||
nextId: MaybeString;
|
nextId: MaybeString;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
@@ -27,7 +29,7 @@ interface StudioClockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock(props: StudioClockProps) {
|
export default function StudioClock(props: StudioClockProps) {
|
||||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props;
|
const { backstageEvents, eventNext, general, isMirrored, time, selectedId, nextId, onAir, settings } = props;
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
|
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
|
||||||
data-testid='studio-view'
|
data-testid='studio-view'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={studioClockOptions} />
|
<ViewParamsEditor viewOptions={studioClockOptions} />
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
||||||
|
|||||||
@@ -194,4 +194,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MessageState,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
|
ProjectData,
|
||||||
Settings,
|
Settings,
|
||||||
SimpleTimerState,
|
SimpleTimerState,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -50,6 +52,7 @@ interface TimerProps {
|
|||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
message: MessageState;
|
message: MessageState;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
@@ -58,7 +61,8 @@ interface TimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
|
||||||
|
props;
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -168,6 +172,8 @@ export default function Timer(props: TimerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
|
|
||||||
<ViewParamsEditor viewOptions={timerOptions} />
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { langNo } from './languages/no';
|
|||||||
import { langPl } from './languages/pl';
|
import { langPl } from './languages/pl';
|
||||||
import { langPt } from './languages/pt';
|
import { langPt } from './languages/pt';
|
||||||
import { langSv } from './languages/sv';
|
import { langSv } from './languages/sv';
|
||||||
|
import { langZhCn } from './languages/zh';
|
||||||
|
|
||||||
const translationsList = {
|
const translationsList = {
|
||||||
en: langEn,
|
en: langEn,
|
||||||
@@ -24,6 +25,7 @@ const translationsList = {
|
|||||||
pt: langPt,
|
pt: langPt,
|
||||||
sv: langSv,
|
sv: langSv,
|
||||||
pl: langPl,
|
pl: langPl,
|
||||||
|
zh: langZhCn,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
|
export const langZhCn: TranslationObject = {
|
||||||
|
'common.expected_finish': '预计完成',
|
||||||
|
'common.minutes': '分钟',
|
||||||
|
'common.now': '现在',
|
||||||
|
'common.next': '接下来',
|
||||||
|
'common.public_message': '公众消息',
|
||||||
|
'common.scheduled_start': '计划开始',
|
||||||
|
'common.scheduled_end': '计划结束',
|
||||||
|
'common.projected_start': '预计开始',
|
||||||
|
'common.projected_end': '预计结束',
|
||||||
|
'common.stage_timer': '舞台计时器',
|
||||||
|
'common.started_at': '开始于',
|
||||||
|
'common.time_now': '当前时间',
|
||||||
|
'countdown.ended': '事件结束于',
|
||||||
|
'countdown.running': '事件进行中',
|
||||||
|
'countdown.select_event': '选择要关注的事件',
|
||||||
|
'countdown.to_start': '到开始时间',
|
||||||
|
'countdown.waiting': '等待事件开始',
|
||||||
|
'countdown.overtime': '超时了',
|
||||||
|
'timeline.live': '当前进行',
|
||||||
|
'timeline.done': '已结束',
|
||||||
|
'timeline.due': '即将开始',
|
||||||
|
'timeline.followedby': '随后是',
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ export const navigatorConstants = [
|
|||||||
{ url: 'lower', label: 'Lower Thirds' },
|
{ url: 'lower', label: 'Lower Thirds' },
|
||||||
{ url: 'studio', label: 'Studio Clock' },
|
{ url: 'studio', label: 'Studio Clock' },
|
||||||
{ url: 'countdown', label: 'Countdown' },
|
{ url: 'countdown', label: 'Countdown' },
|
||||||
|
{ url: 'info', label: 'Project Info' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// default time format to use for users in 12 hour clocks
|
// default time format to use for users in 12 hour clocks
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
.project {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box; /* reset */
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%; /* restrict the page width to viewport */
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
|
color: var(--color-override, $viewer-color);
|
||||||
|
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 30vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: min(calc(100vw - 4rem), 800px);
|
||||||
|
font-size: clamp(16px, 1.5vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.info__label {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a.info__value {
|
||||||
|
color: $action-text-color;
|
||||||
|
&:hover {
|
||||||
|
color: $ontime-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import Empty from '../../common/components/state/Empty';
|
||||||
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
|
|
||||||
|
import BackstageInfo from './backstage-info/BackstageInfo';
|
||||||
|
import PublicInfo from './public-info/PublicInfo';
|
||||||
|
import { projectInfoOptions } from './projectInfo.options';
|
||||||
|
|
||||||
|
import './ProjectInfo.scss';
|
||||||
|
|
||||||
|
interface ProjectInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectInfoProps(props: ProjectInfoProps) {
|
||||||
|
const { general, isMirrored } = props;
|
||||||
|
|
||||||
|
useWindowTitle('Project info');
|
||||||
|
|
||||||
|
if (!general) {
|
||||||
|
return <Empty text='No data found' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||||
|
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||||
|
{general.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
|
<div className='info'>
|
||||||
|
{general.title && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Title</div>
|
||||||
|
<div className='info__value'>{general.title}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{general.description && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Description</div>
|
||||||
|
<div className='info__value'>{general.description}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<BackstageInfo general={general} />
|
||||||
|
<PublicInfo general={general} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
interface BackstageInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackstageInfo(props: BackstageInfoProps) {
|
||||||
|
const { general } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const showBackstage = isStringBoolean(searchParams.get('showBackstage'));
|
||||||
|
|
||||||
|
if (!showBackstage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{general.backstageInfo && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Backstage info</div>
|
||||||
|
<div className='info__value'>{general.backstageInfo}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{general.backstageUrl && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Backstage URL</div>
|
||||||
|
<a href={general.backstageUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||||
|
{general.backstageUrl}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const projectInfoOptions: ViewOption[] = [
|
||||||
|
{ section: 'Data visibility' },
|
||||||
|
{
|
||||||
|
id: 'showBackstage',
|
||||||
|
title: 'Show backstage Data',
|
||||||
|
description: 'Weather to show fields related to the backstage views',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'showPublic',
|
||||||
|
title: 'Show Public Data',
|
||||||
|
description: 'Weather to show fields related to the public views',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
interface PublicInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicInfo(props: PublicInfoProps) {
|
||||||
|
const { general } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const showPublic = isStringBoolean(searchParams.get('showPublic'));
|
||||||
|
|
||||||
|
if (!showPublic) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{general.publicInfo && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Public info</div>
|
||||||
|
<div className='info__value'>{general.publicInfo}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{general.publicUrl && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Public URL</div>
|
||||||
|
<a href={general.publicUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||||
|
{general.publicUrl}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,10 +18,17 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: clamp(16px, 1.5vw, 24px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
@@ -71,9 +72,10 @@ export default function TimelinePage(props: TimelinePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className='timeline'>
|
<div className='timeline' data-testid='timeline-view'>
|
||||||
<ViewParamsEditor viewOptions={progressOptions} />
|
<ViewParamsEditor viewOptions={progressOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { sentryVitePlugin } from '@sentry/vite-plugin';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { fileURLToPath, URL } from 'node:url';
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { splitVendorChunkPlugin } from 'vite';
|
|
||||||
import { compression } from 'vite-plugin-compression2';
|
import { compression } from 'vite-plugin-compression2';
|
||||||
import svgrPlugin from 'vite-plugin-svgr';
|
import svgrPlugin from 'vite-plugin-svgr';
|
||||||
|
|
||||||
@@ -12,9 +11,9 @@ const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
|
|||||||
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
splitVendorChunkPlugin(),
|
|
||||||
svgrPlugin(),
|
svgrPlugin(),
|
||||||
!isDev &&
|
!isDev &&
|
||||||
sentryVitePlugin({
|
sentryVitePlugin({
|
||||||
@@ -33,7 +32,10 @@ export default defineConfig({
|
|||||||
excludeReplayWorker: true,
|
excludeReplayWorker: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
compression({ algorithm: 'brotliCompress' }),
|
compression({
|
||||||
|
algorithm: 'brotliCompress',
|
||||||
|
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@@ -46,6 +48,16 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: './build',
|
outDir: './build',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
// Split vendor code
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.7.1",
|
"version": "3.9.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||