Feat/55 v2 (#225)

* chore: upgrade relevant libraries
* feat(skip): add skip styling to paginated items
* chore: upgrade relevant libraries
* Fix: issue with text colour (#214)
* fix: adjust text colour from context
* fix: issue with wrong proptype
* fix issue with vite migration (#217)
* hotfix: 1.8.2 issues vite migration
* fix: file import options
* feat(55): styling
* refactor: remove onhover option for entry block
* refactor: relocate logging provider
* refactor: convert to typescript
* feat(55): add event editor
* refactor: extract event actions
* fix: bad import
* feat(55): redesign components
* fix: issues with not awaiting async
* refactor: replace socket with subscription
* refactor: remove unused
* style: small tweaks
* refactor: extract event actions
* refactor: cleanup debug
* refactor: chakra imports
* fix: optimistic mutations
* refactor: handle promise rejections
* refactor: validation
* fix: rq optimistic mutations
* feat: add playback feedback to block
* refactor: no optimistic adding of events
* refactor: simplify cursor state
* styles: cleanup editor style
* refactor: cleanup duration update
* fix: revert package upgrade (issues with vitest)
* fix: prevent cyclic imports
* refactor: extract data fetcher
* refactor: typescript migration
* chore: update tests
This commit is contained in:
Carlos Valente
2022-10-19 21:30:25 +02:00
committed by GitHub
parent 13be6ea2bc
commit 0fea4064c3
157 changed files with 4072 additions and 3033 deletions
+17 -13
View File
@@ -3,29 +3,29 @@
"version": "1.8.2",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.3.2",
"@chakra-ui/react": "2.3.4",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@dnd-kit/utilities": "^3.2.0",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@react-icons/all-files": "^4.1.0",
"@tanstack/react-query": "^4.1.3",
"@tanstack/react-query-devtools": "^4.0.10",
"@tanstack/react-query": "^4.10.3",
"@tanstack/react-query-devtools": "^4.11.0",
"autosize": "^5.0.1",
"axios": "^0.27.2",
"color": "^4.2.3",
"framer-motion": "^7.3.2",
"jotai": "^1.7.8",
"framer-motion": "^7.5.3",
"jotai": "^1.8.5",
"luxon": "^3.0.1",
"react": "^18.1.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.1.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-qr-code": "^2.0.5",
"react-router-dom": "^6.3.0",
"react-table": "^7.7.0",
"socket.io-client": "^4.5.1",
"socket.io-client": "^4.5.2",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^2.1.4"
},
@@ -56,12 +56,13 @@
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/node": "^18.7.16",
"@types/react": "^18.0.19",
"@types/react": "^18.0.21",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.37.0",
"@typescript-eslint/parser": "^5.37.0",
"@vitejs/plugin-react": "^2.1.0",
"eslint": "^8.23.1",
"eslint": "^8.25.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.0.4",
"eslint-plugin-react": "^7.31.8",
@@ -76,9 +77,12 @@
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard-scss": "^4.0.0",
"typescript": "^4.8.3",
"vite": "^3.1.1",
"vite": "^3.1.6",
"vite-plugin-svgr": "^2.2.1",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.23.2"
},
"resolutions": {
"**/@types/react": "18.0.21"
}
}
}
+21 -18
View File
@@ -1,18 +1,19 @@
import { Suspense, useCallback, useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
import { AppContextProvider } from 'common/context/AppContext';
import { LoggingProvider } from 'common/context/LoggingContext';
import SocketProvider from 'common/context/socketContext';
import { AppContextProvider } from './common/context/AppContext';
import SocketProvider from './common/context/socketContext';
import { ontimeQueryClient } from './common/queryClient';
import theme from './theme/theme';
import AppRouter from './AppRouter';
// Load Open Sans typeface
import('typeface-open-sans');
export const ontimeQueryClient = new QueryClient();
function App() {
@@ -45,20 +46,22 @@ function App() {
return (
<ChakraProvider resetCSS theme={theme}>
<SocketProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<AppRouter />
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
<LoggingProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<Suspense fallback={null}>
<AppRouter />
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</LoggingProvider>
</SocketProvider>
</ChakraProvider>
);
+2 -4
View File
@@ -1,10 +1,8 @@
import { lazy, useEffect } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ALIASES } from './common/api/apiConstants';
import { getAliases } from './common/api/ontimeApi';
import { useFetch } from './common/hooks/useFetch';
import { useSocketProvider } from './common/hooks/useSocketProvider';
import useAliases from './common/hooks-query/useAliases';
import withSocket from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('features/editors/ProtectedEditor'));
@@ -39,7 +37,7 @@ const Info = lazy(() => import('features/info/InfoExport'));
export default function AppRouter() {
useSocketProvider();
const { data } = useFetch(ALIASES, getAliases);
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
+12 -6
View File
@@ -6,13 +6,19 @@ const publicLocation = 'public';
const pipLocation = 'pip';
const studioLocation = 'studio';
const cuesheetLocation = 'cuesheet';
const countdownLocation = 'countdown';
const clockLocation = 'clock';
const lowerLocation = 'lower';
export const viewerLocations = [
{ link: speakerLocation, label: 'Stage timer' },
{ link: clockLocation, label: 'Clock' },
{ link: minimalLocation, label: 'Minimal timer' },
{ link: speakerLocation, label: 'Speaker Screen' },
{ link: smLocation, label: 'Backstage Screen' },
{ link: publicLocation, label: 'Public Screen' },
{ link: smLocation, label: 'Backstage screen' },
{ link: publicLocation, label: 'Public screen' },
{ link: lowerLocation, label: 'Lower thirds' },
{ link: pipLocation, label: 'Picture in Picture' },
{ link: studioLocation, label: 'Studio Clock' },
{ link: cuesheetLocation, label: 'Cuesheet' }
];
{ link: studioLocation, label: 'Studio clock' },
{ link: countdownLocation, label: 'Countdown' },
{ link: cuesheetLocation, label: 'Cuesheet' },
];
+3 -3
View File
@@ -2,8 +2,9 @@ export const STATIC_PORT = 4001;
export const EVENT_TABLE = ['event'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const EVENTS_TABLE = ['events'];
export const APP_TABLE = ['appinfo'];
export const EVENTS_TABLE_KEY = 'events';
export const EVENTS_TABLE = [EVENTS_TABLE_KEY];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
@@ -25,7 +26,6 @@ export const calculateServer = () =>
export const serverURL = calculateServer();
export const eventURL = `${serverURL}/${EVENT_TABLE}`;
export const eventsURL = `${serverURL}/${EVENTS_TABLE}`;
export const playbackURL = `${serverURL}/playback`;
export const ontimeURL = `${serverURL}/ontime`;
export const stylesPath = 'external/styles/override.css';
@@ -1,18 +1,22 @@
import axios from 'axios';
import { EventDataType } from '../models/EventData.type';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export const fetchEvent = async () => {
export async function fetchEvent(): Promise<EventDataType> {
const res = await axios.get(eventURL);
return res.data;
};
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export const postEvent = async (data) => axios.post(eventURL, data);
export async function postEvent(data: EventDataType) {
return axios.post(eventURL, data);
}
-54
View File
@@ -1,54 +0,0 @@
import axios from 'axios';
import { eventsURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export const fetchAllEvents = async () => {
const res = await axios.get(eventsURL);
return res.data;
};
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export const requestPost = async (data) => axios.post(eventsURL, data);
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export const requestPut = async (data) => axios.put(eventsURL, data);
/**
* @description HTTP request to modify event
* @return {Promise}
*/
export const requestPatch = async (data) => axios.patch(eventsURL, data);
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export const requestReorder = async (data) => axios.patch(`${eventsURL}/reorder`, data);
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export const requestApplyDelay = async (eventId) => axios.patch(`${eventsURL}/applydelay/${eventId}`);
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export const requestDelete = async (eventId) => axios.delete(`${eventsURL}/${eventId}`);
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export const requestDeleteAll = async () => axios.delete(`${eventsURL}/all`);
+70
View File
@@ -0,0 +1,70 @@
import axios from 'axios';
import { OntimeEventEntry } from '../models/EventTypes';
import { eventsURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchAllEvents(): Promise<OntimeEventEntry[]> {
const res = await axios.get(eventsURL);
return res.data;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeEventEntry) {
return axios.post(eventsURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: OntimeEventEntry) {
return axios.put(eventsURL, data);
}
/**
* @description HTTP request to modify event
* @return {Promise}
*/
export async function requestPatchEvent(data: OntimeEventEntry) {
return axios.patch(eventsURL, data);
}
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: OntimeEventEntry) {
return axios.patch(`${eventsURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${eventsURL}/applydelay/${eventId}`);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${eventsURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${eventsURL}/all`);
}
-283
View File
@@ -1,283 +0,0 @@
import axios from 'axios';
import { ontimeURL } from './apiConstants';
/**
* @description placeholder information for ontimeInfo
* @type {{settings: {serverPort: number, version: string}, networkInterfaces: *[]}}
*/
export const ontimePlaceholderInfo = {
networkInterfaces: [],
settings: {
version: '',
serverPort: 4001,
},
};
/**
* @description placeholder information for ontimeSettings
* @type {{pinCode: null}}
*/
export const ontimePlaceholderSettings = {
app: 'ontime',
version: 1,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
};
/**
* @description placeholder information for eventSettings
* @type {{backstageInfo: string, endMessage: string, publicInfo: string, title: string, url: string}}
*/
export const eventPlaceholderSettings = {
title: '',
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
};
/**
* @description placeholder information for userFields
* @type {{user1: string, user2: string, user0: string, user9: string, user7: string, user8: string, user5: string, user6: string, user3: string, user4: string}}
*/
export const userFieldsPlaceholder = {
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
/**
* @description placeholder information for oscSettings
* @type {{targetIP: string, port: string, portOut: string, enabled: boolean}}
*/
export const oscPlaceholderSettings = {
port: '',
portOut: '',
targetIP: '',
enabled: false,
};
/**
* @description placeholder information for view settings
* @type {{overrideCSS: boolean}}
*/
export const viewsPlaceholder = {
overrideStyles: false,
};
/**
* @description placeholder information for httpSettings
* @type {{onStart: {url: string, enabled: boolean}, onLoad: {url: string, enabled: boolean}, onPause: {url: string, enabled: boolean}, onFinish: {url: string, enabled: boolean}, onUpdate: {url: string, enabled: boolean}, onStop: {url: string, enabled: boolean}}}
*/
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
};
/**
* @description ontime utility variables
* @type {[{name: string, description: string}, {name: string, description: string}, {name: string, description: string}, {name: string, description: string}, {name: string, description: string}, null, null]}
*/
export const ontimeVars = [
{
name: '$timer',
description: 'Current running timer',
},
{
name: '$title',
description: 'Current title',
},
{
name: '$presenter',
description: 'Current timer',
},
{
name: '$subtitle',
description: 'Current subtitle',
},
{
name: '$next-title',
description: 'Next title',
},
{
name: '$next-presenter',
description: 'Next timer',
},
{
name: '$next-subtitle',
description: 'Next subtitle',
},
];
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export const getSettings = async () => {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
};
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export const postSettings = async (data) => axios.post(`${ontimeURL}/settings`, data);
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export const getInfo = async () => {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
};
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export const getView = async () => {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
};
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export const postView = async (data) => axios.post(`${ontimeURL}/views`, data);
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export const getAliases = async () => {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
};
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export const postAliases = async (data) => axios.post(`${ontimeURL}/aliases`, data);
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export const getUserFields = async () => {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
};
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export const postUserFields = async (data) => axios.post(`${ontimeURL}/userfields`, data);
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export const getOSC = async () => {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
};
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export const postOSC = async (data) => axios.post(`${ontimeURL}/osc`, data);
/**
* @description HTTP request to download db
* @return {Promise}
*/
export const downloadEvents = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'events.json';
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadEvents = async (file, setProgress, options) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyEvents = options?.onlyEvents;
await axios
.post(`${ontimeURL}/db?onlyEvents=${onlyEvents}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description HTTP request to upload events
* @return {Promise}
*/
export const uploadEventsWithPath = async (filepath) =>
axios.post(`${ontimeURL}/dbpath`, { path: filepath });
+157
View File
@@ -0,0 +1,157 @@
import axios from 'axios';
import { URLAliasType } from '../models/Alias.type';
import { InfoType } from '../models/Info.types';
import { OntimeSettingsType } from '../models/OntimeSettings.type';
import { OscSettingsType } from '../models/OscSettings.type';
import { UserFieldsType } from '../models/UserFields.type';
import { ViewSettingsType } from '../models/ViewSettings.type';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<OntimeSettingsType> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: OntimeSettingsType) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<InfoType> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettingsType> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postView(data: ViewSettingsType) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<URLAliasType[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAliases(data: URLAliasType[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export async function getUserFields(): Promise<UserFieldsType> {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
}
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export async function postUserFields(data: UserFieldsType) {
return axios.post(`${ontimeURL}/userfields`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OscSettingsType> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OscSettingsType) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to download db
* @return {Promise}
*/
export const downloadEvents = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'events.json';
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
type UploadEventsOptions = {
onlyEvents?: boolean;
}
export const uploadEvents = async (file: string, setProgress: (value: number) => void, options?: UploadEventsOptions) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyEvents = options?.onlyEvents;
await axios
.post(`${ontimeURL}/db?onlyEvents=${onlyEvents}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setProgress(complete);
},
})
.then((response) => response.data.id);
};
-45
View File
@@ -1,45 +0,0 @@
import axios from 'axios';
import { playbackURL } from './apiConstants';
/**
* @description HTTP call to start current timer
* @return {Promise}
*/
export const getStart = async () => axios.get(`${playbackURL}/start`);
/**
* @description HTTP call to pause current timer
* @return {Promise}
*/
export const getPause = async () => axios.get(`${playbackURL}/pause`);
/**
* @description HTTP call to start roll mode
* @return {Promise}
*/
export const getRoll = async () => axios.get(`${playbackURL}/roll`);
/**
* @description HTTP call to skip to previous event
* @return {Promise}
*/
export const getPrevious = async () => axios.get(`${playbackURL}/previous`);
/**
* @description HTTP call to skip to next event
* @return {Promise}
*/
export const getNext = async () => axios.get(`${playbackURL}/next`);
/**
* @description HTTP call to unload current timer
* @return {Promise}
*/
export const getUnload = async () => axios.get(`${playbackURL}/unload`);
/**
* @description HTTP call to reload current timer
* @return {Promise}
*/
export const getReload = async () => axios.get(`${playbackURL}/reload`);
@@ -1,3 +1,4 @@
import { atom } from 'jotai';
import { atomWithStorage, selectAtom } from 'jotai/utils';
export const eventSettingsAtom = atomWithStorage('ontime-eventSettings', {
@@ -18,3 +19,5 @@ export const defaultPublicAtom = selectAtom(
eventSettingsAtom,
(settings) => settings.defaultPublic
);
export const editorEventId = atom<string | null>(null);
@@ -1,6 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { FiClock } from '@react-icons/all-files/fi/FiClock';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiPlus } from '@react-icons/all-files/fi/FiPlus';
@@ -30,10 +28,7 @@ export default function ActionButtons(props: ActionButtonProps) {
aria-label='Options'
size='xs'
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor='orange.200'
color='orange.500'
colorScheme='blue'
/>
</Tooltip>
<MenuList style={menuStyle}>
@@ -1,4 +1,4 @@
import { Button } from '@chakra-ui/button';
import { Button } from '@chakra-ui/react';
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
import PropTypes from 'prop-types';
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { FiUsers } from '@react-icons/all-files/fi/FiUsers';
import PropTypes from 'prop-types';
@@ -1,5 +1,4 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Button, IconButton } from '@chakra-ui/button';
import {
AlertDialog,
AlertDialogBody,
@@ -7,8 +6,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
} from '@chakra-ui/modal';
import { Tooltip } from '@chakra-ui/tooltip';
Button, IconButton, Tooltip,
} from '@chakra-ui/react';
import { FiPower } from '@react-icons/all-files/fi/FiPower';
import PropTypes from 'prop-types';
@@ -24,7 +23,7 @@ export default function QuitIconBtn(props) {
useEffect(() => {
if (window.process?.type === 'renderer') {
window.ipcRenderer.on('user-request-shutdown', () => {
emitInfo('Shutdown request')
emitInfo('Shutdown request');
setIsOpen(true);
});
}
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import PropTypes from 'prop-types';
@@ -25,5 +24,5 @@ export default function RollIconBtn(props) {
RollIconBtn.propTypes = {
clickhandler: PropTypes.func,
active: PropTypes.bool,
disabled: PropTypes.bool
}
disabled: PropTypes.bool,
};
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import PropTypes from 'prop-types';
@@ -1,6 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { IconButtonProps } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: () => void;
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import PropTypes from 'prop-types';
export default function TooltipLoadingActionBtn(props) {
@@ -10,7 +9,7 @@ export default function TooltipLoadingActionBtn(props) {
const handleClick = useCallback(() => {
setLoading(true);
clickHandler();
},[clickHandler, setLoading]);
}, [clickHandler, setLoading]);
return (
<Tooltip label={tooltip} shouldWrapChildren={loading}>
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import PropTypes from 'prop-types';
@@ -36,7 +36,7 @@ class ErrorBoundary extends React.Component {
render() {
if (this.state.errorMessage) {
return (
<div className={style.errorContainer}>
<div className={style.errorContainer} data-testid="error-container">
<div>
<p className={style.error}>:/</p>
<p>Something went wrong</p>
@@ -1,70 +0,0 @@
import { useCallback, useContext } from 'react';
import EditableTimer from 'common/components/input/EditableTimer';
import PropTypes from 'prop-types';
import { LoggingContext } from '../../context/LoggingContext';
import { validateTimes } from '../../utils/entryValidator';
export default function EventTimes(props) {
const { actionHandler, delay, timeStart, timeEnd, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext);
/**
* This code is duplicated from EventTimesVertical
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidate = useCallback(
() => (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else return entry === 'durationOverride';
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
},
[emitWarning, timeEnd, timeStart]
);
return (
<>
<EditableTimer
name='timeStart'
validate={handleValidate}
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<EditableTimer
name='timeEnd'
validate={handleValidate}
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
</>
);
}
EventTimes.propTypes = {
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
previousEnd: PropTypes.number,
};
@@ -1,73 +0,0 @@
import { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import { LoggingContext } from '../../context/LoggingContext';
import { validateTimes } from '../../utils/entryValidator';
import Times from './Times';
import TimesDelayed from './TimesDelayed';
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration, previousEnd, actionHandler } = props;
const { emitWarning } = useContext(LoggingContext);
/**
* This code is duplicated from EventTimes
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidate = useCallback(
() => (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else return entry === 'durationOverride';
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
},
[emitWarning, timeEnd, timeStart]
);
return delay != null && delay !== 0 ? (
<TimesDelayed
handleValidate={handleValidate}
actionHandler={actionHandler}
delay={delay}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
) : (
<Times
handleValidate={handleValidate}
actionHandler={actionHandler}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
);
}
EventTimesVertical.propTypes = {
delay: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,50 +0,0 @@
import PropTypes from 'prop-types';
import EditableTimer from '../input/EditableTimer';
import style from './Times.module.scss';
export default function Times(props) {
const { handleValidate, actionHandler, timeStart, timeEnd, duration, previousEnd } = props;
return (
<>
<span className={style.label}>Start</span>
<EditableTimer
name='timeStart'
validate={handleValidate}
actionHandler={actionHandler}
time={timeStart}
delay={0}
previousEnd={previousEnd}
/>
<span className={style.label}>End</span>
<EditableTimer
name='timeEnd'
validate={handleValidate}
actionHandler={actionHandler}
time={timeEnd}
delay={0}
previousEnd={previousEnd}
/>
<span className={style.label}>Duration</span>
<EditableTimer
name='durationOverride'
validate={handleValidate}
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
}
Times.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
};
@@ -1,6 +0,0 @@
@use '../../../theme/main' as *;
.label {
font-size: 0.75em;
color: $label-gray;
}
@@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import { stringFromMillis } from '../../utils/time';
import EditableTimer from '../input/EditableTimer';
import style from './Times.module.scss';
export default function TimesDelayed(props) {
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration, previousEnd } = props;
const scheduledStart = stringFromMillis(timeStart, false);
const scheduledEnd = stringFromMillis(timeEnd, false);
return (
<>
<span className={style.label}>
Start <span>{scheduledStart}</span>
</span>
<EditableTimer
name='timeStart'
validate={handleValidate}
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<span className={style.label}>
End <span>{scheduledEnd}</span>
</span>
<EditableTimer
name='timeEnd'
validate={handleValidate}
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
<span className={style.label}>Duration</span>
<EditableTimer
name='durationOverride'
validate={handleValidate}
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
}
TimesDelayed.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
};
@@ -0,0 +1,10 @@
@use '../../../theme/main' as *;
input[type="color"] {
appearance: none;
background-color: $action-blue;
cursor: pointer;
height: 32px;
width: 32px;
padding: 0;
}
@@ -0,0 +1,22 @@
import { Input } from '@chakra-ui/react';
import style from './ColourInput.module.scss';
interface ColourInputProps {
value: string;
handleChange: (newValue: string) => void;
}
export default function ColourInput(props: ColourInputProps) {
const { value, handleChange } = props;
return (
<Input
size='sm'
variant='filled'
className={style.colourInput}
type='color'
value={value}
onChange={(event) => handleChange(event.target.value)}
/>
);
}
@@ -1,62 +1,86 @@
import { useCallback, useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { clamp } from 'common/utils/math';
import PropTypes from 'prop-types';
import { clamp } from '../../utils/math';
import style from './TimeInput.module.css';
import style from './TimeInput.module.scss';
const inputProps = {
width: 20,
fontWeight: 400,
backgroundColor: 'rgba(0,0,0,0.05)',
backgroundColor: 'rgba(255,255,255,0.13)',
color: '#fff',
border: '1px solid #ecc94b55',
borderRadius: '8px',
variant: 'filled',
borderRadius: '3px',
placeholder: '-',
textAlign: 'center',
size: 'sm',
};
export default function DelayInput(props) {
const { actionHandler, value } = props;
const { submitHandler, value } = props;
const [_value, setValue] = useState(value);
const inputRef = useRef(null);
useEffect(() => {
if (value == null) return;
setValue(value);
}, [value]);
const handleSubmit = useCallback(
/**
* @description Prepare delay value for update
* @param {string} value string to be parsed
*/
const validate = useCallback(
(newValue) => {
if (newValue === value) return;
if (newValue === '') setValue(0);
const delayValue = clamp(Number(newValue), -60, 60);
// convert to ms and updates
const msVal = clamp(newValue, -60, 60) * 60000;
actionHandler('update', { field: 'duration', value: msVal });
if (delayValue === value) return;
setValue(delayValue);
submitHandler(delayValue);
},
[actionHandler, value]
[submitHandler, value]
);
const labelText = `minutes ${value >= 0 ? 'delayed' : 'ahead'}`;
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback((event) => {
if (event.key === 'Enter') {
inputRef.current.blur();
validate(event.target.value);
} else if (event.key === 'Escape') {
inputRef.current.blur();
setValue(value);
}
}, [validate, value]);
const labelText = `${Math.abs(value) > 1 ? 'minutes' : 'minute'} ${
value >= 0 ? 'delayed' : 'ahead'
}`;
return (
<div className={style.timeInput}>
<Editable
<div className={style.delayInput}>
<Input
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
{...inputProps}
value={_value}
onChange={(v) => setValue(v)}
onSubmit={(v) => handleSubmit(v)}
>
<EditablePreview />
<EditableInput type='number' min='-60' max='60' />
</Editable>
onChange={(event) => setValue(event.target.value)}
onBlur={() => setValue(value)}
onKeyDown={onKeyDownHandler}
type='number'
/>
<span className={style.label}>{labelText}</span>
</div>
);
}
DelayInput.propTypes = {
actionHandler: PropTypes.func,
submitHandler: PropTypes.func,
value: PropTypes.number,
};
@@ -1,56 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import PropTypes from 'prop-types';
import style from './EditableText.module.scss';
export default function EditableText(props) {
const { label, defaultValue, placeholder, submitHandler, maxchar = 40, ...rest } = props;
const [text, setText] = useState(defaultValue || '');
useEffect(() => {
if (defaultValue == null) setText('');
else setText(defaultValue);
}, [defaultValue]);
const handleSubmit = useCallback((submittedVal) => {
// No need to update if it hasnt changed
if (submittedVal === defaultValue) return;
// submit a cleaned up version of the string
const cleanVal = submittedVal.trim();
submitHandler(cleanVal);
if (cleanVal !== submittedVal) {
setText(cleanVal);
}
},[defaultValue, submitHandler]);
const handleChange = useCallback((val) => {
if (val.length < maxchar) setText(val);
},[maxchar]);
return (
<div className={style.block}>
<span className={style.title}>{label}</span>
<Editable
onChange={(v) => handleChange(v)}
onSubmit={(v) => handleSubmit(v)}
value={text}
placeholder={placeholder}
className={style.inline}
{...rest}
>
<EditablePreview className={text === '' ? style.preview : ''} />
<EditableInput />
</Editable>
</div>
);
}
EditableText.propTypes = {
label: PropTypes.string,
defaultValue: PropTypes.string,
placeholder: PropTypes.string,
submitHandler: PropTypes.func.isRequired,
maxchar: PropTypes.number,
};
@@ -1,28 +0,0 @@
@use '../../../theme/main' as *;
.block {
overflow: hidden;
display: flex;
align-items: center;
width: 100%;
.title {
padding-left: 1em;
font-size: 0.75em;
color: $label-gray;
display: inline-block;
min-width: 6em;
}
.preview {
color: $bg-gray-500;
}
.inline {
display: inline;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
}
@@ -1,97 +0,0 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import PropTypes from 'prop-types';
import { LoggingContext } from '../../context/LoggingContext';
import { forgivingStringToMillis } from '../../utils/dateConfig';
import { stringFromMillis } from '../../utils/time';
import style from './EditableTimer.module.scss';
export default function EditableTimer(props) {
const { name, actionHandler, time = 0, delay, validate, previousEnd } = props;
const { emitError } = useContext(LoggingContext);
const [value, setValue] = useState('');
const handleSubmit = useCallback((value) => {
// Check if there is anything there
if (value === '') return false;
let newValMillis = 0;
// check for known aliases
if (value === 'p' || value === 'prev' || value === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// string to pass should add to the end before
const val = value.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(value);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (newValMillis === originalMillis) return false;
// validate with parent
if (!validate(name, newValMillis)) return false;
// update entry
actionHandler('update', { field: name, value: newValMillis });
return true;
},[actionHandler, delay, name, previousEnd, time, validate]);
// prepare time fields
const validateValue = useCallback((value) => {
const success = handleSubmit(value);
if (success) {
const ms = forgivingStringToMillis(value);
setValue(stringFromMillis(ms + delay));
} else {
setValue(stringFromMillis(time + delay));
}
},[delay, handleSubmit, time]);
useEffect(() => {
if (time == null) return;
try {
setValue(stringFromMillis(time + delay));
} catch (error) {
emitError(`Unable to parse date: ${error.text}`);
}
}, [time, delay, emitError]);
const isDelayed = delay != null && delay !== 0;
return (
<Editable
data-testid='editable-timer'
onChange={(v) => setValue(v)}
onSubmit={(v) => validateValue(v)}
onCancel={() => setValue(stringFromMillis(time + delay, true))}
value={value}
className={isDelayed ? style.delayedEditable : style.editable}
>
<EditablePreview />
<EditableInput type='text' placeholder='--:--:--' data-testid='editable-timer-input' />
</Editable>
);
}
EditableTimer.propTypes = {
name: PropTypes.string.isRequired,
actionHandler: PropTypes.func.isRequired,
time: PropTypes.number,
delay: PropTypes.number,
validate: PropTypes.func.isRequired,
previousEnd: PropTypes.number,
};
@@ -1,17 +0,0 @@
@use '../../../theme/main'as *;
.editable,
.delayedEditable {
background-color: $input-bg;
border: $input-border;
width: 6.5em;
letter-spacing: 1px;
height: fit-content;
text-align: center;
border-radius: 8px;
}
.delayedEditable {
border: $input-delayed-border;
}
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import PropTypes from 'prop-types';
export default function TextInput(props) {
const { isTextArea, size = 'sm', field, initialText = '', submitHandler } = props;
const inputRef = useRef(null);
const [text, setText] = useState(initialText);
useEffect(() => {
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
/**
* @description Handles Input value change
* @param {string} newValue
*/
const handleChange = useCallback(
(newValue) => {
if (newValue !== text) {
setText(newValue);
}
},
[text]
);
/**
* @description Handles submit events
* @param {string} valueToSubmit
*/
const handleSubmit = useCallback(
(valueToSubmit) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText) {
return;
}
const cleanVal = valueToSubmit.trim();
submitHandler(field, cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
},
[field, initialText, submitHandler]
);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(async () => {
setText(initialText);
},[initialText])
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const keyHandler = useCallback(
(event) => {
if (event.key === 'Escape') {
resetValue();
} else if (event.key === 'Enter') {
if (!isTextArea) {
handleSubmit(text);
}
}
},
[resetValue, isTextArea, handleSubmit, text]
);
return isTextArea ? (
<Textarea
ref={inputRef}
size={size}
variant='filled'
value={text}
onChange={(event) => handleChange(event.target.value)}
onBlur={(event) => handleSubmit(event.target.value)}
onKeyDown={(event) => keyHandler(event)}
data-testid='input-textarea'
/>
) : (
<Input
ref={inputRef}
size={size}
variant='filled'
value={text}
onChange={(event) => handleChange(event.target.value)}
onBlur={(event) => handleSubmit(event.target.value)}
onKeyDown={(event) => keyHandler(event)}
data-testid='input-textfield'
/>
);
}
TextInput.propTypes = {
isTextArea: PropTypes.bool,
size: PropTypes.string,
field: PropTypes.string.isRequired,
initialText: PropTypes.string,
submitHandler: PropTypes.func,
};
@@ -0,0 +1,167 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { IconButton, Input, InputGroup, InputLeftElement } from '@chakra-ui/react';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { LoggingContext } from 'common/context/LoggingContext';
import { forgivingStringToMillis } from 'common/utils/dateConfig';
import { stringFromMillis } from 'common/utils/time';
import PropTypes from 'prop-types';
import style from './TimeInput.module.scss';
export default function TimeInput(props) {
const { name, submitHandler, time = 0, delay, placeholder, validationHandler, previousEnd } = props;
const { emitError } = useContext(LoggingContext);
const inputRef = useRef(null);
const [value, setValue] = useState('');
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
// Todo: check if change is necessary
try {
setValue(stringFromMillis(time + delay));
} catch (error) {
emitError(`Unable to parse date: ${error.text}`);
}
}, [delay, emitError, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue) => {
// Check if there is anything there
if (newValue === '') {
return false;
}
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (
newValue.startsWith('+') ||
newValue.startsWith('p+') ||
newValue.startsWith('p +')
) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (newValMillis === originalMillis) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[delay, name, previousEnd, submitHandler, time, validationHandler]
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(stringFromMillis(ms + delay));
} else {
resetValue();
}
},
[delay, handleSubmit, resetValue]
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event) => {
if (event.key === 'Enter') {
inputRef.current.blur();
validateAndSubmit(event.target.value);
} else if (event.key === 'Tab') {
validateAndSubmit(event.target.value);
}
if (event.key === 'Escape') {
inputRef.current.blur();
resetValue();
}
},
[resetValue, validateAndSubmit]
);
useEffect(() => {
if (time == null) return;
resetValue();
}, [emitError, resetValue, time]);
const isDelayed = delay != null && delay !== 0;
return (
<InputGroup size='sm' className={`${style.timeInput} ${isDelayed ? style.delayed : ''}`}>
<InputLeftElement width='fit-content'>
<IconButton
size='sm'
icon={<IoLink style={{ transform: 'rotate(-45deg)' }} />}
aria-label='automate'
colorScheme='blue'
style={{ borderRadius: '2px', width: 'min-content' }}
tabIndex={-1}
/>
</InputLeftElement>
<Input
ref={inputRef}
data-testid='time-input'
className={style.inputField}
type='text'
placeholder={placeholder}
variant='filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={resetValue}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
/>
</InputGroup>
);
}
TimeInput.propTypes = {
name: PropTypes.string,
submitHandler: PropTypes.func,
time: PropTypes.number,
delay: PropTypes.number,
placeholder: PropTypes.string,
validationHandler: PropTypes.func,
previousEnd: PropTypes.number,
};
@@ -1,9 +0,0 @@
.timeInput {
display: flex;
font-size: 15px;
}
.label {
padding-left: 0.8em;
align-self: center;
}
@@ -0,0 +1,47 @@
@use '../../../theme/main' as *;
@mixin input-field {
border: 1px solid transparent;
background-color: $input-bg;
font-size: 1em;
letter-spacing: 1px;
&:hover {
background-color: $input-hover-bg;
}
}
.timeInput {
width: fit-content !important;
.inputField {
@include input-field;
width: 7.5em;
padding: 0 0 0 2.6em;
}
&.delayed {
.inputField {
border: $input-delayed-border;
background-color: $input-bg-delayed;
&:hover {
background-color: $input-hover-bg-delayed;
}
}
}
}
.delayInput {
display: flex;
align-items: center;
.inputField {
@include input-field;
}
.label {
padding-left: 8px;
font-size: 14px;
}
}
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import TextInput from '../TextInput';
describe('TextInput component', () => {
describe('when given props', () => {
it('renders correctly', () => {
const testField = 'test';
const testText = 'Test 123';
render(<TextInput field={testField} initialText={testText} />);
const input = screen.getByTestId('input-textfield');
expect(input).toBeInTheDocument();
expect(input).toHaveValue(testText);
});
it('Handles renders as textarea', () => {
const testField = 'test';
const testText = 'Test 123';
render(<TextInput field={testField} initialText={testText} isTextArea />);
const input = screen.getByTestId('input-textarea');
expect(input).toBeInTheDocument();
expect(input).toHaveValue(testText);
});
});
describe('on status change', () => {
it('calls submitHandler on new value', async () => {
const testField = 'test';
const testText = 'Test 123';
const myTypedString = '456';
const expectedString = testText + myTypedString;
const submitHandler = vi.fn();
render(<TextInput field={testField} initialText={testText} submitHandler={submitHandler} />);
const input = screen.getByTestId('input-textfield');
// submit without changing value
await userEvent.type(input, '{enter}');
expect(submitHandler).not.toHaveBeenCalled();
// on new value we can submit
await userEvent.type(input, myTypedString);
expect(input).toHaveValue(expectedString);
await userEvent.type(input, '{enter}');
expect(submitHandler).toHaveBeenCalledWith(testField, expectedString);
});
it('cleans value before submitting', async () => {
const testField = 'test';
const myTypedString = ' 456 ';
const expectedString = '456';
const submitHandler = vi.fn();
render(<TextInput field={testField} submitHandler={submitHandler} />);
const input = screen.getByTestId('input-textfield');
// on new value we can submit
await userEvent.type(input, myTypedString);
expect(input).toHaveValue(myTypedString);
await userEvent.type(input, '{enter}');
expect(submitHandler).toHaveBeenCalledWith(testField, expectedString);
});
});
describe('handles edge cases', () => {
it('handles number values', () => {
const testField = 'test';
const testText = 123;
render(<TextInput field={testField} initialText={testText} />);
const input = screen.getByTestId('input-textfield');
expect(input).toBeInTheDocument();
expect(input).toHaveValue(`${testText}`);
});
it('handles null value', () => {
const testField = 'test';
const testText = null;
const expected = '';
render(<TextInput field={testField} initialText={testText} />);
const input = screen.getByTestId('input-textfield');
expect(input).toBeInTheDocument();
expect(input).toHaveValue(expected);
});
it('handles undefined value', () => {
const testField = 'test';
const expected = '';
render(<TextInput field={testField} />);
const input = screen.getByTestId('input-textfield');
expect(input).toBeInTheDocument();
expect(input).toHaveValue(expected);
});
});
});
+1 -2
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { IconButton } from '@chakra-ui/button';
import { Image } from '@chakra-ui/react';
import { IconButton, Image } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import navlogo from 'assets/images/logos/LOGO-72.png';
@@ -50,6 +50,10 @@
}
}
.entry .skip {
text-decoration: line-through;
}
.backstage-indicator {
color: var(--accent-color-override, $accent-color);
margin-left: auto;
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useInterval } from 'common/hooks/useInterval';
import { OntimeEvent } from 'common/models/EventTypes';
import { OntimeEvent } from '../../application-types/event';
import Empty from '../state/Empty';
import TodayItem from './TodayItem';
import style from './Paginator.module.scss';
import './Paginator.scss';
interface PaginatorProps {
events: OntimeEvent[];
@@ -80,7 +80,7 @@ export default function Paginator(props: PaginatorProps) {
}
return (
<div className={style.entries}>
<div className='paginator entries'>
{page.map((e) => {
if (e.id === selectedId) selectedState = 1;
else if (selectedState === 1) selectedState = 2;
@@ -0,0 +1,37 @@
import { formatTime } from '../../utils/time';
import './Paginator.scss';
interface TodayItemProps {
selected: number;
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function TodayItem(props: TodayItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
// Format timers
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
// user colours
const userColour = colour !== '' ? colour : 'transparent';
let selectStyle = 'entry--past';
if (selected === 1) selectStyle = 'entry--now';
else if (selected === 2) selectStyle = 'entry--future';
return (
<div className={`entry ${selectStyle} ${skip ? 'skip': ''}}`} style={{ borderLeft: `4px solid ${userColour}` }}>
<div className='entry-times'>
{`${start} · ${end}`}
</div>
<div className='entry-title'>{title}</div>
{backstageEvent && <div className='backstage-indicator' />}
</div>
);
}
@@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { IconButton } from '@chakra-ui/button';
import { HStack, PinInput, PinInputField } from '@chakra-ui/react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
import PropTypes from 'prop-types';
@@ -1,6 +1,6 @@
import { ChangeEvent, useCallback, useContext, useRef, useState } from 'react';
import { Button } from '@chakra-ui/button';
import {
Button,
Checkbox,
FormControl,
FormErrorMessage,
@@ -1,51 +0,0 @@
import PropTypes from 'prop-types';
import { formatTime } from '../../utils/time';
import style from './Paginator.module.scss';
interface TodayItemProps {
selected: number;
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
// Todo: apply skip CSS and selector
export default function TodayItem(props: TodayItemProps) {
// @ts-ignore
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
// Format timers
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
// user colours
const userColour = colour !== '' ? colour : 'transparent';
// select styling
let selectStyle = style.entryPast;
if (selected === 1) selectStyle = style.entryNow;
else if (selected === 2) selectStyle = style.entryFuture;
return (
<div className={selectStyle} style={{ borderLeft: `4px solid ${userColour}` }}>
<div className={`${style.entryTimes} ${backstageEvent ? style.backstage : ''}`}>
{`${start} · ${end}`}
</div>
<div className={style.entryTitle}>{title}</div>
{backstageEvent && <div className={style.backstageInd} />}
</div>
);
}
TodayItem.propTypes = {
selected: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
title: PropTypes.string,
backstageEvent: PropTypes.bool,
colour: PropTypes.string,
};
+4 -6
View File
@@ -1,8 +1,6 @@
import { createContext, useCallback, useEffect, useState } from 'react';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { useFetch } from '../hooks/useFetch';
import useSettings from '../hooks-query/useSettings';
export const AppContext = createContext({
auth: false,
@@ -13,7 +11,7 @@ export const AppContext = createContext({
export const AppContextProvider = ({ children }) => {
const [auth, setAuth] = useState(true);
const { data } = useFetch(APP_SETTINGS, getSettings);
const { data } = useSettings();
useEffect(() => {
if (data == null) return;
@@ -22,7 +20,7 @@ export const AppContextProvider = ({ children }) => {
if (previousEntry === data?.pinCode) {
setAuth(true);
} else {
sessionStorage.removeItem('ontime-entry')
sessionStorage.removeItem('ontime-entry');
}
} else if (data?.pinCode == null || data?.pinCode === '') {
setAuth(true);
@@ -49,7 +47,7 @@ export const AppContextProvider = ({ children }) => {
setAuth(correct);
return correct;
},
[data]
[data],
);
return <AppContext.Provider value={{ auth, validate }}>{children}</AppContext.Provider>;
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { ALIASES } from '../api/apiConstants';
import { getAliases } from '../api/ontimeApi';
export default function useAliases() {
const {
data,
status,
isError,
refetch,
} = useQuery(ALIASES, getAliases, { placeholderData: [] });
return { data, status, isError, refetch };
}
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { EVENT_TABLE } from '../api/apiConstants';
import { fetchEvent } from '../api/eventApi';
import { eventDataPlaceholder } from '../models/EventData.type';
export default function useEvent() {
const {
data,
status,
isError,
refetch,
} = useQuery(EVENT_TABLE, fetchEvent, { placeholderData: eventDataPlaceholder });
return { data, status, isError, refetch };
}
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { EVENTS_TABLE } from '../api/apiConstants';
import { fetchAllEvents } from '../api/eventsApi';
export default function useEventsList() {
const {
data,
status,
isError,
refetch,
} = useQuery(EVENTS_TABLE, fetchAllEvents, { placeholderData: [] });
return { data, status, isError, refetch };
}
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { APP_INFO } from '../api/apiConstants';
import { getInfo } from '../api/ontimeApi';
import { ontimePlaceholderInfo } from '../models/Info.types';
export default function useInfo() {
const {
data,
status,
isError,
refetch,
} = useQuery(APP_INFO, getInfo, { placeholderData: ontimePlaceholderInfo });
return { data, status, isError, refetch };
}
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { OSC_SETTINGS } from '../api/apiConstants';
import { getOSC } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings.type';
export default function useOscSettings() {
const {
data,
status,
isError,
refetch,
} = useQuery(OSC_SETTINGS, getOSC, { placeholderData: oscPlaceholderSettings });
return { data, status, isError, refetch };
}
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { ontimePlaceholderSettings } from '../models/OntimeSettings.type';
export default function useSettings() {
const {
data,
status,
isError,
refetch,
} = useQuery(APP_SETTINGS, getSettings, { placeholderData: ontimePlaceholderSettings });
return { data, status, isError, refetch };
}
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { USERFIELDS } from '../api/apiConstants';
import { getUserFields } from '../api/ontimeApi';
import { userFieldsPlaceholder } from '../models/UserFields.type';
export default function useUserFields() {
const {
data,
status,
isError,
refetch,
} = useQuery(USERFIELDS, getUserFields, { placeholderData: userFieldsPlaceholder });
return { data, status, isError, refetch };
}
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { VIEW_SETTINGS } from '../api/apiConstants';
import { getView } from '../api/ontimeApi';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const {
data,
status,
isError,
refetch,
} = useQuery(VIEW_SETTINGS, getView, { placeholderData: viewsSettingsPlaceholder });
return { data, status, isError, refetch };
}
+288
View File
@@ -0,0 +1,288 @@
import { useCallback, useContext } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { EVENTS_TABLE, EVENTS_TABLE_KEY } from '../api/apiConstants';
import {
requestApplyDelay,
requestDelete,
requestDeleteAll,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
} from '../api/eventsApi';
import { LoggingContext } from '../context/LoggingContext';
/**
* @description Set of utilities for events
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { emitError } = useContext(LoggingContext);
/**
* @description Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation(requestPostEvent, {
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
/**
* @description Adds new event to list
* @param {object} event - Event to be added
* @param {object} [options] - Event options
*/
const addEvent = useCallback(
async (event, options) => {
const newEvent = { ...event };
// ************* CHECK OPTIONS
// there is an option to pass an index of an array to use as start time
if (typeof options?.startIsLastEnd !== 'undefined') {
const events = queryClient.getQueryData(EVENTS_TABLE);
const previousEvent = events.find((event) => event.id === options.startIsLastEnd);
newEvent.timeStart = previousEvent.timeEnd || 0;
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (newEvent.type === 'event') {
newEvent.duration = Math.max(0, newEvent.timeEnd - newEvent.timeStart) || 0;
}
try {
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
emitError(`Error fetching data: ${error.message}`);
}
},
[_addEventMutation, emitError],
);
/**
* @description Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation(requestPutEvent, {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries([EVENTS_TABLE_KEY, newEvent.id]);
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([EVENTS_TABLE_KEY, newEvent.id]);
// optimistically update object
queryClient.setQueryData([EVENTS_TABLE_KEY, newEvent.id], newEvent);
// Return a context with the previous and new events
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (error, newEvent, context) => {
queryClient.setQueryData([EVENTS_TABLE_KEY, context.newEvent.id], context.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async (newEvent) => {
await queryClient.invalidateQueries([EVENTS_TABLE_KEY, newEvent.id]);
},
});
/**
* @description Updates existing event
* @param {object} event - Event to be added
*/
const updateEvent = useCallback(
async (event) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
emitError(`Error updating event: ${error.message}`);
}
},
[_updateEventMutation, emitError]
);
/**
* @description Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation(requestDelete, {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries([EVENTS_TABLE_KEY, eventId]);
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(EVENTS_TABLE);
const filtered = [...previousEvents].filter((e) => e.id !== eventId);
// optimistically update object
queryClient.setQueryData(EVENTS_TABLE, filtered);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (error, eventId, context) => {
queryClient.setQueryData(EVENTS_TABLE, context.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
/**
* @description Deletes an event form the list
* @param {object} eventId - Event to be deleted
*/
const deleteEvent = useCallback(
async (eventId) => {
try {
await _deleteEventMutation.mutateAsync(eventId);
} catch (error) {
emitError(`Error deleting event: ${error.message}`);
}
},
[_deleteEventMutation, emitError],
);
/**
* @description Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries(EVENTS_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(EVENTS_TABLE);
const clear = [];
// optimistically update object
queryClient.setQueryData(EVENTS_TABLE, clear);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undos optimist update
onError: (error, eventId, context) => {
queryClient.setQueryData(EVENTS_TABLE, context.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
/**
* @description Deletes all events from list
*/
const deleteAllEvents = useCallback(async () => {
try {
await _deleteAllEventsMutation.mutateAsync();
} catch (error) {
emitError(`Error deleting events: ${error.message}`);
}
}, [_deleteAllEventsMutation, emitError]);
/**
* @description Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation(requestApplyDelay, {
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
/**
* @description Applies a given delay
* @param {object} delayEventId - Id of delay to be applied
*/
const applyDelay = useCallback(
async (delayEventId) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
emitError(`Error applying delay: ${error.message}`);
}
},
[_applyDelayMutation, emitError],
);
/**
* @description Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation(requestReorderEvent, {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries(EVENTS_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(EVENTS_TABLE);
const e = [...previousEvents];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
// optimistically update object
queryClient.setQueryData(EVENTS_TABLE, e);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (error, eventId, context) => {
queryClient.setQueryData(EVENTS_TABLE, context.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(EVENTS_TABLE);
},
});
/**
* @description Reorders a given event
* @param {string} eventID - ID of event to reorder
* @param {number} from - Current index
* @param {number} to - New Index
*/
const reorderEvent = useCallback(
async (eventId, from, to) => {
try {
const reorderObject = {
index: eventId,
from: from,
to: to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
emitError(`Error re-ordering event: ${error.message}`);
}
},
[_reorderEventMutation, emitError],
);
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
};
-18
View File
@@ -1,18 +0,0 @@
import { QueryFunction, QueryKey, useQuery, UseQueryOptions } from '@tanstack/react-query';
interface UseFetchState {
data: unknown;
status: "loading" | "error" | "success";
isError: boolean;
refetch: () => void;
}
export const useFetch = ( key: QueryKey, fn: QueryFunction, options?: UseQueryOptions): UseFetchState => {
const { data, status, isError, refetch } = useQuery(key, fn, {
refetchInterval: 10000,
cacheTime: Infinity,
...options
});
return { data, status, isError, refetch };
};
+21 -8
View File
@@ -20,8 +20,9 @@ export const useEventListProvider = () => {
() => ({
selectedEventId: null,
nextEventId: null,
playback: null,
}),
[]
[],
);
return data ?? placeholder;
};
@@ -48,7 +49,7 @@ export const useMessageControlProvider = () => {
},
onAir: false,
}),
[]
[],
);
const returnData = data ?? placeholder;
@@ -63,7 +64,7 @@ export const useMessageControlProvider = () => {
lowerVisible: (payload) => socket.emit('set-lower-message-visible', payload),
onAir: (payload) => socket.emit('set-onAir', payload),
}),
[socket]
[socket],
);
return { data: returnData, setMessage };
@@ -82,7 +83,7 @@ export const usePlaybackControlProvider = () => {
selectedEventId: null,
numEvents: 0,
}),
[]
[],
);
const resetData = useCallback(() => {
@@ -118,7 +119,7 @@ export const usePlaybackControlProvider = () => {
socket.emit('set-delay', amount);
},
}),
[resetData, socket]
[resetData, socket],
);
const returnData = data ?? placeholder;
@@ -148,7 +149,7 @@ export const useInfoProvider = () => {
selectedEventIndex: null,
numEvents: 0,
}),
[]
[],
);
return data ?? placeholder;
};
@@ -163,7 +164,7 @@ export const useCuesheetProvider = () => {
selectedEventId: null,
titleNow: '',
}),
[]
[],
);
return data ?? placeholder;
@@ -183,11 +184,23 @@ export const useTimerProvider = () => {
expectedFinish: null,
startedAt: null,
}),
[]
[],
);
return data ?? placeholder;
};
export const useEventProvider = (eventId) => {
const socket = useSocket();
const setPlayback = useMemo(() => ({
loadEvent: () => socket.emit('set-loadid', eventId),
startEvent: () => socket.emit('set-startid', eventId),
pause: () => socket.emit('set-pause'),
}), [socket]);
return { setPlayback };
};
export const useSocketProvider = () => {
const queryClient = useQueryClient();
const socket = useSocket();
+11
View File
@@ -0,0 +1,11 @@
export type URLAliasType = {
enabled: boolean;
alias: string;
pathAndParams: string;
}
export const aliasPlaceholder: URLAliasType = {
enabled: false,
alias: '',
pathAndParams: '',
};
@@ -0,0 +1,15 @@
export type EventDataType = {
title: string;
url: string;
publicInfo: string;
backstageInfo: string;
endMessage: string;
}
export const eventDataPlaceholder: EventDataType = {
title: '',
url: '',
publicInfo: '',
backstageInfo: '',
endMessage: '',
};
@@ -1,19 +1,21 @@
export type EventTypes = 'event' | 'delay' | 'block';
export interface OntimeBaseEvent {
type: 'block' | 'event' | 'delay';
type: EventTypes;
id: string;
}
export interface OntimeDelay extends OntimeBaseEvent {
export type OntimeDelay = OntimeBaseEvent & {
type: 'delay';
duration: number;
revision: number;
}
export interface OntimeBlock extends OntimeBaseEvent {
export type OntimeBlock = OntimeBaseEvent & {
type: 'block';
}
export interface OntimeEvent extends OntimeBaseEvent {
export type OntimeEvent = OntimeBaseEvent & {
type: 'event';
title: string,
subtitle: string,
+26
View File
@@ -0,0 +1,26 @@
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
};
+14
View File
@@ -0,0 +1,14 @@
import { OntimeSettingsType } from './OntimeSettings.type';
export type InfoType = {
networkInterfaces: string[];
settings: Pick<OntimeSettingsType, "version" | "serverPort" >
}
export const ontimePlaceholderInfo: InfoType = {
networkInterfaces: [],
settings: {
version: 0,
serverPort: 4001,
},
};
@@ -0,0 +1,19 @@
import { TimeFormat } from './OntimeTypes';
export type OntimeSettingsType = {
app: string;
version: number;
serverPort: number;
lock: null | boolean;
pinCode: null | number | string;
timeFormat: TimeFormat;
}
export const ontimePlaceholderSettings: OntimeSettingsType = {
app: 'ontime',
version: 1,
serverPort: 4001,
lock: null,
pinCode: null,
timeFormat: '24',
};
+2 -20
View File
@@ -1,20 +1,2 @@
type Playstate = 'roll' | 'start' | 'pause' | 'stop';
export type TimeManager = {
clock: number,
running: number,
isNegative: boolean;
startedAt: null | number;
expectedFinish: null | number;
finished: boolean;
playstate: Playstate
}
export type PresenterMessageData = {
text: string;
visible: boolean;
}
export type ViewSettings = {
overrideStyles: boolean;
}
export type Playstate = 'roll' | 'start' | 'pause' | 'stop';
export type TimeFormat = '12' | '24';
+30
View File
@@ -0,0 +1,30 @@
export const ontimeVars = [
{
name: '$timer',
description: 'Current running timer',
},
{
name: '$title',
description: 'Current title',
},
{
name: '$presenter',
description: 'Current timer',
},
{
name: '$subtitle',
description: 'Current subtitle',
},
{
name: '$next-title',
description: 'Next title',
},
{
name: '$next-presenter',
description: 'Next timer',
},
{
name: '$next-subtitle',
description: 'Next subtitle',
},
];
@@ -0,0 +1,13 @@
export type OscSettingsType = {
port: string;
portOut: string;
targetIP: string;
enabled: boolean;
}
export const oscPlaceholderSettings: OscSettingsType = {
port: '',
portOut: '',
targetIP: '',
enabled: false,
};
@@ -0,0 +1,4 @@
export type PresenterMessageType = {
text: string;
visible: boolean;
}
@@ -0,0 +1,11 @@
import { Playstate } from './OntimeTypes';
export type TimeManagerType = {
clock: number,
running: number,
isNegative: boolean;
startedAt: null | number;
expectedFinish: null | number;
finished: boolean;
playstate: Playstate
}
@@ -0,0 +1,25 @@
export type UserFieldsType = {
user0: string;
user1: string;
user2: string;
user3: string;
user4: string;
user5: string;
user6: string;
user7: string;
user8: string;
user9: string;
}
export const userFieldsPlaceholder: UserFieldsType = {
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
+1
View File
@@ -0,0 +1 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -0,0 +1,7 @@
export type ViewSettingsType = {
overrideStyles: boolean;
}
export const viewsSettingsPlaceholder: ViewSettingsType = {
overrideStyles: false,
};
+3
View File
@@ -0,0 +1,3 @@
import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient();
@@ -0,0 +1,27 @@
import { calculateDuration, DAY_TO_MS } from '../timesManager';
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('calculates duration correctly', () => {
const testStart = 1;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
describe('Handles edge cases', () => {
it('when start is after end', () => {
const testStart = 3;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
});
+4 -2
View File
@@ -1,4 +1,4 @@
import { OntimeEvent, OntimeEventEntry } from '../application-types/event';
import { OntimeEvent, OntimeEventEntry } from '../models/EventTypes';
import { formatTime } from './time';
@@ -97,7 +97,8 @@ export const formatEventList = (events: OntimeEvent[], selectedId: string, nextI
* @param {object} event
* @return {object} clean event
*/
export const duplicateEvent = (event: OntimeEvent) => {
type DuplicatedEvent = OntimeEvent | { after?: string };
export const duplicateEvent = (event: OntimeEvent, after?: string): DuplicatedEvent => {
return {
type: 'event',
title: event.title,
@@ -109,5 +110,6 @@ export const duplicateEvent = (event: OntimeEvent) => {
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after: after,
};
};
+6
View File
@@ -21,3 +21,9 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
}
return { backgroundColor: '#000', color: "#fffffa" };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (...classNames: any[]) => classNames.filter(Boolean).join(" ")
+1 -1
View File
@@ -1,7 +1,7 @@
import { DateTime } from 'luxon';
import { ontimeQueryClient } from '../../App';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { mth, mtm, mts } from './timeConstants';
+51
View File
@@ -0,0 +1,51 @@
/**
* @description Milliseconds in a day
*/
export const DAY_TO_MS = 86400000;
/**
* @description calculates duration from given values
*/
export const calculateDuration = (start: number, end: number): number =>
start > end ? end + DAY_TO_MS - start : end - start;
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (field: string, val: number, timeStart: number, timeEnd: number): {start: number, end: number, durationOverride: boolean} => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (field: string, value: number, timeStart: number, timeEnd: number): { value: boolean, catch: string } => {
const validate = { value: true, catch: '' };
// 1. if one of times is not entered, anything goes
if (value == null || timeStart == null || timeEnd == null) return validate;
if (timeStart === 0) return validate;
// 2. find out what's what
const { start, end, durationOverride } = handleTimeEntry(field, value, timeStart, timeEnd);
if (durationOverride !== null) {
return validate;
}
// 3. validation rules
if (start > end) {
validate.catch = 'Start time later than end time';
}
return validate;
};
@@ -1,7 +1,5 @@
import { useEffect, useState } from 'react';
import { IconButton } from '@chakra-ui/button';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { Tooltip } from '@chakra-ui/tooltip';
import { Editable, EditableInput, EditablePreview, IconButton, Tooltip } from '@chakra-ui/react';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
@@ -1,4 +1,4 @@
import { Box } from '@chakra-ui/layout';
import { Box } from '@chakra-ui/react';
import { FiArrowUpRight } from '@react-icons/all-files/fi/FiArrowUpRight';
import ErrorBoundary from '../../../common/components/errorBoundary/ErrorBoundary';
@@ -1,6 +1,5 @@
import { memo } from 'react';
import { Button } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/react';
import { Button, Tooltip } from '@chakra-ui/react';
import TimerDisplay from 'common/components/countdown/TimerDisplay';
import PropTypes from 'prop-types';
@@ -55,7 +54,6 @@ const PlaybackTimer = (props) => {
{isWaiting ? (
<div className={style.roll}>
<span className={style.rolltag}>Roll: Countdown to start</span>
<span className={style.time}>FIX</span>
</div>
) : (
<>
@@ -1,4 +1,4 @@
import { Box } from '@chakra-ui/layout';
import { Box } from '@chakra-ui/react';
import { FiArrowUpRight } from '@react-icons/all-files/fi/FiArrowUpRight';
import ErrorBoundary from '../../../common/components/errorBoundary/ErrorBoundary';
@@ -1,57 +0,0 @@
@use '../../../theme/main' as *;
/* ============= COMMON ============= */
.block {
margin: 0.2em 0;
padding: 0.2em 0.5em;
box-sizing: border-box;
width: 100%;
border-radius: 8px;
font-size: 15px;
border: $block-border;
display: grid;
grid-template-columns: 2em auto;
align-content: center;
align-items: baseline;
gap: 0.5em;
background-color: $block-block-color;
background: linear-gradient(
0deg,
rgba(107, 70, 193, 0.4) 0%,
rgba(128, 90, 213, 0.4) 20%,
rgba(107, 70, 193, 0.15) 21%
);
}
/* ================ DRAG ================ */
.drag {
color: rgba(255, 255, 255, 0.67);
align-self: center;
justify-self: start;
}
/* ============== ACTION ================ */
.actionOverlay {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: center;
align-self: flex-start;
padding-top: 0.2em;
opacity: 0.8;
transition: linear 0.1s;
justify-self: end;
}
.block:hover > .actionOverlay {
opacity: 1;
transition: linear 0.1s;
}
/* NA */
.actionOverlay:hover {
opacity: 1;
}
@@ -1,67 +0,0 @@
@use '../../../theme/main' as *;
/* ============= COMMON ============= */
.delay {
margin: 0.2em 0;
padding: 0.2em 0.5em;
width: 100%;
border-radius: 8px;
font-size: 15px;
border: $block-border;
display: grid;
grid-template-columns: 2em 1fr auto;
grid-template-areas: 'drag inpt btns';
justify-content: center;
align-content: center;
align-items: baseline;
gap: 0.5em;
background-color: $block-delay-color;
background: linear-gradient(
180deg,
rgba(214, 158, 46, 0.4) 0%,
rgba(236, 201, 75, 0.4) 20%,
rgba(214, 158, 46, 0.07) 21%
);
}
/* ================ DRAG ================ */
.drag {
grid-area: drag;
color: $block-icon-drag;
align-self: center;
}
/* =============== INPUT ================ */
.input {
grid-area: inpt;
}
/* ============== ACTION ================ */
.actionOverlay {
grid-area: btns;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: center;
align-self: flex-start;
padding-top: 0.2em;
opacity: 0.8;
transition: linear 0.1s;
}
.delay:hover > .actionOverlay {
opacity: 1;
transition: linear 0.1s;
}
/* NA */
.actionOverlay:hover {
opacity: 1;
}
+12 -1
View File
@@ -148,10 +148,21 @@ h1 {
left: 0;
z-index: 10;
color: white;
transition: bottom 0.3s;
&.noEvent {
bottom: -500px;
transition: bottom 0.7s;
}
.header {
display: flex;
justify-content: space-between;
}
.header {
h1 {
margin-right: auto;
}
}
}
+6 -5
View File
@@ -1,11 +1,10 @@
import { lazy, useEffect } from 'react';
import { useDisclosure } from '@chakra-ui/hooks';
import { Box } from '@chakra-ui/layout';
import { Box } from '@chakra-ui/react';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
import UploadModal from 'common/components/upload-modal/UploadModal';
import ModalManager from 'features/modals/ModalManager';
import UploadModal from '../../common/components/upload-modal/UploadModal';
import { LoggingProvider } from '../../common/context/LoggingContext';
import MenuBar from '../menu/MenuBar';
import styles from './Editor.module.scss';
@@ -14,6 +13,7 @@ const EventList = lazy(() => import('features/editors/list/EventListExport'));
const TimerControl = lazy(() => import('features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('features/control/message/MessageControlExport'));
const Info = lazy(() => import('features/info/InfoExport'));
const EventEditor = lazy(() => import('features/event-editor/EventEditorExport'));
export default function Editor() {
const {
@@ -34,7 +34,7 @@ export default function Editor() {
}, []);
return (
<LoggingProvider>
<>
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<ErrorBoundary>
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
@@ -56,6 +56,7 @@ export default function Editor() {
<TimerControl />
<Info />
</div>
</LoggingProvider>
<EventEditor />
</>
);
}
@@ -1,71 +0,0 @@
import { memo } from 'react';
import { HStack } from '@chakra-ui/react';
import { FiMoreVertical } from '@react-icons/all-files/fi/FiMoreVertical';
import PropTypes from 'prop-types';
import ActionButtons from '../../../common/components/buttons/ActionButtons';
import PublicIconBtn from '../../../common/components/buttons/PublicIconBtn';
import EventTimes from '../../../common/components/eventTimes/EventTimes';
import EditableText from '../../../common/components/input/EditableText';
import style from './EventBlock.module.scss';
const areEqual = (prevProps, nextProps) => {
return (
prevProps.provided === nextProps.provided &&
prevProps.data.revision === nextProps.data.revision &&
prevProps.next === nextProps.next &&
prevProps.delay === nextProps.delay &&
prevProps.delayValue === nextProps.delayValue &&
prevProps.previousEnd === nextProps.previousEnd
);
};
function CollapsedBlock(props) {
const { provided, data, next, delay, delayValue, previousEnd, actionHandler } = props;
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && <span className={style.delayValue}>{delayValue}</span>}
</div>
<EventTimes
actionHandler={actionHandler}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
<div className={style.titleContainer}>
<EditableText
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
</div>
<HStack spacing='0.5em' className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
</HStack>
</>
);
}
CollapsedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.any,
delayValue: PropTypes.string,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
export default memo(CollapsedBlock, areEqual);
@@ -1,79 +0,0 @@
import { useContext, useMemo } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import Icon from '@chakra-ui/icon';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { millisToMinutes } from 'common/utils/dateConfig';
import PropTypes from 'prop-types';
import { CollapseContext } from '../../../common/context/CollapseContext';
import CollapsedBlock from './CollapsedBlock';
import ExpandedBlock from './ExpandedBlock';
import style from './EventBlock.module.scss';
export default function EventBlock(props) {
const { data, selected, delay, index, eventIndex, previousEnd, actionHandler, next } = props;
const { isCollapsed, setCollapsed } = useContext(CollapseContext);
const collapsed = useMemo(() => isCollapsed(data.id), [data.id, isCollapsed]);
const selectedStyle = selected ? style.active : '';
const collapsedStyle = collapsed ? style.collapsed : style.expanded;
const classSelect = `${style.event} ${collapsedStyle} ${selectedStyle}`;
// Calculate delay in min
let delayValue = null;
if (delay != null && delay !== 0) {
delayValue = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
}
const handleCollapse = (isCollapsed) => {
setCollapsed(data.id, isCollapsed);
};
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div className={classSelect} {...provided.draggableProps} ref={provided.innerRef}>
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
onClick={() => handleCollapse(!collapsed)}
/>
{collapsed ? (
<CollapsedBlock
provided={provided}
data={data}
next={next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
/>
) : (
<ExpandedBlock
provided={provided}
eventIndex={eventIndex}
data={data}
next={next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
/>
)}
</div>
)}
</Draggable>
);
}
EventBlock.propTypes = {
data: PropTypes.object.isRequired,
selected: PropTypes.bool.isRequired,
delay: PropTypes.number,
index: PropTypes.number.isRequired,
eventIndex: PropTypes.number.isRequired,
previousEnd: PropTypes.number,
actionHandler: PropTypes.func.isRequired,
next: PropTypes.bool,
};
@@ -1,221 +0,0 @@
@use '../../../theme/main' as *;
/* ============= COMMON ============= */
.event {
margin: 0.2em 0;
padding: 0.2em 0.5em;
box-sizing: border-box;
width: 100%;
border-radius: 8px;
font-size: 15px;
border: 1px solid rgba(255, 255, 255, 0.05);
background-color: $bg-gray-950;
display: grid;
gap: 0.5em;
}
.active {
background-color: rgba(255, 255, 255, 0.36);
background: linear-gradient(
180deg,
#00000000 10%,
#4bffab10 97%,
#227c52aa 98%,
#4bffabcc 100%
);
}
.collapsed {
grid-template-columns: 2em 2em auto auto 1fr auto 3.7em;
grid-template-areas: 'drag indi time time text more btns';
justify-content: center;
align-items: center;
}
.expanded {
grid-template-columns: 2em 2em 2em auto 1fr auto 3.7em;
grid-template-rows: repeat(4, 1fr);
grid-template-areas:
'drag indi time time text more btns'
'.... indi time time text .... btns'
'.... indi time time text .... btns'
'.... indi time time text .... btns';
justify-content: center;
}
/* ================ DRAG ================ */
.drag {
grid-area: drag;
color: rgba(255, 255, 255, 0.67);
align-self: center;
}
/* ============= INDICATORS ============= */
.indicators {
grid-area: indi;
display: flex;
flex-direction: column;
align-self: flex-start;
font-size: 0.75em;
overflow: hidden;
min-height: 3em;
min-width: 0;
justify-content: center;
}
.next,
.nextDisabled {
width: max-content;
padding: 0 0.2em;
color: $ontime-accent;
transition: 0.3s;
}
.next {
opacity: 0.8;
}
.nextDisabled {
opacity: 0.05;
}
.delayValue {
color: #d69e2eaa;
padding: 0 0.2em;
text-align: center;
align-self: flex-start;
width: max-content;
}
.next:after,
.delayValue:after {
content: '\200b';
}
/* =============== TIMES ================ */
.time {
grid-area: time;
align-self: center;
}
.start {
grid-area: star;
}
.end {
grid-area: end;
}
.duration {
grid-area: dura;
}
.timeExpanded {
grid-area: time;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-items: flex-start;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 0 0.5em 0.5em 0.5em;
}
.label {
font-size: 0.75em;
color: #aaa;
}
.calculatedTime {
grid-area: time;
font-size: 0.75em;
}
/* =============== TITLES =============== */
.titleContainer {
grid-area: text;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
width: 100%;
display: block;
white-space: nowrap;
overflow: hidden;
}
.collapsed .titleContainer {
height: fit-content;
}
.expanded .titleContainer {
height: 100%;
border-radius: 4px;
}
.oscLabel {
color: #4bffabcc;
font-size: 0.8em;
float: right;
padding-right: 1em;
-webkit-user-select: text;
user-select: text;
}
/* ================ MORE ================ */
.moreExpanded,
.moreCollapsed {
cursor: pointer;
color: #fff;
grid-area: more;
margin-top: 0.2em;
align-self: baseline;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform 0.3s;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform 0.3s;
}
/* ============== ACTION ================ */
.actionOverlay {
grid-area: btns;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: center;
align-self: flex-start;
padding-top: 0.2em;
opacity: 0.8;
transition: linear 0.1s;
}
.expanded > .actionOverlay {
flex-direction: column;
justify-self: flex-end;
}
.event:hover > .actionOverlay {
opacity: 1;
transition: linear 0.1s;
}
/* NA */
.actionOverlay:hover {
opacity: 1;
}
@@ -1,111 +0,0 @@
import { memo } from 'react';
import { VStack } from '@chakra-ui/react';
import { FiMoreVertical } from '@react-icons/all-files/fi/FiMoreVertical';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import PropTypes from 'prop-types';
import ActionButtons from '../../../common/components/buttons/ActionButtons';
import PublicIconBtn from '../../../common/components/buttons/PublicIconBtn';
import TooltipLoadingActionBtn from '../../../common/components/buttons/TooltipLoadingActionBtn';
import EventTimesVertical from '../../../common/components/eventTimes/EventTimesVertical';
import EditableText from '../../../common/components/input/EditableText';
import style from './EventBlock.module.scss';
const areEqual = (prevProps, nextProps) => {
return (
prevProps.provided === nextProps.provided &&
prevProps.data.revision === nextProps.data.revision &&
prevProps.next === nextProps.next &&
prevProps.delay === nextProps.delay &&
prevProps.delayValue === nextProps.delayValue &&
prevProps.previousEnd === nextProps.previousEnd
);
};
function ExpandedBlock(props) {
const { provided, data, eventIndex, next, delay, delayValue, previousEnd, actionHandler } = props;
const oscid = data?.id || '...';
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && <span className={style.delayValue}>{delayValue}</span>}
</div>
<div className={style.timeExpanded}>
<EventTimesVertical
actionHandler={actionHandler}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
</div>
<div className={style.titleContainer}>
<EditableText
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
<EditableText
label='Presenter'
defaultValue={data.presenter}
placeholder='Add Presenter name'
submitHandler={(v) => actionHandler('update', { field: 'presenter', value: v })}
/>
<EditableText
label='Subtitle'
defaultValue={data.subtitle}
placeholder='Add Subtitle'
submitHandler={(v) => actionHandler('update', { field: 'subtitle', value: v })}
/>
<EditableText
label='Note'
defaultValue={data.note}
placeholder='Add Note'
style={{ color: '#d69e2e' }}
maxchar={160}
submitHandler={(v) => actionHandler('update', { field: 'note', value: v })}
/>
<span className={style.oscLabel}>
{`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
</span>
</div>
<VStack spacing='0.5em' className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
<TooltipLoadingActionBtn
clickHandler={() => actionHandler('delete')}
icon={<IoRemove />}
colorScheme='red'
tooltip='Delete'
_hover={{ bg: 'red.400' }}
/>
</VStack>
</>
);
}
ExpandedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
eventIndex: PropTypes.number.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.number,
delayValue: PropTypes.string,
previousEnd: PropTypes.number,
actionHandler: PropTypes.func.isRequired,
};
export default memo(ExpandedBlock, areEqual);
@@ -0,0 +1,36 @@
$block-gap: 4px;
$element-spacing: 4px;
$binder-width: 32px;
$clearance: 8px;
$block-border-radius: 3px;
@mixin block-spacing() {
margin: 4px 1px;
padding: 4px 10px 4px 2px;
gap: 2px;
}
@mixin action-overlay() {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: center;
opacity: 0.8;
transition: linear 0.1s;
}
@mixin block-hover() {
opacity: 1;
transition: linear 0.1s;
}
@mixin drag-style() {
font-size: 20px;
text-align: center;
opacity: 0.1;
cursor: grab;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
}
@@ -1,7 +1,7 @@
import { Draggable } from 'react-beautiful-dnd';
import { HStack } from '@chakra-ui/react';
import { FiMoreVertical } from '@react-icons/all-files/fi/FiMoreVertical';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import PropTypes from 'prop-types';
import ActionButtons from '../../../common/components/buttons/ActionButtons';
@@ -15,15 +15,11 @@ export default function BlockBlock(props) {
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div
className={style.block}
{...provided.draggableProps}
ref={provided.innerRef}
>
<div className={style.block} {...provided.draggableProps} ref={provided.innerRef}>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<IoReorderTwo />
</span>
<HStack spacing='0.5em' className={style.actionOverlay}>
<HStack spacing='4px' className={style.actionOverlay}>
<TooltipLoadingActionBtn
clickHandler={() => actionHandler('delete')}
icon={<IoRemove />}
@@ -39,7 +35,6 @@ export default function BlockBlock(props) {
);
}
BlockBlock.propTypes = {
index: PropTypes.number.isRequired,
data: PropTypes.object.isRequired,
@@ -0,0 +1,42 @@
@use '../../../theme/main' as *;
@use '../blockMixins' as *;
/* ============= COMMON ============= */
.block {
@include block-spacing;
display: grid;
grid-template-columns: 40px 1fr;
align-items: center;
height: 40px;
border-radius: 2px 2px $block-border-radius $block-border-radius;
background-color: rgba(107, 70, 193, 0.4);
background: linear-gradient(
0deg,
rgba(107, 70, 193, 0.4) 0%,
rgba(128, 90, 213, 0.4) 20%,
rgba(107, 70, 193, 0.15) 21%
);
}
/* ================ DRAG ================ */
.drag {
@include drag-style;
}
/* ============== ACTION ================ */
.actionOverlay {
@include action-overlay;
justify-self: end;
}
.block:hover > .actionOverlay {
@include block-hover;
}
/* NA */
.actionOverlay:hover {
opacity: 1;
}
@@ -1,45 +1,68 @@
import { useCallback } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { HStack } from '@chakra-ui/react';
import { Button, HStack } from '@chakra-ui/react';
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
import { FiMoreVertical } from '@react-icons/all-files/fi/FiMoreVertical';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import ActionButtons from 'common/components/buttons/ActionButtons';
import TooltipLoadingActionBtn from 'common/components/buttons/TooltipLoadingActionBtn';
import DelayInput from 'common/components/input/DelayInput';
import { useEventAction } from 'common/hooks/useEventAction';
import { millisToMinutes } from 'common/utils/dateConfig';
import PropTypes from 'prop-types';
import ActionButtons from '../../../common/components/buttons/ActionButtons';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import TooltipLoadingActionBtn from '../../../common/components/buttons/TooltipLoadingActionBtn';
import style from './DelayBlock.module.scss';
export default function DelayBlock(props) {
const { eventsHandler, data, index, actionHandler } = props;
const { data, index, actionHandler } = props;
const { applyDelay, deleteEvent, updateEvent } = useEventAction();
const applyDelayHandler = useCallback(() => {
eventsHandler('applyDelay', { id: data.id, duration: data.duration });
}, [data.duration, data.id, eventsHandler]);
applyDelay(data.id);
}, [data.id, applyDelay]);
const deleteHandler = useCallback(() => {
deleteEvent(data.id);
}, [data.id, deleteEvent]);
const delaySubmitHandler = useCallback(
(value) => {
const newEvent = {
id: data.id,
duration: value * 60000,
};
updateEvent(newEvent);
},
[data.id, updateEvent]
);
const delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div className={style.delay} {...provided.draggableProps} ref={provided.innerRef}>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<IoReorderTwo />
</span>
<DelayInput className={style.input} value={delayValue} actionHandler={actionHandler} />
<HStack spacing='0.5em' className={style.actionOverlay}>
<TooltipActionBtn
clickHandler={applyDelayHandler}
icon={<FiCheck />}
<DelayInput
className={style.input}
value={delayValue}
submitHandler={delaySubmitHandler}
/>
<HStack spacing='4px' className={style.actionOverlay}>
<Button
onClick={applyDelayHandler}
size='xs'
colorScheme='orange'
tooltip='Apply delays'
_hover={{ bg: 'orange.400' }}
/>
leftIcon={<FiCheck />}
>
Apply delay
</Button>
<TooltipLoadingActionBtn
clickHandler={() => actionHandler('delete')}
clickHandler={deleteHandler}
icon={<IoRemove />}
colorScheme='red'
tooltip='Delete'
@@ -54,7 +77,6 @@ export default function DelayBlock(props) {
}
DelayBlock.propTypes = {
eventsHandler: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
@@ -0,0 +1,51 @@
@use '../../../theme/main' as *;
@use '../blockMixins' as *;
/* ============= COMMON ============= */
.delay {
@include block-spacing;
display: grid;
grid-template-columns: 32px 1fr auto;
grid-template-areas: 'drag inpt btns';
align-items: center;
height: 40px;
border-radius: $block-border-radius $block-border-radius 2px 2px;
background-color: rgba(214, 158, 46, 0.4);
background: linear-gradient(
180deg,
rgba(214, 158, 46, 0.4) 0%,
rgba(214, 158, 46, 0.4) 20%,
rgba(214, 158, 46, 0.17) 21%
);
}
/* ================ DRAG ================ */
.drag {
grid-area: drag;
@include drag-style;
}
/* =============== INPUT ================ */
.input {
grid-area: inpt;
}
/* ============== ACTION ================ */
.actionOverlay {
@include action-overlay;
}
.delay:hover > .actionOverlay {
@include block-hover;
}
/* NA */
.actionOverlay:hover {
opacity: 1;
}
@@ -1,31 +1,62 @@
import { useEffect, useState } from 'react';
import { Checkbox } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/tooltip';
import { useCallback, useContext, useEffect, useState } from 'react';
import { Checkbox, Tooltip } from '@chakra-ui/react';
import { defaultPublicAtom, startTimeIsLastEndAtom } from 'common/atoms/LocalEventSettings';
import { LoggingContext } from 'common/context/LoggingContext';
import { useEventAction } from 'common/hooks/useEventAction';
import { EventTypes } from 'common/models/EventTypes';
import { useAtomValue } from 'jotai';
import PropTypes from 'prop-types';
import {
defaultPublicAtom,
startTimeIsLastEndAtom,
} from '../../../common/atoms/LocalEventSettings';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './EntryBlock.module.scss';
export default function EntryBlock(props) {
interface EntryBlockProps {
showKbd: boolean;
previousId?: string;
visible?: boolean;
disableAddDelay?: boolean;
disableAddBlock: boolean;
}
export default function EntryBlock(props: EntryBlockProps) {
const {
showKbd,
previousId,
eventsHandler,
visible,
visible = true,
disableAddDelay = true,
disableAddBlock,
} = props;
const { addEvent } = useEventAction();
const { emitError } = useContext(LoggingContext);
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
const defaultPublic = useAtomValue(defaultPublicAtom);
const [doStartTime, setStartTime] = useState(startTimeIsLastEnd);
const [doPublic, setPublic] = useState(defaultPublic);
const handleCreateEvent = useCallback((eventType: EventTypes) => {
switch (eventType) {
case 'event': {
const newEvent = { type: 'event', after: previousId, isPublic: doPublic };
const options = { startIsLastEnd: doStartTime ? previousId : undefined };
addEvent(newEvent, options);
break;
}
case 'delay': {
addEvent({ type: 'delay', after: previousId });
break;
}
case 'block': {
addEvent({ type: 'block', after: previousId });
break;
}
default: {
emitError(`Cannot create unknown event type: ${eventType}`);
break;
}
}
}, [addEvent, doPublic, doStartTime, emitError, previousId]);
useEffect(() => {
setStartTime(startTimeIsLastEnd);
}, [startTimeIsLastEnd]);
@@ -39,13 +70,7 @@ export default function EntryBlock(props) {
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
<span
className={style.createEvent}
onClick={() =>
eventsHandler(
'add',
{ type: 'event', after: previousId, isPublic: doPublic },
{ startIsLastEnd: doStartTime ? previousId : undefined }
)
}
onClick={() => handleCreateEvent('event')}
role='button'
>
E{showKbd && <span className={style.keyboard}>Alt + E</span>}
@@ -54,7 +79,7 @@ export default function EntryBlock(props) {
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
<span
className={`${style.createDelay} ${disableAddDelay ? style.disabled : ''}`}
onClick={() => eventsHandler('add', { type: 'delay', after: previousId })}
onClick={() => handleCreateEvent('delay')}
role='button'
>
D{showKbd && <span className={style.keyboard}>Alt + D</span>}
@@ -63,7 +88,7 @@ export default function EntryBlock(props) {
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
<span
className={`${style.createBlock} ${disableAddBlock ? style.disabled : ''}`}
onClick={() => eventsHandler('add', { type: 'block', after: previousId })}
onClick={() => handleCreateEvent('block')}
role='button'
>
B{showKbd && <span className={style.keyboard}>Alt + B</span>}
@@ -74,9 +99,7 @@ export default function EntryBlock(props) {
size='sm'
colorScheme='blue'
isChecked={doStartTime}
onChange={(e) => {
setStartTime(e.target.checked);
}}
onChange={(e) => setStartTime(e.target.checked)}
>
Start time is last end
</Checkbox>
@@ -92,12 +115,3 @@ export default function EntryBlock(props) {
</div>
);
}
EntryBlock.propTypes = {
showKbd: PropTypes.bool,
eventsHandler: PropTypes.func,
visible: PropTypes.bool,
previousId: PropTypes.string,
disableAddDelay: PropTypes.bool,
disableAddBlock: PropTypes.bool,
};

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