From ce80683496b309bb2e5aab908622ffd32b5158f5 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 15 May 2021 23:03:27 +0200 Subject: [PATCH 01/55] style: update logo --- client/public/logo192.png | Bin 11675 -> 11285 bytes client/public/logo512.png | Bin 49436 -> 48091 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/client/public/logo192.png b/client/public/logo192.png index 85aa1a0c0c52836cd9f8893fcac4bdc5e8c5a030..7508eedfaaa8438f50e80a63dfe045b7f0d29c04 100644 GIT binary patch literal 11285 zcmV+wEb7yVP)FEK~#7F?R|TU z9o3oV@6`39{pyFY8E`jC+nxm6><(sTLVKkRG#iX&Hi>ZXNSm?RNZ#J``p|0Q&(5rs#EXp@%z5>D1`zOg4Wj7MxOB$1Od-)ris@zhGE#Klxo#Q z8}*cjUOj>KDf686wK1Ypp6mO(}pD{4XOG`~nk?np1?DP=JE!OgU;s5lX0XtXQ$)%M`S|K#PAuQymIS z1-SXgx~8Tkb>_^Oy{KYTtq0KQ%q^($n%WvbnY_W5sJ&iJ9Txm`DBu&Sk``4J z1tPk$iCiyR8m}9q%}_+NXz}93XNeZ=N0p!|JOF2R=;3ZDDzT~tRpSl3_W&gO+!Xm0 z68>9JpjuEh-oRTApt(Nh^uLG#)r@DfH}JBtotFxrE3{rkk>RNoaHi-UA1P4)&#-dk z%7;k!_n{E}S^^uiee`ZOz%zIi18}7l&7MI~p;e3EcgmG#ptxWUJb@P!sL1WlIB?&C zjXuN$)2;vo-=DGYA}*L#1t^4n#$(!*VA_IUlKs_1ush=s89Qi%?SHQ!HUq`-4iKBPfrhy zAMZg|*P&=#S63Hm6;rJQQ>Fld^VbAe$k(pD7j12~;?6tow5AyS4;?A-a|NI9w;Q>Fxyt^n1Z z>TGYn6Myz+pTWKN-s_#vR|3-{R)X!@zgx}9z)En(WT!|cRROA1_}SWT-1z6%u;HPq z5&BACWq9`4zm7aZ6$aOnUQaM-3ZR=eR8^jzt^NA-pNXbggYgQRH~)3D#S3Na2_~i4 z#H1;}iWMuqiz;c`!-BtQ(-)%oOaN1(DpQ0VM~)ns6st5TtwD9G&WGVSnCd+B)Dw98 z@xPq%EY5VIt!-75!D?uzua+3$*2!u`I^iCGsLux7veRp9-1rb4dE^UK&+*3#rbb`+ z%2z2y*ahD)8Fd5`ssK9ZW!~>?x|36xPe1)vQU1O_iYlQ+6P_8GFa@ZFpqH)wV~_n; z6vzjAgNGmfg6}oW6B-mxSPZau@#3!{9-h!UFxTI*<^STr2S1AfmBG)6pOI4VIX2)= z⪙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 LNkvXXu0mjfKS7iO literal 11675 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%0A-XgNi4oKSq3=a# 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 From a6d78470ad87d7bc36ea4b2eb79ffe275f2d520d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 15 May 2021 23:04:28 +0200 Subject: [PATCH 02/55] Update .gitignore - Ignore electron database - Ignore dist files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 1bddd3553..db47040fd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules/ # production build/ +dist/ # misc .DS_Store @@ -32,3 +33,4 @@ server/db.json yarn.lock package.json package-lock.json +db.json From 764bd52663249458587fbf4e7a0354e1ab379226 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 15 May 2021 23:13:05 +0200 Subject: [PATCH 03/55] bugfix: prevent crash when array is undefined --- server/classes/EventTimer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index dd9a3cecf..38b7d762b 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -342,6 +342,8 @@ class EventTimer extends Timer { } setupWithEventList(eventlist) { + if (!Array.isArray(eventlist) || eventlist.length < 1) return; + // filter only events const events = eventlist.filter((e) => e.type === 'event'); const numEvents = events.length; From f38823e07755a42e8eed24e300088184e64e3971 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 15 May 2021 23:36:06 +0200 Subject: [PATCH 04/55] bugfixes: delay - mismatch of event definition caused duration not to apply when event was delayed - delays now have revision --- .../components/eventTimes/EventTimesVertical.jsx | 12 +++--------- server/data/eventsDefinition.js | 1 + 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/client/src/common/components/eventTimes/EventTimesVertical.jsx b/client/src/common/components/eventTimes/EventTimesVertical.jsx index 2944de8aa..244054503 100644 --- a/client/src/common/components/eventTimes/EventTimesVertical.jsx +++ b/client/src/common/components/eventTimes/EventTimesVertical.jsx @@ -8,14 +8,8 @@ const label = { }; const TimesDelayed = (props) => { - const { - handleValidate, - actionHandler, - delay, - timeStart, - timeEnd, - duration, - } = props; + const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration } = + props; const scheduledStart = stringFromMillis(timeStart, false); const scheduledEnd = stringFromMillis(timeEnd, false); @@ -44,7 +38,7 @@ const TimesDelayed = (props) => { /> Duration Date: Sat, 15 May 2021 23:50:44 +0200 Subject: [PATCH 05/55] feat: osc goto - renamed previous goto to gotoid --- server/classes/EventTimer.js | 11 ++++++----- server/controllers/OscController.js | 12 +++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 38b7d762b..7692f039c 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -461,18 +461,15 @@ class EventTimer extends Timer { if (eventIndex === -1) return; this.pause(); - this.loadEvent(eventIndex); - - this.broadcastState(); + this.loadEvent(eventIndex, true, true); } // Loads a given event // load timers // load selectedEventIndex // load titles - loadEvent(eventIndex, type = 'load') { + loadEvent(eventIndex, type = 'load', broadcastChange = 'false') { const e = this._eventlist[eventIndex]; - if (e == null) return; const start = e.timeStart == null || e.timeStart === '' ? 0 : e.timeStart; @@ -503,6 +500,10 @@ class EventTimer extends Timer { // look for event after this._loadTitlesNext(); + + if (broadcastChange) + // broadcast current state + this.broadcastState(); } _loadTitlesNow() { diff --git a/server/controllers/OscController.js b/server/controllers/OscController.js index 5c46993d6..288e5ee5e 100644 --- a/server/controllers/OscController.js +++ b/server/controllers/OscController.js @@ -20,7 +20,7 @@ const initiateOSC = (config) => { if (address !== 'ontime') return; // get second part (command) - switch (path) { + switch (path.toLocaleLowerCase()) { case 'start': case 'play': console.log('calling play'); @@ -60,6 +60,16 @@ const initiateOSC = (config) => { break; case 'goto': console.log('calling goto with', args); + try { + let eventIndex = parseInt(args); + if (isNaN(eventIndex) || eventIndex <= 0) return; + global.timer.loadEvent(eventIndex - 1, undefined, true); + } catch (error) { + console.log('error calling goto: ', error); + } + break; + case 'gotoid': + console.log('calling gotoid with', args); try { global.timer.loadEventById(args.toLowerCase()); } catch (error) { From 937106d49ccc7361a2ab1a56e967d87a832bebf2 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 15 May 2021 23:52:53 +0200 Subject: [PATCH 06/55] bugfix: on prev <> next skipping through events while playing would cause false data in clock --- server/classes/EventTimer.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 7692f039c..ae47ba885 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -734,14 +734,15 @@ class EventTimer extends Timer { this.loadEvent(0); return; } + + // change playstate + this.pause(); + const gotoEvent = this.selectedEventIndex > 0 ? this.selectedEventIndex - 1 : 0; if (gotoEvent === this.selectedEventIndex) return; this.loadEvent(gotoEvent); - - // change playstate - this.pause(); } next() { @@ -754,6 +755,9 @@ class EventTimer extends Timer { return; } + // change playstate + this.pause(); + const gotoEvent = this.selectedEventIndex < this.numEvents - 1 ? this.selectedEventIndex + 1 @@ -761,9 +765,6 @@ class EventTimer extends Timer { if (gotoEvent === this.selectedEventIndex) return; this.loadEvent(gotoEvent); - - // change playstate - this.pause(); } unload() { From 1980e0e779029fa1c038a7da8e00c826693cf783 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sun, 16 May 2021 12:59:47 +0200 Subject: [PATCH 07/55] mockup menu actions add buttons for upcoming actions - collapse all - expand all - goto selected --- .../common/components/buttons/CollapseBtn.jsx | 18 ++++++++++++++++++ .../components/buttons/CollapseIconBtn.jsx | 17 ----------------- .../common/components/buttons/CurrentBtn.jsx | 18 ++++++++++++++++++ .../common/components/buttons/ExpandBtn.jsx | 18 ++++++++++++++++++ client/src/features/menu/EventListMenu.jsx | 6 ++++++ 5 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 client/src/common/components/buttons/CollapseBtn.jsx delete mode 100644 client/src/common/components/buttons/CollapseIconBtn.jsx create mode 100644 client/src/common/components/buttons/CurrentBtn.jsx create mode 100644 client/src/common/components/buttons/ExpandBtn.jsx diff --git a/client/src/common/components/buttons/CollapseBtn.jsx b/client/src/common/components/buttons/CollapseBtn.jsx new file mode 100644 index 000000000..5e98ef4d1 --- /dev/null +++ b/client/src/common/components/buttons/CollapseBtn.jsx @@ -0,0 +1,18 @@ +import { Button } from '@chakra-ui/button'; +import { FiChevronsUp } from 'react-icons/fi'; + +export default function CollapseBtn(props) { + const { clickhandler } = props; + return ( + + ); +} diff --git a/client/src/common/components/buttons/CollapseIconBtn.jsx b/client/src/common/components/buttons/CollapseIconBtn.jsx deleted file mode 100644 index 830e038dd..000000000 --- a/client/src/common/components/buttons/CollapseIconBtn.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { IconButton } from '@chakra-ui/button'; -import { FiArrowDownCircle } from 'react-icons/fi'; - -export default function CollapseIconBtn(props) { - const { clickhandler, active, ...rest } = props; - return ( - } - colorScheme='orange' - variant={active ? 'solid' : 'outline'} - onClick={clickhandler} - _focus={{ boxShadow: 'none' }} - {...rest} - /> - ); -} diff --git a/client/src/common/components/buttons/CurrentBtn.jsx b/client/src/common/components/buttons/CurrentBtn.jsx new file mode 100644 index 000000000..2bea19028 --- /dev/null +++ b/client/src/common/components/buttons/CurrentBtn.jsx @@ -0,0 +1,18 @@ +import { Button } from '@chakra-ui/button'; +import { FiTarget } from 'react-icons/fi'; + +export default function CurrentBtn(props) { + const { clickhandler } = props; + return ( + + ); +} diff --git a/client/src/common/components/buttons/ExpandBtn.jsx b/client/src/common/components/buttons/ExpandBtn.jsx new file mode 100644 index 000000000..0ad8a9296 --- /dev/null +++ b/client/src/common/components/buttons/ExpandBtn.jsx @@ -0,0 +1,18 @@ +import { Button } from '@chakra-ui/button'; +import { FiChevronsDown } from 'react-icons/fi'; + +export default function ExpandBtn(props) { + const { clickhandler } = props; + return ( + + ); +} diff --git a/client/src/features/menu/EventListMenu.jsx b/client/src/features/menu/EventListMenu.jsx index 9f8a85f07..988a0748c 100644 --- a/client/src/features/menu/EventListMenu.jsx +++ b/client/src/features/menu/EventListMenu.jsx @@ -10,6 +10,9 @@ import { } from '@chakra-ui/react'; import style from './EventListMenu.module.css'; import MenuActionButtons from '../editors/list/MenuActionButtons'; +import CollapseBtn from 'common/components/buttons/CollapseBtn'; +import ExpandBtn from 'common/components/buttons/ExpandBtn'; +import CurrentBtn from 'common/components/buttons/CurrentBtn'; const EventListMenu = ({ eventsHandler }) => { const buttonProps = { @@ -69,6 +72,9 @@ const EventListMenu = ({ eventsHandler }) => { Download CSV + + + ); From a3608992e872a90a96fb9a1fb102352f85cc89ad Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sun, 16 May 2021 13:43:38 +0200 Subject: [PATCH 08/55] bugfix copy-paste bug --- client/src/features/control/MessageControl.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/control/MessageControl.jsx b/client/src/features/control/MessageControl.jsx index 9d66ad89a..e881cb5f1 100644 --- a/client/src/features/control/MessageControl.jsx +++ b/client/src/features/control/MessageControl.jsx @@ -129,7 +129,7 @@ export default function MessageControl() { text={lower.text} visible={lower.visible} changeHandler={(event) => messageControl('lower-text', event)} - actionHandler={() => messageControl('toggle-publ-visible')} + actionHandler={() => messageControl('toggle-lower-visible')} /> ); From b42710a9720a8f8e387341e6475b07c7483d1e6e Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sun, 16 May 2021 22:26:03 +0200 Subject: [PATCH 09/55] hotfix: no loader Lower third cannot have loader, dont seem to find a way to separe the fallback component for that one route. Disable loaders for now, will need revision --- client/src/App.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 1d009bcba..8ef8295f6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,8 +4,6 @@ import './App.css'; import { QueryClient, QueryClientProvider } from 'react-query'; import SocketProvider from 'app/context/socketContext'; import withSocket from 'features/viewers/ViewWrapper'; -import { ReactQueryDevtools } from 'react-query/devtools'; -import Empty from 'common/state/Empty'; const Editor = lazy(() => import('features/editors/Editor')); const PresenterView = lazy(() => @@ -42,7 +40,7 @@ function App() {
- }> + @@ -50,12 +48,12 @@ function App() { - + {/* Lower cannot have fallback */} + {/* Send to default if nothing found */} -
From 8162c622a681c31c3813138d2578cb446a3473f2 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sun, 16 May 2021 22:41:43 +0200 Subject: [PATCH 10/55] refract: cleanup server call constants --- client/src/app/api/apiConstants.js | 5 ++ client/src/app/api/eventApi.js | 5 +- client/src/app/api/eventsApi.js | 11 +--- client/src/app/api/playbackApi.js | 5 +- client/src/app/context/socketContext.js | 8 +-- .../editors/list/EventListWrapper.jsx | 58 +++++++++---------- client/src/features/modals/SettingsModal.jsx | 5 +- client/src/features/viewers/ViewWrapper.jsx | 9 +-- 8 files changed, 46 insertions(+), 60 deletions(-) diff --git a/client/src/app/api/apiConstants.js b/client/src/app/api/apiConstants.js index 26f818293..33a92277f 100644 --- a/client/src/app/api/apiConstants.js +++ b/client/src/app/api/apiConstants.js @@ -1,7 +1,12 @@ export const NODE_PORT = 4001; +export const EVENT_TABLE = 'event'; +export const EVENTS_TABLE = 'events'; const calculateServer = () => { return window.location.origin.replace(window.location.port, `${NODE_PORT}/`); }; export const serverURL = calculateServer(); +export const eventURL = serverURL + EVENT_TABLE; +export const eventsURL = serverURL + EVENTS_TABLE; +export const playbackURL = serverURL + 'playback'; diff --git a/client/src/app/api/eventApi.js b/client/src/app/api/eventApi.js index 0118acb68..65982241e 100644 --- a/client/src/app/api/eventApi.js +++ b/client/src/app/api/eventApi.js @@ -1,8 +1,5 @@ import axios from 'axios'; -import { serverURL } from './apiConstants'; - -export const eventNamespace = 'event'; -export const eventURL = serverURL + eventNamespace; +import { eventURL } from './apiConstants'; export const fetchEvent = async () => { const res = await axios.get(eventURL); diff --git a/client/src/app/api/eventsApi.js b/client/src/app/api/eventsApi.js index ec19409be..f9b30c035 100644 --- a/client/src/app/api/eventsApi.js +++ b/client/src/app/api/eventsApi.js @@ -1,14 +1,5 @@ import axios from 'axios'; -import { NODE_PORT } from '../api/apiConstants'; - -// get origin from URL -const serverURL = window.location.origin.replace( - window.location.port, - `${NODE_PORT}/` -); - -export const eventsNamespace = 'events'; -export const eventsURL = serverURL + eventsNamespace; +import { eventsURL } from '../api/apiConstants'; export const fetchAllEvents = async () => { const res = await axios.get(eventsURL); diff --git a/client/src/app/api/playbackApi.js b/client/src/app/api/playbackApi.js index 1dabea106..0145da7f5 100644 --- a/client/src/app/api/playbackApi.js +++ b/client/src/app/api/playbackApi.js @@ -1,8 +1,5 @@ import axios from 'axios'; -import { serverURL } from '../api/apiConstants'; - -export const playbackNamespace = 'playback'; -const playbackURL = serverURL + playbackNamespace; +import { playbackURL } from '../api/apiConstants'; export const getStart = async () => { const res = await axios.get(playbackURL + '/start'); diff --git a/client/src/app/context/socketContext.js b/client/src/app/context/socketContext.js index 6767173ed..ecfb9a99e 100644 --- a/client/src/app/context/socketContext.js +++ b/client/src/app/context/socketContext.js @@ -1,12 +1,6 @@ import { createContext, useContext, useEffect, useState } from 'react'; import io from 'socket.io-client'; -import { NODE_PORT } from '../api/apiConstants'; - -// get origin from URL -const serverURL = window.location.origin.replace( - window.location.port, - `${NODE_PORT}/` -); +import { serverURL } from 'app/api/apiConstants'; const SocketContext = createContext([[], () => {}]); diff --git a/client/src/features/editors/list/EventListWrapper.jsx b/client/src/features/editors/list/EventListWrapper.jsx index a61c6f44f..b63fdbac2 100644 --- a/client/src/features/editors/list/EventListWrapper.jsx +++ b/client/src/features/editors/list/EventListWrapper.jsx @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from 'react-query'; import { useCallback, useEffect } from 'react'; import { - eventsNamespace, fetchAllEvents, requestPatch, requestPost, @@ -15,11 +14,12 @@ import EventListMenu from 'features/menu/EventListMenu.jsx'; import { showErrorToast } from 'common/helpers/toastManager'; import { useFetch } from 'app/hooks/useFetch.js'; import Empty from 'common/state/Empty'; +import { EVENTS_TABLE } from 'app/api/apiConstants'; export default function EventListWrapper() { const queryClient = useQueryClient(); const { data, status, isError, refetch } = useFetch( - eventsNamespace, + EVENTS_TABLE, fetchAllEvents ); @@ -27,14 +27,14 @@ export default function EventListWrapper() { // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries - queryClient.cancelQueries(eventsNamespace, { exact: true }); + queryClient.cancelQueries(EVENTS_TABLE, { exact: true }); // Snapshot the previous value - let previousEvents = queryClient.getQueryData(eventsNamespace); + let previousEvents = queryClient.getQueryData(EVENTS_TABLE); console.log('debug', previousEvents); if (previousEvents == null) { refetch(); - previousEvents = queryClient.getQueryData(eventsNamespace); + previousEvents = queryClient.getQueryData(EVENTS_TABLE); } console.log('debug 2', previousEvents); @@ -44,7 +44,7 @@ export default function EventListWrapper() { ...newEvent, id: new Date().toISOString(), }); - queryClient.setQueryData(eventsNamespace, optimistic); + queryClient.setQueryData(EVENTS_TABLE, optimistic); // Return a context with the previous and new todo return { previousEvents }; @@ -52,12 +52,12 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, newEvent, context) => { - queryClient.setQueryData(eventsNamespace, context.previousEvents); + queryClient.setQueryData(EVENTS_TABLE, context.previousEvents); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { - queryClient.invalidateQueries(eventsNamespace); + queryClient.invalidateQueries(EVENTS_TABLE); }, }); @@ -65,16 +65,16 @@ export default function EventListWrapper() { // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries - queryClient.cancelQueries([eventsNamespace, newEvent.id]); + queryClient.cancelQueries([EVENTS_TABLE, newEvent.id]); // Snapshot the previous value const previousEvent = queryClient.getQueryData([ - eventsNamespace, + EVENTS_TABLE, newEvent.id, ]); // optimistically update object - queryClient.setQueryData([eventsNamespace, newEvent.id], newEvent); + queryClient.setQueryData([EVENTS_TABLE, newEvent.id], newEvent); // Return a context with the previous and new todo return { previousEvent, newEvent }; @@ -83,14 +83,14 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, newEvent, context) => { queryClient.setQueryData( - [eventsNamespace, context.newEvent.id], + [EVENTS_TABLE, context.newEvent.id], context.previousEvent ); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: (newEvent) => { - queryClient.invalidateQueries([eventsNamespace, newEvent.id]); + queryClient.invalidateQueries([EVENTS_TABLE, newEvent.id]); }, }); @@ -98,16 +98,16 @@ export default function EventListWrapper() { // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries - queryClient.cancelQueries([eventsNamespace, newEvent.id]); + queryClient.cancelQueries([EVENTS_TABLE, newEvent.id]); // Snapshot the previous value const previousEvent = queryClient.getQueryData([ - eventsNamespace, + EVENTS_TABLE, newEvent.id, ]); // optimistically update object - queryClient.setQueryData([eventsNamespace, newEvent.id], newEvent); + queryClient.setQueryData([EVENTS_TABLE, newEvent.id], newEvent); // Return a context with the previous and new todo return { previousEvent, newEvent }; @@ -116,14 +116,14 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, newEvent, context) => { queryClient.setQueryData( - [eventsNamespace, context.newEvent.id], + [EVENTS_TABLE, context.newEvent.id], context.previousEvent ); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: (newEvent) => { - queryClient.invalidateQueries([eventsNamespace, newEvent.id]); + queryClient.invalidateQueries([EVENTS_TABLE, newEvent.id]); }, }); @@ -131,16 +131,16 @@ export default function EventListWrapper() { // we optimistically update here onMutate: async (eventId) => { // cancel ongoing queries - queryClient.cancelQueries([eventsNamespace, eventId]); + queryClient.cancelQueries([EVENTS_TABLE, eventId]); // Snapshot the previous value - const previousEvents = queryClient.getQueryData(eventsNamespace); + const previousEvents = queryClient.getQueryData(EVENTS_TABLE); let filtered = [...previousEvents]; filtered.filter((e) => e.id === 'eventId'); // optimistically update object - queryClient.setQueryData(eventsNamespace, filtered); + queryClient.setQueryData(EVENTS_TABLE, filtered); // Return a context with the previous and new todo return { previousEvents }; @@ -148,19 +148,19 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, eventId, context) => { - queryClient.setQueryData(eventsNamespace, context.previousEvents); + queryClient.setQueryData(EVENTS_TABLE, context.previousEvents); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { - queryClient.invalidateQueries(eventsNamespace); + queryClient.invalidateQueries(EVENTS_TABLE); }, }); const applyDelay = useMutation(requestApplyDelay, { // Mutation finished, failed or successful onSettled: () => { - queryClient.invalidateQueries(eventsNamespace); + queryClient.invalidateQueries(EVENTS_TABLE); }, }); @@ -168,17 +168,17 @@ export default function EventListWrapper() { // we optimistically update here onMutate: async (data) => { // cancel ongoing queries - queryClient.cancelQueries(eventsNamespace, { exact: true }); + queryClient.cancelQueries(EVENTS_TABLE, { exact: true }); // Snapshot the previous value - const previousEvents = queryClient.getQueryData(eventsNamespace); + 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(eventsNamespace, e); + queryClient.setQueryData(EVENTS_TABLE, e); // Return a context with the previous and new todo return { previousEvents }; @@ -186,12 +186,12 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, eventId, context) => { - queryClient.setQueryData(eventsNamespace, context.previousEvents); + queryClient.setQueryData(EVENTS_TABLE, context.previousEvents); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { - queryClient.invalidateQueries(eventsNamespace); + queryClient.invalidateQueries(EVENTS_TABLE); }, }); diff --git a/client/src/features/modals/SettingsModal.jsx b/client/src/features/modals/SettingsModal.jsx index 401168e5f..7d4c507a0 100644 --- a/client/src/features/modals/SettingsModal.jsx +++ b/client/src/features/modals/SettingsModal.jsx @@ -14,12 +14,13 @@ import { Button, Textarea, } from '@chakra-ui/react'; -import { fetchEvent, postEvent, eventNamespace } from 'app/api/eventApi'; +import { fetchEvent, postEvent } from 'app/api/eventApi'; import { useEffect, useState } from 'react'; import { useFetch } from 'app/hooks/useFetch'; +import { EVENT_TABLE } from 'app/api/apiConstants'; export default function SettingsModal(props) { - const { data, status, isError } = useFetch(eventNamespace, fetchEvent); + const { data, status, isError } = useFetch(EVENT_TABLE, fetchEvent); const [formData, setFormData] = useState({ title: '', url: '', diff --git a/client/src/features/viewers/ViewWrapper.jsx b/client/src/features/viewers/ViewWrapper.jsx index 453625511..6e198eac3 100644 --- a/client/src/features/viewers/ViewWrapper.jsx +++ b/client/src/features/viewers/ViewWrapper.jsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { eventsNamespace, fetchAllEvents } from 'app/api/eventsApi'; -import { fetchEvent, eventNamespace } from 'app/api/eventApi'; +import { fetchAllEvents } from 'app/api/eventsApi'; +import { fetchEvent } from 'app/api/eventApi'; import { useSocket } from 'app/context/socketContext'; import { stringFromMillis } from 'common/dateConfig'; import { useFetch } from 'app/hooks/useFetch'; +import { EVENTS_TABLE, EVENT_TABLE } from 'app/api/apiConstants'; const withSocket = (Component) => { const WrappedComponent = (props) => { @@ -11,12 +12,12 @@ const withSocket = (Component) => { data: eventsData, status: eventsDataStatus, isError: eventsDataIsError, - } = useFetch(eventsNamespace, fetchAllEvents); + } = useFetch(EVENTS_TABLE, fetchAllEvents); const { data: genData, status: genDataStatus, isError: genDataIsError, - } = useFetch(eventNamespace, fetchEvent); + } = useFetch(EVENT_TABLE, fetchEvent); const [publicEvents, setPublicEvents] = useState([]); const [backstageEvents, setBackstageEvents] = useState([]); From 08e0101815c6b1ad0942e676cb8af04d8b50aaaa Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sun, 16 May 2021 22:45:03 +0200 Subject: [PATCH 11/55] bugfix: reload while running reloading while running wouldnt clear paused data --- server/classes/EventTimer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index ae47ba885..960b21af5 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -779,11 +779,11 @@ class EventTimer extends Timer { } reload() { - // reload data - this.loadEvent(this.selectedEventIndex); - // reset playstate this.pause(); + + // reload data + this.loadEvent(this.selectedEventIndex); } } From 78cdcb2aa457a51373c4be28fd3c6bc4c1d51b4d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 08:31:10 +0200 Subject: [PATCH 12/55] lib: jotai --- client/package.json | 1 + client/yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/client/package.json b/client/package.json index 2411db248..886e0b989 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "axios": "^0.21.1", "date-fns": "^2.20.1", "framer-motion": "^4.1.6", + "jotai": "^0.16.5", "react": "^17.0.1", "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.1", diff --git a/client/yarn.lock b/client/yarn.lock index e0d883923..1062924a6 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -7575,6 +7575,11 @@ jest@26.6.0: import-local "^3.0.2" jest-cli "^26.6.0" +jotai@^0.16.5: + version "0.16.5" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-0.16.5.tgz#493bab65b69c045c508d8a3a4996fcc4d75e8228" + integrity sha512-exiddLOPp22P0c7z+X5jQEraoEdFm80BaNeuT2WTMAFNRZY2l1XmefR/PXj9JEaAMrHRQk7BKbJ51C5AddoSWA== + js-sha3@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" From 34481b57c6f2493992e0698fe0cd3674a11e220d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 08:31:34 +0200 Subject: [PATCH 13/55] fix: cache time infinite attempt fixing issues with cache becoming undefined --- client/src/app/hooks/useFetch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/hooks/useFetch.js b/client/src/app/hooks/useFetch.js index 6daf49350..3ddbdeaba 100644 --- a/client/src/app/hooks/useFetch.js +++ b/client/src/app/hooks/useFetch.js @@ -4,7 +4,7 @@ const refetchIntervalMs = 10000; export const useFetch = (namespace, fn) => { const { data, status, isError, refetch } = useQuery(namespace, fn, { refetchInterval: refetchIntervalMs, - cacheTime: refetchIntervalMs, + cacheTime: Infinity, }); return { data, status, isError, refetch }; From c912a61b5724a3d30661bbceedba38c80743e232 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 08:34:03 +0200 Subject: [PATCH 14/55] feat: expand collapse --- client/src/app/context/collapseAtom.js | 60 +++++++++++++++++++ .../common/components/buttons/CollapseBtn.jsx | 2 +- .../common/components/buttons/ExpandBtn.jsx | 2 +- .../src/features/editors/list/EventBlock.jsx | 48 +++++++++------ .../editors/list/EventListWrapper.jsx | 14 ++++- client/src/features/menu/EventListMenu.jsx | 7 ++- 6 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 client/src/app/context/collapseAtom.js diff --git a/client/src/app/context/collapseAtom.js b/client/src/app/context/collapseAtom.js new file mode 100644 index 000000000..30d1e18de --- /dev/null +++ b/client/src/app/context/collapseAtom.js @@ -0,0 +1,60 @@ +const { atom } = require('jotai'); + +const PATH = 'option-collapse'; + +// collapse options object, initialised from local storage +// REFRACT: When do we read from local storage? +// on every render? + +export const collapseAtom = atom(JSON.parse(localStorage.getItem(PATH)) || {}); + +// get a single option, if it exists +export const SelectCollapse = (id) => { + return atom((get) => get(collapseAtom)[id]); +}; + +// change a single item in object +export const HandleCollapse = atom(null, (get, set, payload) => { + const updatedVal = { + ...get(collapseAtom), + ...payload, + }; + set(collapseAtom, updatedVal); + localStorage.setItem(PATH, JSON.stringify(updatedVal)); +}); + +// change collapsed in several items +export const BatchOperation = atom(null, (get, set, payload) => { + let prevOptions = get(collapseAtom); + + let newOptions = {}; + + for (const item of payload.items) { + newOptions[item.id] = payload.isCollapsed; + } + + if (payload.clear) { + // clear object + prevOptions = {}; + // clear localstorage + localStorage.removeItem(PATH); + } + + const options = { ...prevOptions, ...newOptions }; + + set(collapseAtom, options); + + localStorage.setItem(PATH, JSON.stringify(options)); +}); + +// change collapsed in all items +export const setAll = async (items, isCollapsed) => { + // clear storage + localStorage.removeItem(PATH); + + // clear local object + console.log('debug collapse here', isCollapsed); + + // call batch + BatchOperation({ items: items, isCollapsed: isCollapsed }); +}; diff --git a/client/src/common/components/buttons/CollapseBtn.jsx b/client/src/common/components/buttons/CollapseBtn.jsx index 5e98ef4d1..490af34bb 100644 --- a/client/src/common/components/buttons/CollapseBtn.jsx +++ b/client/src/common/components/buttons/CollapseBtn.jsx @@ -7,7 +7,7 @@ export default function CollapseBtn(props) { + + } + colorScheme='white' + variant='outline' + background='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + /> + ); } diff --git a/client/src/common/components/buttons/CurrentBtn.jsx b/client/src/common/components/buttons/CurrentBtn.jsx index 2bea19028..a15c62aa0 100644 --- a/client/src/common/components/buttons/CurrentBtn.jsx +++ b/client/src/common/components/buttons/CurrentBtn.jsx @@ -2,13 +2,13 @@ import { Button } from '@chakra-ui/button'; import { FiTarget } from 'react-icons/fi'; export default function CurrentBtn(props) { - const { clickhandler } = props; + const { clickhandler, active } = props; return ( + + } + colorScheme='white' + variant='outline' + background='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + /> + ); } diff --git a/client/src/common/components/buttons/LockIconBtn.jsx b/client/src/common/components/buttons/LockIconBtn.jsx new file mode 100644 index 000000000..d52030fd1 --- /dev/null +++ b/client/src/common/components/buttons/LockIconBtn.jsx @@ -0,0 +1,21 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiTarget } from 'react-icons/fi'; + +export default function LockIconBtn(props) { + const { clickhandler, active, ref } = props; + return ( + + } + colorScheme='pink' + color={'pink.300'} + variant={active ? 'solid' : 'outline'} + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + /> + + ); +} From f1538def9947a84b9e33ceb15a20ba5757972dcc Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 12:40:33 +0200 Subject: [PATCH 17/55] list follows cursor - add settings atom with cursor mode - in locked mode eventlist scrolls to cursor - cleanup unused menu items --- client/src/app/context/settingsAtom.js | 34 +++++++++ .../src/features/editors/list/EventList.jsx | 26 ++++++- client/src/features/menu/EventListMenu.jsx | 73 ++++++------------- 3 files changed, 80 insertions(+), 53 deletions(-) create mode 100644 client/src/app/context/settingsAtom.js diff --git a/client/src/app/context/settingsAtom.js b/client/src/app/context/settingsAtom.js new file mode 100644 index 000000000..c49055d5b --- /dev/null +++ b/client/src/app/context/settingsAtom.js @@ -0,0 +1,34 @@ +const { atom } = require('jotai'); + +const PATH = 'option-gen'; +const initialValue = { + cursor: 'locked', +}; + +export const settingsAtom = atom( + (get) => { + const storedOptions = localStorage.getItem(PATH); + if (storedOptions == null) return initialValue; + + return JSON.parse(storedOptions); + }, + (get, set, newValues) => { + set(settingsAtom, newValues); + localStorage.setItem(PATH, JSON.stringify(newValues)); + } +); + +// get a single option, if it exists +export const SelectSetting = (setting) => { + return atom((get) => get(settingsAtom)[setting]); +}; + +// change a single item in object +export const HandleOptions = atom(null, (get, set, payload) => { + const updatedVal = { + ...get(settingsAtom), + ...payload, + }; + set(settingsAtom, updatedVal); + localStorage.setItem(PATH, JSON.stringify(updatedVal)); +}); diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index 51c582c90..8b43078fa 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -1,18 +1,23 @@ import style from './List.module.css'; -import { Fragment, useEffect, useState } from 'react'; +import { createRef, Fragment, useEffect, useMemo, useState } from 'react'; import { useSocket } from 'app/context/socketContext'; import tinykeys from 'tinykeys'; import Empty from 'common/state/Empty'; import EventListItem from './EventListItem'; import { AnimatePresence, motion } from 'framer-motion'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import { useAtom } from 'jotai'; +import { SelectSetting } from 'app/context/settingsAtom'; export default function EventList(props) { const { events, eventsHandler } = props; const socket = useSocket(); const [selected, setSelected] = useState(null); const [next, setNext] = useState(null); - const [cursor, setCursor] = useState(null); + const [cursor, setCursor] = useState(0); + const [cursorSettings] = useAtom(useMemo(() => SelectSetting('cursor'), [])); + + const cursorRef = createRef(); // Handle keyboard shortcuts useEffect(() => { @@ -70,6 +75,21 @@ export default function EventList(props) { }; }, [socket]); + // attach scroll to cursor + useEffect(() => { + if (cursor == null || cursorRef.current == null) return; + + cursorRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'start', + }); + }, [cursor, cursorRef]); + + useEffect(() => { + if (cursorSettings !== 'locked') return; + }, [selected]); + if (events.length < 1) { return ; } @@ -114,6 +134,7 @@ export default function EventList(props) { {cursor === -1 && ( {cursor === index && ( { - const buttonProps = { - size: 'sm', - variant: 'outline', - colorScheme: 'whiteAlpha', - backgroundColor: '#ffffff05', - _hover: { bg: 'blue.800' }, - _expanded: { bg: 'blue.400' }, - _focus: { boxShadow: 'none' }, - }; - - const menuStyle = { - color: 'initial', - backgroundColor: 'rgba(255,255,255,0.67)', - }; + const [cursorSettings] = useAtom(useMemo(() => SelectSetting('cursor'), [])); + const [, SetOption] = useAtom(HandleOptions); const actionHandler = (action) => { switch (action) { @@ -41,6 +23,13 @@ const EventListMenu = ({ eventsHandler }) => { case 'block': eventsHandler('add', { type: action, order: 0 }); break; + case 'togglelock': + let newSet = 'locked'; + if (cursorSettings === 'locked') { + newSet = 'unlocked'; + } + SetOption({ cursor: newSet }); + break; default: break; } @@ -48,36 +37,18 @@ const EventListMenu = ({ eventsHandler }) => { return (
- - - - - - - - - Upload Excel - Upload CSV - - - - - - - - - - - Download Excel - Download CSV - - - eventsHandler('collapseall')} /> eventsHandler('expandall')} /> + + actionHandler('togglelock')} + active={cursorSettings === 'locked'} + /> +
); From 6f97e0caaaac8266d61747536b669d2953b3895d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 12:53:24 +0200 Subject: [PATCH 18/55] style: cursor --- .../src/features/editors/list/EventList.jsx | 50 +++---------------- .../src/features/editors/list/List.module.css | 18 ++++--- 2 files changed, 17 insertions(+), 51 deletions(-) diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index 8b43078fa..11a9ad78a 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -1,10 +1,9 @@ import style from './List.module.css'; -import { createRef, Fragment, useEffect, useMemo, useState } from 'react'; +import { createRef, useEffect, useMemo, useState } from 'react'; import { useSocket } from 'app/context/socketContext'; import tinykeys from 'tinykeys'; import Empty from 'common/state/Empty'; import EventListItem from './EventListItem'; -import { AnimatePresence, motion } from 'framer-motion'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { useAtom } from 'jotai'; import { SelectSetting } from 'app/context/settingsAtom'; @@ -94,22 +93,6 @@ export default function EventList(props) { return ; } - // motion - const cursorVariants = { - hidden: { - scale: 0, - }, - visible: { - scale: 1, - transition: { - duration: 0.3, - }, - }, - exit: { - scale: 0, - }, - }; - // DND const handleOnDragEnd = (result) => { // drop outside of area @@ -131,18 +114,6 @@ export default function EventList(props) { return (
- - {cursor === -1 && ( - - )} - {(provided) => ( @@ -157,7 +128,10 @@ export default function EventList(props) { cumulativeDelay += e.duration; } else if (e.type === 'block') cumulativeDelay = 0; return ( - +
- - {cursor === index && ( - - )} - - +
); })} {provided.placeholder} diff --git a/client/src/features/editors/list/List.module.css b/client/src/features/editors/list/List.module.css index e676a162c..339f84d3d 100644 --- a/client/src/features/editors/list/List.module.css +++ b/client/src/features/editors/list/List.module.css @@ -21,11 +21,15 @@ } .cursor { - width: 90%; - min-height: 2px; - background-color: #ff7597; - border-radius: 2px; - margin: 0 auto; - /* transition: 1s; - transition-property: all; */ + width: 100%; + background: linear-gradient( + 180deg, + #ff7597 0%, + #0000 10%, + #0000 90%, + #ff7597 100% + ); + border-radius: 20px; + transition: 0.3s; + transition-property: all; } From cc7b8278b8768a61a32e95d62bad9c0bb899f322 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 13:13:30 +0200 Subject: [PATCH 19/55] cursor follows playback --- client/src/features/editors/list/EventList.jsx | 18 ++++++++++++------ server/classes/EventTimer.js | 11 +++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index 11a9ad78a..3d2e874df 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -56,12 +56,13 @@ export default function EventList(props) { if (socket == null) return; // ask for playstate - socket.emit('get-selected-id'); + socket.emit('get-selected'); socket.emit('get-next-id'); // Handle playstate - socket.on('selected-id', (data) => { + socket.on('selected', (data) => { setSelected(data); + console.log('debug cursor setted', data); }); socket.on('next-id', (data) => { setNext(data); @@ -69,7 +70,7 @@ export default function EventList(props) { // Clear listener return () => { - socket.off('selected-id'); + socket.off('selected'); socket.off('next-id'); }; }, [socket]); @@ -86,8 +87,11 @@ export default function EventList(props) { }, [cursor, cursorRef]); useEffect(() => { - if (cursorSettings !== 'locked') return; - }, [selected]); + if (cursorSettings !== 'locked' || selected == null) return; + if (selected.index == null) return; + + setCursor(selected.index); + }, [selected, cursorSettings]); if (events.length < 1) { return ; @@ -112,6 +116,8 @@ export default function EventList(props) { console.log('EventList: events in event list', events); let cumulativeDelay = 0; + console.log('debug selected', selected); + return (
@@ -136,7 +142,7 @@ export default function EventList(props) { type={e.type} index={index} data={e} - selected={selected === e.id} + selected={selected?.id === e.id} next={next === e.id} eventsHandler={eventsHandler} delay={cumulativeDelay} diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 960b21af5..6059a7a18 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -90,6 +90,10 @@ class EventTimer extends Timer { broadcastState() { this.io.emit('timer', this.getObject()); this.io.emit('playstate', this.state); + this.io.emit('selected', { + id: this.selectedEventId, + index: this.selectedEventIndex, + }); this.io.emit('selected-id', this.selectedEventId); this.io.emit('next-id', this.nextEventId); this.io.emit('publicselected-id', this.selectedPublicEventId); @@ -259,6 +263,13 @@ class EventTimer extends Timer { /*******************************************/ // selection data + socket.on('get-selected', () => { + socket.emit('selected', { + id: this.selectedEventId, + index: this.selectedEventIndex, + }); + }); + socket.on('get-selected-id', () => { socket.emit('selected-id', this.selectedEventId); }); From 6ae075569676112a2e97e1216088c1af2c2ae6ed Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 20 May 2021 13:42:53 +0200 Subject: [PATCH 20/55] cleanup - style tweaks - container follows cursor --- .../src/features/editors/list/EventList.jsx | 20 +++++++++++-------- .../src/features/editors/list/List.module.css | 13 ++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index 3d2e874df..dc6c0b913 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -75,9 +75,18 @@ export default function EventList(props) { }; }, [socket]); + // attach cursor to selected + useEffect(() => { + if (cursorSettings !== 'locked' || selected == null) return; + if (selected.index == null) return; + + setCursor(selected.index); + }, [selected, cursorSettings]); + // attach scroll to cursor useEffect(() => { if (cursor == null || cursorRef.current == null) return; + console.log('debug cursor scrolling'); cursorRef.current.scrollIntoView({ behavior: 'smooth', @@ -86,13 +95,6 @@ export default function EventList(props) { }); }, [cursor, cursorRef]); - useEffect(() => { - if (cursorSettings !== 'locked' || selected == null) return; - if (selected.index == null) return; - - setCursor(selected.index); - }, [selected, cursorSettings]); - if (events.length < 1) { return ; } @@ -129,14 +131,16 @@ export default function EventList(props) { ref={provided.innerRef} > {events.map((e, index) => { + let isCursor = cursor === index; if (index === 0) cumulativeDelay = 0; if (e.type === 'delay' && e.duration != null) { cumulativeDelay += e.duration; } else if (e.type === 'block') cumulativeDelay = 0; return (
Date: Thu, 20 May 2021 14:12:23 +0200 Subject: [PATCH 21/55] feat: deleteall --- client/src/app/api/eventsApi.js | 5 +++ .../editors/list/EventListWrapper.jsx | 39 +++++++++++++++++++ client/src/features/menu/EventListMenu.jsx | 3 ++ .../src/features/menu/MenuActionButtons.jsx | 20 ++++++++-- server/controllers/eventsController.js | 17 ++++++++ server/routes/eventsRouter.js | 3 ++ 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/client/src/app/api/eventsApi.js b/client/src/app/api/eventsApi.js index f9b30c035..17a2ada40 100644 --- a/client/src/app/api/eventsApi.js +++ b/client/src/app/api/eventsApi.js @@ -37,3 +37,8 @@ export const requestDelete = async (eventId) => { const res = await axios.delete(eventsURL + '/' + eventId); return res; }; + +export const requestDeleteAll = async () => { + const res = await axios.delete(eventsURL + '/all'); + return res; +}; diff --git a/client/src/features/editors/list/EventListWrapper.jsx b/client/src/features/editors/list/EventListWrapper.jsx index c7f28f705..efae46062 100644 --- a/client/src/features/editors/list/EventListWrapper.jsx +++ b/client/src/features/editors/list/EventListWrapper.jsx @@ -6,6 +6,7 @@ import { requestPost, requestPut, requestDelete, + requestDeleteAll, requestReorder, requestApplyDelay, } from 'app/api/eventsApi.js'; @@ -160,6 +161,35 @@ export default function EventListWrapper() { }, }); + const deleteAllEvents = useMutation(requestDeleteAll, { + // we optimistically update here + onMutate: async () => { + // cancel ongoing queries + queryClient.cancelQueries(EVENTS_TABLE, { exact: true }); + + // Snapshot the previous value + const previousEvents = queryClient.getQueryData(EVENTS_TABLE); + + let clear = []; + + // optimistically update object + queryClient.setQueryData(EVENTS_TABLE, clear); + + // Return a context with the previous and new todo + 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); + }, + }); + const applyDelay = useMutation(requestApplyDelay, { // Mutation finished, failed or successful onSettled: () => { @@ -298,6 +328,15 @@ export default function EventListWrapper() { setCollapsed({ clear: true, items: data, isCollapsed: false }); break; + case 'deleteall': + try { + let t = Date.now(); + await deleteAllEvents.mutateAsync(); + console.log('debug m deleteall', Date.now() - t); + } catch (error) { + showErrorToast('Error deleting events', error.message); + } + break; default: showErrorToast('Unrecognised request', action); break; diff --git a/client/src/features/menu/EventListMenu.jsx b/client/src/features/menu/EventListMenu.jsx index 912c47b95..54bc4f46d 100644 --- a/client/src/features/menu/EventListMenu.jsx +++ b/client/src/features/menu/EventListMenu.jsx @@ -30,6 +30,9 @@ const EventListMenu = ({ eventsHandler }) => { } SetOption({ cursor: newSet }); break; + case 'deleteall': + eventsHandler('deleteall'); + break; default: break; } diff --git a/client/src/features/menu/MenuActionButtons.jsx b/client/src/features/menu/MenuActionButtons.jsx index f506be7be..eff31e739 100644 --- a/client/src/features/menu/MenuActionButtons.jsx +++ b/client/src/features/menu/MenuActionButtons.jsx @@ -1,6 +1,13 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu'; import { IconButton } from '@chakra-ui/button'; -import { FiZap, FiPlus, FiClock, FiMinusCircle } from 'react-icons/fi'; +import { + FiTrash2, + FiZap, + FiPlus, + FiClock, + FiMinusCircle, +} from 'react-icons/fi'; +import { Divider } from '@chakra-ui/layout'; export default function MenuActionButtons(props) { const { actionHandler } = props; @@ -22,9 +29,6 @@ export default function MenuActionButtons(props) { color={'orange.500'} /> - {/* } onClick={props.deleteAllHandler}> - Delete All - */} } onClick={() => actionHandler('event')}> Event first @@ -37,6 +41,14 @@ export default function MenuActionButtons(props) { > Block first + + } + onClick={() => actionHandler('deleteall')} + color='red.500' + > + Delete All + ); diff --git a/server/controllers/eventsController.js b/server/controllers/eventsController.js index 9c1306e15..5f3973121 100644 --- a/server/controllers/eventsController.js +++ b/server/controllers/eventsController.js @@ -295,3 +295,20 @@ exports.eventsDelete = async (req, res) => { res.status(400).send(error); } }; + +// Create controller for DELETE request to '/events/:eventId' +// Returns - +exports.eventsDeleteAll = async (req, res) => { + try { + // set with nothing + db.set('events', []).write(); + + // update timer object + _updateTimersSingle(); + + res.sendStatus(201); + } catch (error) { + console.log('debug:', error); + res.status(400).send(error); + } +}; diff --git a/server/routes/eventsRouter.js b/server/routes/eventsRouter.js index a156906cf..b8bf90cd9 100644 --- a/server/routes/eventsRouter.js +++ b/server/routes/eventsRouter.js @@ -25,6 +25,9 @@ router.patch('/reorder/', eventsController.eventsReorder); // create route between controller and '/events/applydelay/:eventId' endpoint router.patch('/applydelay/:eventId', eventsController.eventsApplyDelay); +// create route between controller and '/events/all' endpoint +router.delete('/all', eventsController.eventsDeleteAll); + // create route between controller and '/events/:eventId' endpoint router.delete('/:eventId', eventsController.eventsDelete); From 5b7905e86a929575c07418dd3d0b741007e13d87 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Mon, 24 May 2021 17:06:02 +0200 Subject: [PATCH 22/55] cjs > esm imports --- server/app.js | 43 +++++++++++++------------ server/classes/EventTimer.js | 12 +++---- server/classes/Timer.js | 6 ++-- server/config.js | 15 +++++++++ server/config.json | 15 --------- server/controllers/OscController.js | 8 ++--- server/controllers/eventController.js | 24 +++++++------- server/controllers/eventsController.js | 28 +++++++++------- server/data/dataModel.js | 4 +-- server/data/eventsDefinition.js | 8 ++--- server/data/playbackData.json | 8 ----- server/package.json | 2 +- server/routes/eventRouter.js | 39 ++++++++++++++--------- server/routes/eventsRouter.js | 36 +++++++++++++-------- server/routes/playbackRouter.js | 6 ++-- server/yarn.lock | 44 ++++++-------------------- 16 files changed, 138 insertions(+), 160 deletions(-) create mode 100644 server/config.js delete mode 100644 server/config.json delete mode 100644 server/data/playbackData.json diff --git a/server/app.js b/server/app.js index 3210ded28..6064b1251 100644 --- a/server/app.js +++ b/server/app.js @@ -1,27 +1,33 @@ // get config -const config = require('./config.json'); +import { config } from './config.js'; // init database -const low = require('lowdb'); -const FileSync = require('lowdb/adapters/FileSync'); +import { Low, JSONFile } from 'lowdb'; -const adapter = new FileSync(config.database.filename); -const db = low(adapter); +const adapter = new JSONFile(config.database.filename); +export const db = new Low(adapter); // dependencies -const express = require('express'); -const http = require('http'); -const cors = require('cors'); -const { dbModel } = require('./data/dataModel.js'); +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import { dbModel } from './data/dataModel.js'; -db.defaults(dbModel).write(); +// Read data from JSON file, this will set db.data content +await db.read(); -// export db -module.exports.db = db; +// If file.json doesn't exist, db.data will be null +// Set default data +// db.data ||= { posts: [] }; +if (!db.data) db.data = { dbModel }; + +// get data +const { events } = db.data; // Import Routes -const eventsRouter = require('./routes/eventsRouter.js'); -const eventRouter = require('./routes/eventRouter.js'); +import { router as eventsRouter } from './routes/eventsRouter.js'; +import { router as eventRouter } from './routes/eventRouter.js'; + // No settings yet // const settingsRouter = require('./routes/settingsRouter.js'); @@ -29,7 +35,7 @@ const eventRouter = require('./routes/eventRouter.js'); const port = process.env.PORT || config.server.port; // Global Objects -const EventTimer = require('./classes/EventTimer.js'); +import { EventTimer } from './classes/EventTimer.js'; // Create express APP const app = express(); @@ -62,12 +68,9 @@ app.use((err, req, res, next) => { // create HTTP server const server = http.createServer(app); -// get data (if any) -const eventlist = db.get('events').value(); - // init timer global.timer = new EventTimer(server, config); -global.timer.setupWithEventList(eventlist); +global.timer.setupWithEventList(events); // Start server server.listen(port, () => @@ -75,6 +78,6 @@ server.listen(port, () => ); // Start OSC server -const { initiateOSC } = require('./controllers/OscController.js'); +import { initiateOSC } from './controllers/OscController.js'; initiateOSC(config.osc); diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 6059a7a18..a81ee4388 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -1,5 +1,5 @@ -const Timer = require('./Timer'); -const socketIo = require('socket.io'); +import { Timer } from './Timer.js'; +import { Server } from 'socket.io'; /* * EventTimer adds functions specific to APP @@ -9,7 +9,7 @@ const socketIo = require('socket.io'); * */ -class EventTimer extends Timer { +export class EventTimer extends Timer { // AUX DAYMS = 86400000; @@ -57,12 +57,12 @@ class EventTimer extends Timer { numEvents = null; _eventlist = null; - constructor(server, config) { + constructor(httpServer, config) { // call super constructor super(); // initialise socketIO server - this.io = socketIo(server, { + this.io = new Server(httpServer, { cors: { origin: '*', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', @@ -797,5 +797,3 @@ class EventTimer extends Timer { this.loadEvent(this.selectedEventIndex); } } - -module.exports = EventTimer; diff --git a/server/classes/Timer.js b/server/classes/Timer.js index 6fd83e5e3..3a402ccd7 100644 --- a/server/classes/Timer.js +++ b/server/classes/Timer.js @@ -4,7 +4,7 @@ * */ -class Timer { +export class Timer { clock = null; duration = null; current = null; @@ -210,6 +210,4 @@ class Timer { this._finishedAt = null; } } -} - -module.exports = Timer; +} \ No newline at end of file diff --git a/server/config.js b/server/config.js new file mode 100644 index 000000000..64ef4ef2b --- /dev/null +++ b/server/config.js @@ -0,0 +1,15 @@ +export const config = { + timer: { + refresh: 1000, + }, + server: { + port: 4001, + }, + database: { + filename: 'db.json', + tablename: 'events', + }, + osc: { + port: 8888, + }, +}; diff --git a/server/config.json b/server/config.json deleted file mode 100644 index 40d18e4a8..000000000 --- a/server/config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "timer": { - "refresh": 1000 - }, - "server": { - "port": 4001 - }, - "database": { - "filename": "db.json", - "tablename": "events" - }, - "osc": { - "port": 8888 - } -} \ No newline at end of file diff --git a/server/controllers/OscController.js b/server/controllers/OscController.js index 288e5ee5e..89b1010f2 100644 --- a/server/controllers/OscController.js +++ b/server/controllers/OscController.js @@ -1,7 +1,7 @@ -const osc = require('node-osc'); +import { Server } from 'node-osc'; -const initiateOSC = (config) => { - const oscServer = new osc.Server(config.port, '0.0.0.0', () => { +export const initiateOSC = (config) => { + const oscServer = new Server(config.port, '0.0.0.0', () => { console.log(`OSC Server is listening on port ${config.port}`); }); @@ -83,5 +83,3 @@ const initiateOSC = (config) => { } }); }; - -module.exports = { initiateOSC }; diff --git a/server/controllers/eventController.js b/server/controllers/eventController.js index f78e4204e..23988490b 100644 --- a/server/controllers/eventController.js +++ b/server/controllers/eventController.js @@ -1,5 +1,5 @@ // get database -const { db } = require('../app.js'); +import { db } from '../app.js'; const table = 'event'; @@ -9,7 +9,7 @@ function getSettings() { // Create controller for GET request to 'event' // Returns ACK message -exports.getAll = async (req, res) => { +export const getAll = async (req, res) => { const settings = getSettings(); if (settings) res.json(settings); else res.sendStatus(400); @@ -17,7 +17,7 @@ exports.getAll = async (req, res) => { // Create controller for POST request to 'event' // Returns ACK message -exports.post = async (req, res) => { +export const post = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -35,7 +35,7 @@ exports.post = async (req, res) => { // Create controller for GET request to 'event/title' // Returns ACK message -exports.titleGet = async (req, res) => { +export const titleGet = async (req, res) => { const settings = getSettings(); if (settings) res.json(settings.title); @@ -44,7 +44,7 @@ exports.titleGet = async (req, res) => { // Create controller for POST request to 'event/title' // Returns ACK message -exports.titlePost = async (req, res) => { +export const titlePost = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -61,7 +61,7 @@ exports.titlePost = async (req, res) => { // Create controller for GET request to '/event/url' // Returns ACK message -exports.urlGet = async (req, res) => { +export const urlGet = async (req, res) => { const event = getSettings(); if (event) res.json(event.url); @@ -70,7 +70,7 @@ exports.urlGet = async (req, res) => { // Create controller for POST request to '/event/url' // Returns ACK message -exports.urlPost = async (req, res) => { +export const urlPost = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -87,7 +87,7 @@ exports.urlPost = async (req, res) => { // Create controller for GET request to 'event/publicInfo' // Returns ACK message -exports.publicInfoGet = async (req, res) => { +export const publicInfoGet = async (req, res) => { const settings = getSettings(); if (settings) res.json(settings.publicInfo); @@ -96,7 +96,7 @@ exports.publicInfoGet = async (req, res) => { // Create controller for POST request to '/event/publicInfo' // Returns ACK message -exports.publicInfoPost = async (req, res) => { +export const publicInfoPost = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -113,7 +113,7 @@ exports.publicInfoPost = async (req, res) => { // Create controller for GET request to 'event/backstageInfo' // Returns ACK message -exports.backstageInfoGet = async (req, res) => { +export const backstageInfoGet = async (req, res) => { const settings = getSettings(); if (settings) res.json(settings.backstageInfo); @@ -122,7 +122,7 @@ exports.backstageInfoGet = async (req, res) => { // Create controller for POST request to '/event/info' // Returns ACK message -exports.backstageInfoPost = async (req, res) => { +export const backstageInfoPost = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -139,6 +139,6 @@ exports.backstageInfoPost = async (req, res) => { // Create controller for GET request to 'event/osc' // Returns ACK message -exports.osc = async (req, res) => { +export const osc = async (req, res) => { res.send('Not yet implemented').status(500); }; diff --git a/server/controllers/eventsController.js b/server/controllers/eventsController.js index 5f3973121..4768eeb16 100644 --- a/server/controllers/eventsController.js +++ b/server/controllers/eventsController.js @@ -1,10 +1,14 @@ // get database -const db = require('../app.js').db; +import { db } from '../app.js'; // utils -const customAlphabet = require('nanoid').customAlphabet; +import { customAlphabet } from 'nanoid'; const nanoid = customAlphabet('1234567890abcdef', 4); -const eventDefs = require('../data/eventsDefinition.js'); +import { + event as eventDef, + block as blockDef, + delay as delayDEf, +} from '../data/eventsDefinition.js'; function _getEventsCount() { return db.get('events').size().value(); @@ -68,21 +72,21 @@ function _deleteTimerId(entryId) { // Create controller for GET request to '/events' // Returns - -exports.eventsGetAll = async (req, res) => { +export const eventsGetAll = async (req, res) => { const results = db.get('events').value(); res.json(results); }; // Create controller for GET request to '/events/:eventId' // Returns - -exports.eventsGetById = async (req, res) => { +export const eventsGetById = async (req, res) => { const e = db.get('events').find({ id: req.params.eventId }).value(); res.json(e); }; // Create controller for POST request to '/events/' // Returns - -exports.eventsPost = async (req, res) => { +export const eventsPost = async (req, res) => { // TODO: Validate event if (!req.body) { res.status(400).send(`No object found in request`); @@ -131,7 +135,7 @@ exports.eventsPost = async (req, res) => { // Create controller for PUT request to '/events/' // Returns - -exports.eventsPut = async (req, res) => { +export const eventsPut = async (req, res) => { // no valid params if (!req.body) { res.status(400).send(`No object found`); @@ -162,12 +166,12 @@ exports.eventsPut = async (req, res) => { // Create controller for PATCH request to '/events/' // Returns - -exports.eventsPatch = async (req, res) => { +export const eventsPatch = async (req, res) => { // Code is the same as put, call that this.eventsPut(req, res); }; -exports.eventsReorder = async (req, res) => { +export const eventsReorder = async (req, res) => { // TODO: Validate event if (!req.body) { res.status(400).send(`No object found in request`); @@ -209,7 +213,7 @@ exports.eventsReorder = async (req, res) => { // Create controller for PATCH request to '/events/applydelay/:eventId' // Returns - -exports.eventsApplyDelay = async (req, res) => { +export const eventsApplyDelay = async (req, res) => { // no valid params if (!req.params.eventId) { res.status(400).send(`No id found in request`); @@ -274,7 +278,7 @@ exports.eventsApplyDelay = async (req, res) => { // Create controller for DELETE request to '/events/:eventId' // Returns - -exports.eventsDelete = async (req, res) => { +export const eventsDelete = async (req, res) => { // no valid params if (!req.params.eventId) { res.status(400).send(`No id found in request`); @@ -298,7 +302,7 @@ exports.eventsDelete = async (req, res) => { // Create controller for DELETE request to '/events/:eventId' // Returns - -exports.eventsDeleteAll = async (req, res) => { +export const eventsDeleteAll = async (req, res) => { try { // set with nothing db.set('events', []).write(); diff --git a/server/data/dataModel.js b/server/data/dataModel.js index cc6f67470..cbe964153 100644 --- a/server/data/dataModel.js +++ b/server/data/dataModel.js @@ -1,4 +1,4 @@ -const dbModel = { +export const dbModel = { events: [], event: { title: '', @@ -14,5 +14,3 @@ const dbModel = { lock: false, }, }; - -module.exports = { dbModel }; diff --git a/server/data/eventsDefinition.js b/server/data/eventsDefinition.js index d98f968f8..f1f326ee8 100644 --- a/server/data/eventsDefinition.js +++ b/server/data/eventsDefinition.js @@ -1,4 +1,4 @@ -const event = { +export const event = { title: '', subtitle: '', presenter: '', @@ -10,14 +10,12 @@ const event = { revision: 0, }; -const delay = { +export const delay = { duration: 0, type: 'delay', revision: 0, }; -const block = { +export const block = { type: 'block', }; - -module.exports = { event, delay, block }; diff --git a/server/data/playbackData.json b/server/data/playbackData.json deleted file mode 100644 index 0337f8752..000000000 --- a/server/data/playbackData.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "current": null, - "next": null, - "currentTimer": null, - "numEvents": 0, - "state": "pause", - "prevState": "pause" -} \ No newline at end of file diff --git a/server/package.json b/server/package.json index 45025a56a..243cd0fa3 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "body-parser": "~1.19.0", "express": "~4.17.1", "express-session": "~1.17.1", - "lowdb": "^1.0.0", + "lowdb": "2.1.0", "nanoid": "^3.1.22", "node-osc": "^6.0.1", "passport": "~0.4.1", diff --git a/server/routes/eventRouter.js b/server/routes/eventRouter.js index 327fe30b5..99a6b5054 100644 --- a/server/routes/eventRouter.js +++ b/server/routes/eventRouter.js @@ -1,37 +1,46 @@ -const express = require('express'); -const router = express.Router(); +import express from 'express'; +export const router = express.Router(); // import event controller -const eventController = require('../controllers/eventController'); +import { + getAll, + post, + titleGet, + titlePost, + publicInfoGet, + publicInfoPost, + backstageInfoGet, + backstageInfoPost, + urlGet, + urlPost, +} from '../controllers/eventController.js'; // create route between controller and '/settings' endpoint -router.get('/', eventController.getAll); +router.get('/', getAll); // create route between controller and '/settings' endpoint -router.post('/', eventController.post); +router.post('/', post); // create route between controller and '/event/title' endpoint -router.get('/title', eventController.titleGet); +router.get('/title', titleGet); // create route between controller and '/event/title' endpoint -router.post('/title', eventController.titlePost); +router.post('/title', titlePost); // create route between controller and '/event/info' endpoint -router.get('/publicInfo', eventController.publicInfoGet); +router.get('/publicInfo', publicInfoGet); // create route between controller and '/event/info' endpoint -router.post('/publicInfo', eventController.publicInfoPost); +router.post('/publicInfo', publicInfoPost); // create route between controller and '/event/info' endpoint -router.get('/backstageInfo', eventController.backstageInfoGet); +router.get('/backstageInfo', backstageInfoGet); // create route between controller and '/event/info' endpoint -router.post('/backstageInfo', eventController.backstageInfoPost); +router.post('/backstageInfo', backstageInfoPost); // create route between controller and '/event/url' endpoint -router.get('/url', eventController.urlGet); +router.get('/url', urlGet); // create route between controller and '/event/url' endpoint -router.post('/url', eventController.urlPost); - -module.exports = router; +router.post('/url', urlPost); diff --git a/server/routes/eventsRouter.js b/server/routes/eventsRouter.js index b8bf90cd9..9ea280c45 100644 --- a/server/routes/eventsRouter.js +++ b/server/routes/eventsRouter.js @@ -1,34 +1,42 @@ -const express = require('express'); -const router = express.Router(); +import express from 'express'; +export const router = express.Router(); // import events controller -const eventsController = require('../controllers/eventsController'); +import { + eventsGetAll, + eventsGetById, + eventsPost, + eventsPut, + eventsPatch, + eventsReorder, + eventsApplyDelay, + eventsDeleteAll, + eventsDelete, +} from '../controllers/eventsController.js'; // create route between controller and '/events/' endpoint -router.get('/', eventsController.eventsGetAll); +router.get('/', eventsGetAll); // create route between controller and '/events/:eventId' endpoint -router.get('/:eventId', eventsController.eventsGetById); +router.get('/:eventId', eventsGetById); // create route between controller and '/events/' endpoint -router.post('/', eventsController.eventsPost); +router.post('/', eventsPost); // create route between controller and '/events/' endpoint -router.put('/', eventsController.eventsPut); +router.put('/', eventsPut); // create route between controller and '/events/' endpoint -router.patch('/', eventsController.eventsPatch); +router.patch('/', eventsPatch); // create route between controller and '/events/reorder' endpoint -router.patch('/reorder/', eventsController.eventsReorder); +router.patch('/reorder/', eventsReorder); // create route between controller and '/events/applydelay/:eventId' endpoint -router.patch('/applydelay/:eventId', eventsController.eventsApplyDelay); +router.patch('/applydelay/:eventId', eventsApplyDelay); // create route between controller and '/events/all' endpoint -router.delete('/all', eventsController.eventsDeleteAll); +router.delete('/all', eventsDeleteAll); // create route between controller and '/events/:eventId' endpoint -router.delete('/:eventId', eventsController.eventsDelete); - -module.exports = router; +router.delete('/:eventId', eventsDelete); diff --git a/server/routes/playbackRouter.js b/server/routes/playbackRouter.js index e767a3156..3b71b9c86 100644 --- a/server/routes/playbackRouter.js +++ b/server/routes/playbackRouter.js @@ -1,5 +1,5 @@ -const express = require('express'); -const router = express.Router(); +import express from 'express'; +export const router = express.Router(); // import event controller const playbackController = require('../controllers/playbackController'); @@ -30,5 +30,3 @@ router.get('/unload', playbackController.pbUnload); // create route between controller and '/playback/reload' endpoint router.get('/reload', playbackController.pbReload); - -module.exports = router; diff --git a/server/yarn.lock b/server/yarn.lock index 4b7da8610..7ef514f68 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -253,11 +253,6 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= -graceful-fs@^4.1.3: - version "4.2.6" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" - integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== - http-errors@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" @@ -302,26 +297,12 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-promise@^2.1.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" - integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== - -lodash@4: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -lowdb@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-1.0.0.tgz#5243be6b22786ccce30e50c9a33eac36b20c8064" - integrity sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ== +lowdb@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-2.1.0.tgz#c8063e228b5ab3e082ece90e0512537ecb6e1e2a" + integrity sha512-F4Go8/V37gAidTR3c5poyjprOpZSDNSLJVOmI0ny4D4q9rC37OkBhlzX0bqj7LZlT3UIj4FchmZrrSw7qY+eGQ== dependencies: - graceful-fs "^4.1.3" - is-promise "^2.1.0" - lodash "4" - pify "^3.0.0" - steno "^0.4.1" + steno "^1.0.0" media-typer@0.3.0: version "0.3.0" @@ -446,11 +427,6 @@ pause@0.0.1: resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= - proxy-addr@~2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" @@ -567,12 +543,10 @@ socket.io@^4.0.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= -steno@^0.4.1: - version "0.4.4" - resolved "https://registry.yarnpkg.com/steno/-/steno-0.4.4.tgz#071105bdfc286e6615c0403c27e9d7b5dcb855cb" - integrity sha1-BxEFvfwobmYVwEA8J+nXtdy4Vcs= - dependencies: - graceful-fs "^4.1.3" +steno@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/steno/-/steno-1.0.0.tgz#475e32c6066ec9760229eaaf1550601764fbecba" + integrity sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw== toidentifier@1.0.0: version "1.0.0" From bb1f063c6a86b68c0fb6eec27aee4f77c7fa89fe Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Mon, 24 May 2021 18:22:29 +0200 Subject: [PATCH 23/55] migrate event logic --- server/app.js | 9 +++-- server/controllers/eventController.js | 47 ++++++++------------------- server/data/dataModel.js | 2 +- 3 files changed, 21 insertions(+), 37 deletions(-) diff --git a/server/app.js b/server/app.js index 6064b1251..8244342a3 100644 --- a/server/app.js +++ b/server/app.js @@ -18,11 +18,14 @@ await db.read(); // If file.json doesn't exist, db.data will be null // Set default data -// db.data ||= { posts: [] }; -if (!db.data) db.data = { dbModel }; +// db.data ||= { events: [] }; +if (db.data == null) { + db.data = dbModel; + db.write(); +} // get data -const { events } = db.data; +export const data = db.data; // Import Routes import { router as eventsRouter } from './routes/eventsRouter.js'; diff --git a/server/controllers/eventController.js b/server/controllers/eventController.js index 23988490b..c003e4086 100644 --- a/server/controllers/eventController.js +++ b/server/controllers/eventController.js @@ -1,18 +1,10 @@ // get database -import { db } from '../app.js'; - -const table = 'event'; - -function getSettings() { - return db.get(table).value(); -} +import { db, data } from '../app.js'; // Create controller for GET request to 'event' // Returns ACK message export const getAll = async (req, res) => { - const settings = getSettings(); - if (settings) res.json(settings); - else res.sendStatus(400); + res.json(data.event); }; // Create controller for POST request to 'event' @@ -24,22 +16,20 @@ export const post = async (req, res) => { } // TODO: validate data try { - db.get(table) - .assign({ ...req.body }) - .write(); + data.event = { ...data.event, ...req.body }; + db.write(); + // data.event.push({ ...req.body }).write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); + console.log(error); } }; // Create controller for GET request to 'event/title' // Returns ACK message export const titleGet = async (req, res) => { - const settings = getSettings(); - - if (settings) res.json(settings.title); - else res.sendStatus(400); + res.json(data.event.title); }; // Create controller for POST request to 'event/title' @@ -52,7 +42,7 @@ export const titlePost = async (req, res) => { // TODO: validate data try { - db.get(table).assign({ title: req.body.title }).write(); + data.event.assign({ title: req.body.title }).write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); @@ -62,10 +52,7 @@ export const titlePost = async (req, res) => { // Create controller for GET request to '/event/url' // Returns ACK message export const urlGet = async (req, res) => { - const event = getSettings(); - - if (event) res.json(event.url); - else res.sendStatus(400); + res.json(data.event.url); }; // Create controller for POST request to '/event/url' @@ -78,7 +65,7 @@ export const urlPost = async (req, res) => { // TODO: validate data try { - db.get(table).assign({ url: req.body.url }).write(); + data.event.assign({ url: req.body.url }).write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); @@ -88,10 +75,7 @@ export const urlPost = async (req, res) => { // Create controller for GET request to 'event/publicInfo' // Returns ACK message export const publicInfoGet = async (req, res) => { - const settings = getSettings(); - - if (settings) res.json(settings.publicInfo); - else res.sendStatus(400); + res.json(data.event.publicInfo); }; // Create controller for POST request to '/event/publicInfo' @@ -104,7 +88,7 @@ export const publicInfoPost = async (req, res) => { // TODO: validate data try { - db.get(table).assign({ publicInfo: req.body.publicInfo }).write(); + data.event.assign({ publicInfo: req.body.publicInfo }).write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); @@ -114,10 +98,7 @@ export const publicInfoPost = async (req, res) => { // Create controller for GET request to 'event/backstageInfo' // Returns ACK message export const backstageInfoGet = async (req, res) => { - const settings = getSettings(); - - if (settings) res.json(settings.backstageInfo); - else res.sendStatus(400); + res.json(data.event.backstageInfo); }; // Create controller for POST request to '/event/info' @@ -130,7 +111,7 @@ export const backstageInfoPost = async (req, res) => { // TODO: validate data try { - db.get(table).assign({ backstageInfo: req.body.backstageInfo }).write(); + data.event.assign({ backstageInfo: req.body.backstageInfo }).write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); diff --git a/server/data/dataModel.js b/server/data/dataModel.js index cbe964153..39e781b87 100644 --- a/server/data/dataModel.js +++ b/server/data/dataModel.js @@ -4,7 +4,7 @@ export const dbModel = { title: '', url: '', publicInfo: '', - backStageInfo: '', + backstageInfo: '', }, settings: { app: 'ontime', From 7406d5447295dd8b02ac551054facd9fab5b853a Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Mon, 24 May 2021 23:15:53 +0200 Subject: [PATCH 24/55] refract: delete unused code --- server/controllers/eventController.js | 105 +------------------------- server/routes/eventRouter.js | 45 ++--------- 2 files changed, 8 insertions(+), 142 deletions(-) diff --git a/server/controllers/eventController.js b/server/controllers/eventController.js index c003e4086..938e412b7 100644 --- a/server/controllers/eventController.js +++ b/server/controllers/eventController.js @@ -3,13 +3,13 @@ import { db, data } from '../app.js'; // Create controller for GET request to 'event' // Returns ACK message -export const getAll = async (req, res) => { +export const getEvent = async (req, res) => { res.json(data.event); }; // Create controller for POST request to 'event' // Returns ACK message -export const post = async (req, res) => { +export const postEvent = async (req, res) => { if (!req.body) { res.status(400).send('No object found in request'); return; @@ -17,109 +17,10 @@ export const post = async (req, res) => { // TODO: validate data try { data.event = { ...data.event, ...req.body }; - db.write(); - // data.event.push({ ...req.body }).write(); + await db.write(); res.sendStatus(200); } catch (error) { res.status(400).send(error); console.log(error); } }; - -// Create controller for GET request to 'event/title' -// Returns ACK message -export const titleGet = async (req, res) => { - res.json(data.event.title); -}; - -// Create controller for POST request to 'event/title' -// Returns ACK message -export const titlePost = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); - return; - } - - // TODO: validate data - try { - data.event.assign({ title: req.body.title }).write(); - res.sendStatus(200); - } catch (error) { - res.status(400).send(error); - } -}; - -// Create controller for GET request to '/event/url' -// Returns ACK message -export const urlGet = async (req, res) => { - res.json(data.event.url); -}; - -// Create controller for POST request to '/event/url' -// Returns ACK message -export const urlPost = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); - return; - } - - // TODO: validate data - try { - data.event.assign({ url: req.body.url }).write(); - res.sendStatus(200); - } catch (error) { - res.status(400).send(error); - } -}; - -// Create controller for GET request to 'event/publicInfo' -// Returns ACK message -export const publicInfoGet = async (req, res) => { - res.json(data.event.publicInfo); -}; - -// Create controller for POST request to '/event/publicInfo' -// Returns ACK message -export const publicInfoPost = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); - return; - } - - // TODO: validate data - try { - data.event.assign({ publicInfo: req.body.publicInfo }).write(); - res.sendStatus(200); - } catch (error) { - res.status(400).send(error); - } -}; - -// Create controller for GET request to 'event/backstageInfo' -// Returns ACK message -export const backstageInfoGet = async (req, res) => { - res.json(data.event.backstageInfo); -}; - -// Create controller for POST request to '/event/info' -// Returns ACK message -export const backstageInfoPost = async (req, res) => { - if (!req.body) { - res.status(400).send('No object found in request'); - return; - } - - // TODO: validate data - try { - data.event.assign({ backstageInfo: req.body.backstageInfo }).write(); - res.sendStatus(200); - } catch (error) { - res.status(400).send(error); - } -}; - -// Create controller for GET request to 'event/osc' -// Returns ACK message -export const osc = async (req, res) => { - res.send('Not yet implemented').status(500); -}; diff --git a/server/routes/eventRouter.js b/server/routes/eventRouter.js index 99a6b5054..9b929f95d 100644 --- a/server/routes/eventRouter.js +++ b/server/routes/eventRouter.js @@ -2,45 +2,10 @@ import express from 'express'; export const router = express.Router(); // import event controller -import { - getAll, - post, - titleGet, - titlePost, - publicInfoGet, - publicInfoPost, - backstageInfoGet, - backstageInfoPost, - urlGet, - urlPost, -} from '../controllers/eventController.js'; +import { getEvent, postEvent } from '../controllers/eventController.js'; -// create route between controller and '/settings' endpoint -router.get('/', getAll); +// create route between controller and 'GET /event' endpoint +router.get('/', getEvent); -// create route between controller and '/settings' endpoint -router.post('/', post); - -// create route between controller and '/event/title' endpoint -router.get('/title', titleGet); - -// create route between controller and '/event/title' endpoint -router.post('/title', titlePost); - -// create route between controller and '/event/info' endpoint -router.get('/publicInfo', publicInfoGet); - -// create route between controller and '/event/info' endpoint -router.post('/publicInfo', publicInfoPost); - -// create route between controller and '/event/info' endpoint -router.get('/backstageInfo', backstageInfoGet); - -// create route between controller and '/event/info' endpoint -router.post('/backstageInfo', backstageInfoPost); - -// create route between controller and '/event/url' endpoint -router.get('/url', urlGet); - -// create route between controller and '/event/url' endpoint -router.post('/url', urlPost); +// create route between controller and 'POST /event' endpoint +router.post('/', postEvent); From 7fe3a28a7611da5b5734646a02168bbcdaddefc2 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Mon, 24 May 2021 23:16:19 +0200 Subject: [PATCH 25/55] migrate logic --- server/app.js | 4 +- server/controllers/eventsController.js | 67 +++++++++++++++----------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/server/app.js b/server/app.js index 8244342a3..034e11e90 100644 --- a/server/app.js +++ b/server/app.js @@ -18,7 +18,7 @@ await db.read(); // If file.json doesn't exist, db.data will be null // Set default data -// db.data ||= { events: [] }; +// db.data ||= { events: [] }; NODE v15 - v16 if (db.data == null) { db.data = dbModel; db.write(); @@ -73,7 +73,7 @@ const server = http.createServer(app); // init timer global.timer = new EventTimer(server, config); -global.timer.setupWithEventList(events); +global.timer.setupWithEventList(data.events); // Start server server.listen(port, () => diff --git a/server/controllers/eventsController.js b/server/controllers/eventsController.js index 4768eeb16..bbb2dcb51 100644 --- a/server/controllers/eventsController.js +++ b/server/controllers/eventsController.js @@ -1,26 +1,26 @@ // get database -import { db } from '../app.js'; +import { db, data } from '../app.js'; // utils import { customAlphabet } from 'nanoid'; const nanoid = customAlphabet('1234567890abcdef', 4); import { event as eventDef, + delay as delayDef, block as blockDef, - delay as delayDEf, } from '../data/eventsDefinition.js'; function _getEventsCount() { - return db.get('events').size().value(); + return Array.from(data.events).length; } function _pushNew(entry) { - return db.get('events').push(entry).write(); + return data.events.push(entry).write(); } -function _insertAt(entry, index) { +async function _insertAt(entry, index) { // get events - let events = db.get('events').value(); + let events = data.events; let count = events.length; let order = entry.order; @@ -43,15 +43,18 @@ function _insertAt(entry, index) { } // save events - db.set('events', events).write(); + data.events = events; + await db.write(); } -function _removeById(eventId) { - return db.get('events').remove({ id: eventId }).write(); +async function _removeById(eventId) { + data.events = Array.from(data.events).filter((e) => e.id != eventId); + await db.write(); } function getEventEvents() { - return db.get('events').chain().filter({ type: 'event' }).value(); + // return data.events.filter((e) => e.type === 'event'); + return Array.from(data.events).filter((e) => e.type === 'event'); } // Updates timer object @@ -73,14 +76,14 @@ function _deleteTimerId(entryId) { // Create controller for GET request to '/events' // Returns - export const eventsGetAll = async (req, res) => { - const results = db.get('events').value(); - res.json(results); + res.json(data.events); }; // Create controller for GET request to '/events/:eventId' // Returns - export const eventsGetById = async (req, res) => { - const e = db.get('events').find({ id: req.params.eventId }).value(); + const e = data.events.find({ id: req.params.eventId }).value(); + console.log('event by id', e); res.json(e); }; @@ -99,13 +102,13 @@ export const eventsPost = async (req, res) => { switch (req.body.type) { case 'event': - newEvent = { ...eventDefs.event, ...req.body }; + newEvent = { ...eventDef, ...req.body }; break; case 'delay': - newEvent = { ...eventDefs.delay, ...req.body }; + newEvent = { ...delayDef, ...req.body }; break; case 'block': - newEvent = { ...eventDefs.block, ...req.body }; + newEvent = { ...blockDef, ...req.body }; break; default: @@ -149,17 +152,23 @@ export const eventsPut = async (req, res) => { } try { - db.get('events') - .find({ id: req.body.id }) - .assign({ ...req.body }) - .update('revision', (n) => n + 1) - .write(); + const eventIndex = data.events.findIndex((e) => e.id === req.body.id); + if (eventIndex === -1) { + res.status(400).send(`No Id found found`); + return; + } + + const e = data.events[eventIndex]; + data.events[eventIndex] = { ...e, ...req.body }; + data.events[eventIndex].revision++; + db.write(); // update timer _updateTimersSingle(req.body.id, req.body); res.sendStatus(200); } catch (error) { + console.log(error); res.status(400).send(error); } }; @@ -168,7 +177,7 @@ export const eventsPut = async (req, res) => { // Returns - export const eventsPatch = async (req, res) => { // Code is the same as put, call that - this.eventsPut(req, res); + eventsPut(req, res); }; export const eventsReorder = async (req, res) => { @@ -181,7 +190,7 @@ export const eventsReorder = async (req, res) => { const { index, from, to } = req.body; // get events - let events = db.get('events').value(); + let events = data.events; let idx = events.findIndex((e) => e.id === index, from); // Check if item is at given index @@ -198,7 +207,8 @@ export const eventsReorder = async (req, res) => { events.splice(to, 0, reorderedItem); // save events - db.set('events', events).write(); + data.events = events; + db.write(); // TODO: would it be more efficient to reorder at timer? // update timer @@ -222,7 +232,7 @@ export const eventsApplyDelay = async (req, res) => { try { // get events - let events = db.get('events').value(); + let events = data.events; // AUX let delayIndex = null; @@ -263,7 +273,8 @@ export const eventsApplyDelay = async (req, res) => { if (blockIndex) events.splice(blockIndex - 1, 1); // update events - db.set('events', events).write(); + data.events = events; + db.write(); // update timer _updateTimers(); @@ -305,14 +316,14 @@ export const eventsDelete = async (req, res) => { export const eventsDeleteAll = async (req, res) => { try { // set with nothing - db.set('events', []).write(); + data.events = []; + db.write(); // update timer object _updateTimersSingle(); res.sendStatus(201); } catch (error) { - console.log('debug:', error); res.status(400).send(error); } }; From 250946fdc6a94d0fe879c71f643048b0f5d01567 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 25 May 2021 09:39:28 +0200 Subject: [PATCH 26/55] type module --- server/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/server/package.json b/server/package.json index 243cd0fa3..5597927f8 100644 --- a/server/package.json +++ b/server/package.json @@ -2,6 +2,7 @@ "name": "passport-example", "version": "0.0.1", "description": "Example with Passport (http://www.passportjs.org/)", + "type": "module", "dependencies": { "body-parser": "~1.19.0", "express": "~4.17.1", From b252aa19b9f819aeb70ab1c6234f8761a0bea714 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 25 May 2021 10:19:54 +0200 Subject: [PATCH 27/55] fix: simplify time input --- client/src/common/input/EditableTimer.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/client/src/common/input/EditableTimer.jsx b/client/src/common/input/EditableTimer.jsx index 2c771dbbf..2c39dd6d8 100644 --- a/client/src/common/input/EditableTimer.jsx +++ b/client/src/common/input/EditableTimer.jsx @@ -34,12 +34,12 @@ export default function EditableTimer(props) { if (value === '') return false; // Time now and time submitedVal - const original = stringFromMillis(time, false); + const original = stringFromMillis(time + delay, false); // check if time is different from before if (value === original) return false; - // conver to millis object + // convert to millis object const millis = timeStringToMillis(value, timeFormat); // validate with parent @@ -51,14 +51,8 @@ export default function EditableTimer(props) { return true; }; - const showOriginal = () => { - setValue(stringFromMillis(time, false)); - }; - return ( showOriginal} - onEdit={() => showOriginal} onChange={(v) => setValue(v)} onSubmit={(v) => validateValue(v)} value={value} From 77c25a022ff637a4515550a7df90def016afdd06 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 25 May 2021 12:29:16 +0200 Subject: [PATCH 28/55] roll() timer has roll() implementation --- .../common/components/buttons/RollIconBtn.jsx | 1 - server/classes/EventTimer.js | 66 +++++++++++++++++-- server/classes/Timer.js | 3 +- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/client/src/common/components/buttons/RollIconBtn.jsx b/client/src/common/components/buttons/RollIconBtn.jsx index f34b935c2..079e76f55 100644 --- a/client/src/common/components/buttons/RollIconBtn.jsx +++ b/client/src/common/components/buttons/RollIconBtn.jsx @@ -12,7 +12,6 @@ export default function RollIconBtn(props) { width={120} _focus={{ boxShadow: 'none' }} {...rest} - disabled /> ); } diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 6059a7a18..fba7d38b0 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -109,8 +109,28 @@ class EventTimer extends Timer { update() { // if there is nothing selected, no nothing - if (this.selectedEventId == null) return; - super.update(); + if (this.selectedEventId == null && this.state !== 'roll') return; + + // only implement roll here + if (this.state !== 'roll') { + super.update(); + return; + } + + // get current time + const now = this._getCurrentTime(); + this.clock = now; + + if (this.selectedEventId == null) { + // look for event if none is loaded + this.rollLoad(); + } else if (now > this._finishAt) { + // look for new if finished + this.rollLoad(); + } else { + // update timer as usual + this.current = this._finishAt - now; + } } start() { @@ -140,9 +160,7 @@ class EventTimer extends Timer { else if (payload === 'next') this.next(); else if (payload === 'reload') this.reload(); else if (payload === 'unload') this.unload(); - - // Not yet implemented - // else if (payload === 'roll') this.roll(); + else if (payload === 'roll') this.roll(); this.broadcastThis('playstate', this.state); this.broadcastThis('selected-id', this.selectedEventId); @@ -730,10 +748,44 @@ class EventTimer extends Timer { this.broadcastState(); } + rollLoad() { + const now = this._getCurrentTime(); + this._resetTimers(true); + this._resetSelection(); + + // loop through events, look for where we should be + for (const [index, e] of this._eventlist.entries()) { + if (e.timeStart <= now && now < e.timeEnd) { + // set timers + this._startedAt = e.timeStart; + this._finishAt = e.timeEnd; + this.duration = e.timeEnd - e.timeStart; + this.current = e.timeEnd - now; + + // set selection + this.selectedEventId = e.id; + this.selectedEventIndex = index; + + // set titles + this._loadTitlesNow(); + this._loadTitlesNext(); + // exit + break; + } + } + } + roll() { - console.log('roll: not yet implemented'); - return false; + // do we need to change + if (this.state === 'roll') return; + + // load into event + this.rollLoad(); + + // set state this.state = 'roll'; + + this.broadcastState(); } previous() { diff --git a/server/classes/Timer.js b/server/classes/Timer.js index 6fd83e5e3..1f36c1f5e 100644 --- a/server/classes/Timer.js +++ b/server/classes/Timer.js @@ -110,7 +110,8 @@ class Timer { ); } - _resetTimers() { + _resetTimers(total = false) { + if (total) this.duration = null; this.current = this.duration; this._finishAt = null; this._finishedAt = null; From 8f754587fa25fbf8718c8480eaee45930fd7069e Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 25 May 2021 22:14:40 +0200 Subject: [PATCH 29/55] bugfix: osc gotoid - typo in loadEvent call - force string on osc parameter call --- server/classes/EventTimer.js | 2 +- server/controllers/OscController.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index 6059a7a18..9bc3443bb 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -472,7 +472,7 @@ class EventTimer extends Timer { if (eventIndex === -1) return; this.pause(); - this.loadEvent(eventIndex, true, true); + this.loadEvent(eventIndex, 'load', true); } // Loads a given event diff --git a/server/controllers/OscController.js b/server/controllers/OscController.js index 288e5ee5e..488442002 100644 --- a/server/controllers/OscController.js +++ b/server/controllers/OscController.js @@ -71,7 +71,7 @@ const initiateOSC = (config) => { case 'gotoid': console.log('calling gotoid with', args); try { - global.timer.loadEventById(args.toLowerCase()); + global.timer.loadEventById(args.toString().toLowerCase()); } catch (error) { console.log('error calling goto: ', error); } From 447fede0ff34893e3fc9bb26c267646362c937f4 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 25 May 2021 22:35:38 +0200 Subject: [PATCH 30/55] style: clarify OSC triggers - show path for goto - show path for gotoid --- client/src/features/editors/list/EventBlock.jsx | 10 +++++++--- client/src/features/editors/list/EventList.jsx | 14 ++++++++++++-- client/src/features/editors/list/EventListItem.jsx | 2 ++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/client/src/features/editors/list/EventBlock.jsx b/client/src/features/editors/list/EventBlock.jsx index 62cf829b5..6cc78f21d 100644 --- a/client/src/features/editors/list/EventBlock.jsx +++ b/client/src/features/editors/list/EventBlock.jsx @@ -14,7 +14,8 @@ import { SelectCollapse, HandleCollapse } from 'app/context/collapseAtom'; import { useAtom } from 'jotai'; const ExpandedBlock = (props) => { - const { provided, data, next, delay, delayValue, actionHandler } = props; + const { provided, data, eventIndex, next, delay, delayValue, actionHandler } = + props; const oscid = data.id.length > 4 ? '...' : data.id; @@ -80,7 +81,9 @@ const ExpandedBlock = (props) => { actionHandler('update', { field: 'note', value: v }) } /> - {`OSC ID: ${oscid}`} + + {`/ontime/goto ${eventIndex} << OSC >> /ontime/gotoid ${oscid}`} +
{ }; export default function EventBlock(props) { - const { data, selected, delay, index, actionHandler } = props; + const { data, selected, delay, index, eventIndex, actionHandler } = props; // const [collapsed, setCollapsed] = useState(checkLocalStorage(data.id)); @@ -200,6 +203,7 @@ export default function EventBlock(props) { ) : ( {events.map((e, index) => { let isCursor = cursor === index; - if (index === 0) cumulativeDelay = 0; + if (index === 0) { + cumulativeDelay = 0; + eventIndex = -1; + } if (e.type === 'delay' && e.duration != null) { cumulativeDelay += e.duration; - } else if (e.type === 'block') cumulativeDelay = 0; + } else if (e.type === 'block') { + cumulativeDelay = 0; + } else if (e.type === 'event') { + eventIndex++; + } + return (
{ const { type, index, + eventIndex, data, selected, next, @@ -71,6 +72,7 @@ const EventListItem = (props) => { return ( Date: Tue, 25 May 2021 23:03:03 +0200 Subject: [PATCH 31/55] bugfix: cursor follows playback fix bug where cursor would not check for delay and block modules --- .../src/features/editors/list/EventList.jsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index bd581fc0a..1ff4772c4 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -27,7 +27,7 @@ export default function EventList(props) { }, 'Alt+ArrowUp': () => { if (cursor == null) setCursor(0); - else if (cursor >= 0) setCursor(cursor - 1); + else if (cursor > 0) setCursor(cursor - 1); }, 'Alt+KeyE': (event) => { event.preventDefault(); @@ -49,7 +49,7 @@ export default function EventList(props) { return () => { unsubscribe(); }; - }, [cursor, events.length, eventsHandler]); + }, [cursor, events, eventsHandler]); // handle incoming messages useEffect(() => { @@ -62,7 +62,6 @@ export default function EventList(props) { // Handle playstate socket.on('selected', (data) => { setSelected(data); - console.log('debug cursor setted', data); }); socket.on('next-id', (data) => { setNext(data); @@ -80,13 +79,20 @@ export default function EventList(props) { if (cursorSettings !== 'locked' || selected == null) return; if (selected.index == null) return; - setCursor(selected.index); - }, [selected, cursorSettings]); + let eventIndex = -1; + let gotoIndex = -1; + for (const e of events) { + gotoIndex++; + if (e.type === 'event') eventIndex++; + if (eventIndex === selected.index) break; + } + + setCursor(gotoIndex); + }, [events, selected, cursorSettings]); // attach scroll to cursor useEffect(() => { if (cursor == null || cursorRef.current == null) return; - console.log('debug cursor scrolling'); cursorRef.current.scrollIntoView({ behavior: 'smooth', @@ -119,8 +125,6 @@ export default function EventList(props) { let cumulativeDelay = 0; let eventIndex = -1; - console.log('debug selected', selected); - return (
@@ -132,7 +136,6 @@ export default function EventList(props) { ref={provided.innerRef} > {events.map((e, index) => { - let isCursor = cursor === index; if (index === 0) { cumulativeDelay = 0; eventIndex = -1; @@ -147,9 +150,9 @@ export default function EventList(props) { return (
Date: Tue, 25 May 2021 23:14:56 +0200 Subject: [PATCH 32/55] bugfix: timer negative on goto when jumping events using goto, start time would not be set --- server/classes/Timer.js | 3 +++ server/controllers/OscController.js | 1 + 2 files changed, 4 insertions(+) diff --git a/server/classes/Timer.js b/server/classes/Timer.js index 6fd83e5e3..d322c8bf4 100644 --- a/server/classes/Timer.js +++ b/server/classes/Timer.js @@ -50,6 +50,9 @@ class Timer { // check playstate switch (this.state) { case 'start': + // ensure we have a start time + if (this._startedAt == null) this._startedAt = now; + // update current timer this.current = this._startedAt + this.duration + this._pausedTotal - now; diff --git a/server/controllers/OscController.js b/server/controllers/OscController.js index 488442002..dba6d9cc1 100644 --- a/server/controllers/OscController.js +++ b/server/controllers/OscController.js @@ -63,6 +63,7 @@ const initiateOSC = (config) => { try { let eventIndex = parseInt(args); if (isNaN(eventIndex) || eventIndex <= 0) return; + global.timer.pause(); global.timer.loadEvent(eventIndex - 1, undefined, true); } catch (error) { console.log('error calling goto: ', error); From 9c55116053e9ce96a17277088314989d70671552 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 10:14:40 +0200 Subject: [PATCH 33/55] fix: show event index as 1 based --- client/src/features/editors/list/EventBlock.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/editors/list/EventBlock.jsx b/client/src/features/editors/list/EventBlock.jsx index 6cc78f21d..9b26ea583 100644 --- a/client/src/features/editors/list/EventBlock.jsx +++ b/client/src/features/editors/list/EventBlock.jsx @@ -82,7 +82,7 @@ const ExpandedBlock = (props) => { } /> - {`/ontime/goto ${eventIndex} << OSC >> /ontime/gotoid ${oscid}`} + {`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
Date: Wed, 26 May 2021 12:39:36 +0200 Subject: [PATCH 34/55] countdown to upcoming --- server/classes/EventTimer.js | 116 ++++++++++++++++++++++++++++------- server/classes/Timer.js | 4 +- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index fba7d38b0..b7c8a03f2 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -121,15 +121,16 @@ class EventTimer extends Timer { const now = this._getCurrentTime(); this.clock = now; - if (this.selectedEventId == null) { - // look for event if none is loaded - this.rollLoad(); - } else if (now > this._finishAt) { - // look for new if finished - this.rollLoad(); - } else { + if (this.selectedEventId && this.current > 0) { // update timer as usual this.current = this._finishAt - now; + } else { + // look for event if none is loaded + if (this.current <= 0 || this.secondaryTimer <= 0) this.rollLoad(); + + // count to next event + // TODO: replace with proper counter + if (this.secondaryTimer != null) this.secondaryTimer -= 1000; } } @@ -162,6 +163,9 @@ class EventTimer extends Timer { else if (payload === 'unload') this.unload(); else if (payload === 'roll') this.roll(); + // TODO: Cleanup + // here tdo this.broadcastState; + // remove broadcast from functions this.broadcastThis('playstate', this.state); this.broadcastThis('selected-id', this.selectedEventId); this.broadcastThis('titles', this.titles); @@ -577,6 +581,42 @@ class EventTimer extends Timer { } } + _loadThisTitles(eventIndex, type) { + if (eventIndex < 0 || eventIndex >= this.numEvents) return; + const e = this._eventlist[eventIndex]; + + switch (type) { + // now, load to both public and private + case 'now': + break; + case 'now-public': + break; + case 'now-private': + break; + + // next, load to both public and private + case 'next': + this.titles.titleNext = e.title; + this.titles.subtitleNext = e.subtitle; + this.titles.presenterNext = e.presenter; + this.nextEventId = e.id; + + this.titlesPublic.titleNext = e.title; + this.titlesPublic.subtitleNext = e.subtitle; + this.titlesPublic.presenterNext = e.presenter; + this.nextPublicEventId = e.id; + + break; + case 'next-public': + break; + case 'next-private': + break; + + default: + break; + } + } + _loadTitlesNext() { // maybe there is nothing to load if (this.selectedEventIndex == null) return; @@ -660,6 +700,7 @@ class EventTimer extends Timer { state = ${this.state} current = ${this.current} duration = ${this.duration} + secondaryTimer = ${this.secondaryTimer} Events ------------------------------ @@ -753,24 +794,57 @@ class EventTimer extends Timer { this._resetTimers(true); this._resetSelection(); + let foundNow = null; + let nextIndex = null; + let nextStart = null; + // loop through events, look for where we should be for (const [index, e] of this._eventlist.entries()) { - if (e.timeStart <= now && now < e.timeEnd) { - // set timers - this._startedAt = e.timeStart; - this._finishAt = e.timeEnd; - this.duration = e.timeEnd - e.timeStart; - this.current = e.timeEnd - now; + if (!foundNow) { + if (e.timeStart <= now && now < e.timeEnd) { + // set flag + foundNow = true; - // set selection - this.selectedEventId = e.id; - this.selectedEventIndex = index; + // set timers + this._startedAt = e.timeStart; + this._finishAt = e.timeEnd; + this.duration = e.timeEnd - e.timeStart; + this.current = e.timeEnd - now; - // set titles - this._loadTitlesNow(); - this._loadTitlesNext(); - // exit - break; + // set selection + this.selectedEventId = e.id; + this.selectedEventIndex = index; + + // set titles + this._loadTitlesNow(); + + // skip this entry for next + continue; + } + } + // check how far the start is from now + let wait = e.timeStart - now; + if (wait > 0) { + if (nextStart == null || wait < nextStart) { + nextStart = wait; + nextIndex = index; + } + } + } + + // nothing to play next, unload + if (!foundNow && !nextIndex) { + this.unload(); + return; + } + + if (nextIndex) { + // load titles + this._loadThisTitles(nextIndex, 'next'); + + if (!foundNow) { + // timer counts to nextStart + this.secondaryTimer = nextStart; } } } diff --git a/server/classes/Timer.js b/server/classes/Timer.js index 1f36c1f5e..bfca06682 100644 --- a/server/classes/Timer.js +++ b/server/classes/Timer.js @@ -8,6 +8,7 @@ class Timer { clock = null; duration = null; current = null; + secondaryTimer = null; _finishAt = null; _finishedAt = null; _startedAt = null; @@ -113,6 +114,7 @@ class Timer { _resetTimers(total = false) { if (total) this.duration = null; this.current = this.duration; + this.secondaryTimer = null; this._finishAt = null; this._finishedAt = null; this._startedAt = null; @@ -133,7 +135,7 @@ class Timer { return { clock: this.clock, running: Timer.toSeconds(this.current), - currentSeconds: Timer.toSeconds(Math.max(this.current, 0)), + secondary: Timer.toSeconds(this.secondaryTimer), durationSeconds: Timer.toSeconds(this.duration), expectedFinish: this._getExpectedFinish(), startedAt: this._startedAt, From f78c16fb8c94549dc8212085a56cbf2ad374dbad Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 13:10:23 +0200 Subject: [PATCH 35/55] style + cleanup --- .../src/features/control/PlaybackButtons.jsx | 5 +- .../src/features/control/PlaybackControl.jsx | 1 + .../control/PlaybackControl.module.css | 7 +- client/src/features/control/PlaybackTimer.jsx | 34 ++++++--- server/classes/EventTimer.js | 71 +++++++++++-------- 5 files changed, 74 insertions(+), 44 deletions(-) diff --git a/client/src/features/control/PlaybackButtons.jsx b/client/src/features/control/PlaybackButtons.jsx index 4b46ab07b..8958bba51 100644 --- a/client/src/features/control/PlaybackButtons.jsx +++ b/client/src/features/control/PlaybackButtons.jsx @@ -41,10 +41,7 @@ const Transport = ({ selectedId, playbackControl }) => {
playbackControl('previous')} /> playbackControl('next')} /> - playbackControl('unload')} - disabled={!selectedId} - /> + playbackControl('unload')} /> playbackControl('reload')} disabled={!selectedId} diff --git a/client/src/features/control/PlaybackControl.jsx b/client/src/features/control/PlaybackControl.jsx index d5881f9a2..af1201774 100644 --- a/client/src/features/control/PlaybackControl.jsx +++ b/client/src/features/control/PlaybackControl.jsx @@ -90,6 +90,7 @@ export default function PlaybackControl() {
socket.emit('increment-timer', amount)} /> { return ( prevProps.timer.running === nextProps.timer.running && prevProps.timer.expectedFinish === nextProps.timer.expectedFinish && - prevProps.timer.startedAt === nextProps.timer.startedAt + prevProps.timer.startedAt === nextProps.timer.startedAt && + prevProps.playback === nextProps.playback ); }; -const PlaybackTimer = ({ timer, handleIncrement }) => { +const PlaybackTimer = (props) => { + const { timer, playback, handleIncrement } = props; const started = stringFromMillis(timer.startedAt, true); const finish = stringFromMillis(timer.expectedFinish, true); const isNegative = timer.running < 0; const isDelayed = false; - const isRolling = false; + const isRolling = playback === 'roll'; const incrementProps = { size: 'sm', @@ -31,7 +33,7 @@ const PlaybackTimer = ({ timer, handleIncrement }) => { <>
-
+
@@ -49,16 +51,32 @@ const PlaybackTimer = ({ timer, handleIncrement }) => { {finish}
- - - -
diff --git a/server/classes/EventTimer.js b/server/classes/EventTimer.js index b7c8a03f2..07ee230eb 100644 --- a/server/classes/EventTimer.js +++ b/server/classes/EventTimer.js @@ -544,18 +544,12 @@ class EventTimer extends Timer { if (e == null) return; // private title is always current - this.titles.titleNow = e.title; - this.titles.subtitleNow = e.subtitle; - this.titles.presenterNow = e.presenter; - this.selectedEventId = e.id; - // check if current is also public if (e.isPublic) { - this.titlesPublic.titleNow = e.title; - this.titlesPublic.subtitleNow = e.subtitle; - this.titlesPublic.presenterNow = e.presenter; - this.selectedPublicEventId = e.id; + this._loadThisTitles(e, 'now'); } else { + this._loadThisTitles(e, 'now-private'); + // assume there is no public event this.titlesPublic.titleNow = null; this.titlesPublic.subtitleNow = null; @@ -571,45 +565,70 @@ class EventTimer extends Timer { this._eventlist[i].type === 'event' && this._eventlist[i].isPublic ) { - this.titlesPublic.titleNow = this._eventlist[i].title; - this.titlesPublic.subtitleNow = this._eventlist[i].subtitle; - this.titlesPublic.presenterNow = this._eventlist[i].presenter; - this.selectedPublicEventId = this._eventlist[i].id; + this._loadThisTitles(this._eventlist[i], 'now-public'); break; } } } } - _loadThisTitles(eventIndex, type) { - if (eventIndex < 0 || eventIndex >= this.numEvents) return; - const e = this._eventlist[eventIndex]; + _loadThisTitles(e, type) { + if (e == null) return; switch (type) { // now, load to both public and private case 'now': + // public + this.titlesPublic.titleNow = e.title; + this.titlesPublic.subtitleNow = e.subtitle; + this.titlesPublic.presenterNow = e.presenter; + this.selectedPublicEventId = e.id; + + // private + this.titles.titleNow = e.title; + this.titles.subtitleNow = e.subtitle; + this.titles.presenterNow = e.presenter; + this.selectedEventId = e.id; + break; case 'now-public': + this.titlesPublic.titleNow = e.title; + this.titlesPublic.subtitleNow = e.subtitle; + this.titlesPublic.presenterNow = e.presenter; + this.selectedPublicEventId = e.id; break; case 'now-private': + this.titles.titleNow = e.title; + this.titles.subtitleNow = e.subtitle; + this.titles.presenterNow = e.presenter; + this.selectedEventId = e.id; break; // next, load to both public and private case 'next': - this.titles.titleNext = e.title; - this.titles.subtitleNext = e.subtitle; - this.titles.presenterNext = e.presenter; - this.nextEventId = e.id; - + // public this.titlesPublic.titleNext = e.title; this.titlesPublic.subtitleNext = e.subtitle; this.titlesPublic.presenterNext = e.presenter; this.nextPublicEventId = e.id; + // private + this.titles.titleNext = e.title; + this.titles.subtitleNext = e.subtitle; + this.titles.presenterNext = e.presenter; + this.nextEventId = e.id; break; case 'next-public': + this.titlesPublic.titleNext = e.title; + this.titlesPublic.subtitleNext = e.subtitle; + this.titlesPublic.presenterNext = e.presenter; + this.nextPublicEventId = e.id; break; case 'next-private': + this.titles.titleNext = e.title; + this.titles.subtitleNext = e.subtitle; + this.titles.presenterNext = e.presenter; + this.nextEventId = e.id; break; default: @@ -641,19 +660,13 @@ class EventTimer extends Timer { if (this._eventlist[i].type === 'event') { // if we have not set private if (!nextPrivate) { - this.titles.titleNext = this._eventlist[i].title; - this.titles.subtitleNext = this._eventlist[i].subtitle; - this.titles.presenterNext = this._eventlist[i].presenter; - this.nextEventId = this._eventlist[i].id; + this._loadThisTitles(this._eventlist[i], 'next-private'); nextPrivate = true; } // if event is public if (this._eventlist[i].isPublic) { - this.titlesPublic.titleNext = this._eventlist[i].title; - this.titlesPublic.subtitleNext = this._eventlist[i].subtitle; - this.titlesPublic.presenterNext = this._eventlist[i].presenter; - this.nextPublicEventId = this._eventlist[i].id; + this._loadThisTitles(this._eventlist[i], 'next-public'); nextPublic = true; } } From c310249cd376655cf0e3361c0b6ef21bb0cc3f1a Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 20:42:05 +0200 Subject: [PATCH 36/55] fix: presenter view presenter view shows time correctly when rolling --- client/src/features/viewers/ViewWrapper.jsx | 4 ++-- client/src/features/viewers/presenter/PresenterView.jsx | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/src/features/viewers/ViewWrapper.jsx b/client/src/features/viewers/ViewWrapper.jsx index 6e198eac3..738d6acfb 100644 --- a/client/src/features/viewers/ViewWrapper.jsx +++ b/client/src/features/viewers/ViewWrapper.jsx @@ -37,7 +37,7 @@ const withSocket = (Component) => { }); const [timer, setTimer] = useState({ clock: null, - currentSeconds: null, + running: null, startedAt: null, expectedFinish: null, }); @@ -221,7 +221,7 @@ const withSocket = (Component) => { // get clock string const timeManager = { ...timer, - finished: timer.running <= 0 && timer.startedAt, + finished: playback === 'start' && timer.running <= 0 && timer.startedAt, clock: stringFromMillis(timer.clock), playstate: playback, }; diff --git a/client/src/features/viewers/presenter/PresenterView.jsx b/client/src/features/viewers/presenter/PresenterView.jsx index 5d76d6df7..53a74faab 100644 --- a/client/src/features/viewers/presenter/PresenterView.jsx +++ b/client/src/features/viewers/presenter/PresenterView.jsx @@ -14,10 +14,9 @@ export default function PresenterView(props) { document.title = 'ontime - Speaker Screen'; }, []); - - const showOverlay = pres.text !== '' && pres.visible; - const isPlaying = time.playstate === 'start'; + const isPlaying = time.playstate !== 'pause'; + const normalisedTime = Math.max(time.running, 0); // motion const titleVariants = { @@ -61,7 +60,7 @@ export default function PresenterView(props) {
TIME UP
) : (
- +
)}
@@ -73,7 +72,7 @@ export default function PresenterView(props) { } > From 0a2be1615eb3aee79520cc4a5dd9c7ee7a6c02f7 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 20:44:09 +0200 Subject: [PATCH 37/55] improve: stop list rerender react-query would trigger re-render on refetch even when using cache --- client/src/app/hooks/useFetch.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/app/hooks/useFetch.js b/client/src/app/hooks/useFetch.js index 3ddbdeaba..eceaf10d8 100644 --- a/client/src/app/hooks/useFetch.js +++ b/client/src/app/hooks/useFetch.js @@ -5,6 +5,7 @@ export const useFetch = (namespace, fn) => { const { data, status, isError, refetch } = useQuery(namespace, fn, { refetchInterval: refetchIntervalMs, cacheTime: Infinity, + notifyOnChangeProps: 'tracked', }); return { data, status, isError, refetch }; From 856f9d308ad22edcf5d45eadacc0087542a39a50 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 22:19:55 +0200 Subject: [PATCH 38/55] small style fixes + defaults - remove unused code in collapse atom - stop text input from creating new line - clean overflow - restyle lock button - restyle action buttons - 10 second default on paginator - Pip shows running timer --- client/src/App.css | 2 +- client/src/app/context/collapseAtom.js | 9 --------- client/src/common/components/buttons/LockIconBtn.jsx | 5 +++-- client/src/common/components/views/Paginator.jsx | 2 +- client/src/common/input/EditableText.jsx | 4 ++-- client/src/features/editors/Editor.module.css | 10 ++++++++-- client/src/features/editors/list/ActionButtons.jsx | 2 +- client/src/features/menu/MenuActionButtons.jsx | 2 +- client/src/features/viewers/backstage/StageManager.jsx | 2 +- client/src/features/viewers/production/Pip.jsx | 7 ++----- 10 files changed, 20 insertions(+), 25 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index b28937f73..27fa5fcf3 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -7,6 +7,6 @@ body, html, .App { margin: 0px auto; - overflow: hidden; + overflow: clip; height: 100vh; } diff --git a/client/src/app/context/collapseAtom.js b/client/src/app/context/collapseAtom.js index 57ee2a9e4..ed6b63bc3 100644 --- a/client/src/app/context/collapseAtom.js +++ b/client/src/app/context/collapseAtom.js @@ -58,12 +58,3 @@ export const BatchOperation = atom(null, (get, set, payload) => { localStorage.setItem(PATH, JSON.stringify(options)); }); - -// change collapsed in all items -export const setAll = async (items, isCollapsed) => { - // clear storage - localStorage.removeItem(PATH); - - // call batch - BatchOperation({ items: items, isCollapsed: isCollapsed }); -}; diff --git a/client/src/common/components/buttons/LockIconBtn.jsx b/client/src/common/components/buttons/LockIconBtn.jsx index d52030fd1..c499594d0 100644 --- a/client/src/common/components/buttons/LockIconBtn.jsx +++ b/client/src/common/components/buttons/LockIconBtn.jsx @@ -10,8 +10,9 @@ export default function LockIconBtn(props) { ref={ref} size={props.size || 'xs'} icon={} - colorScheme='pink' - color={'pink.300'} + color={active ? 'pink.100' : 'pink.300'} + borderColor={active ? undefined : 'pink.300'} + backgroundColor={active ? 'pink.300' : undefined} variant={active ? 'solid' : 'outline'} onClick={clickhandler} _focus={{ boxShadow: 'none' }} diff --git a/client/src/common/components/views/Paginator.jsx b/client/src/common/components/views/Paginator.jsx index 540fddf4f..9fcb89762 100644 --- a/client/src/common/components/views/Paginator.jsx +++ b/client/src/common/components/views/Paginator.jsx @@ -6,7 +6,7 @@ import { useInterval } from 'app/hooks/useInterval'; export default function Paginator(props) { const { events, selectedId } = props; const LIMIT_PER_PAGE = props.limit || 8; - const SCROLL_TIME = props.time * 1000 || 5000; + const SCROLL_TIME = props.time * 1000 || 10000; const SCROLL_PAST = false; const [numEvents, setNumEvents] = useState(0); const [page, setPage] = useState([]); diff --git a/client/src/common/input/EditableText.jsx b/client/src/common/input/EditableText.jsx index 73739d6c2..f41d8ad0a 100644 --- a/client/src/common/input/EditableText.jsx +++ b/client/src/common/input/EditableText.jsx @@ -33,9 +33,9 @@ export default function EditableText(props) { > - +
); diff --git a/client/src/features/editors/Editor.module.css b/client/src/features/editors/Editor.module.css index 3e20053c6..73563ae0c 100644 --- a/client/src/features/editors/Editor.module.css +++ b/client/src/features/editors/Editor.module.css @@ -18,11 +18,12 @@ /* 2/3 window, hide previews */ @media (max-width: 1250px) and (min-height: 700px) { .mainContainer { + height: 100%; grid-template-rows: 1fr 1fr; grid-template-columns: 48em 1fr 1fr; - grid-template-areas: + /* grid-template-areas: 'even play sett' - 'even mess sett'; + 'even mess sett'; */ } .info { @@ -40,6 +41,11 @@ 'play'; } + .messages, + .playback { + min-width: 31em; + } + .editor, .info, .settings { diff --git a/client/src/features/editors/list/ActionButtons.jsx b/client/src/features/editors/list/ActionButtons.jsx index 8c1d2bff4..ddd673728 100644 --- a/client/src/features/editors/list/ActionButtons.jsx +++ b/client/src/features/editors/list/ActionButtons.jsx @@ -17,7 +17,7 @@ export default function ActionButtons(props) { aria-label='Options' size='xs' icon={} - _expanded={{ bg: 'pink.300', color: 'white' }} + _expanded={{ bg: 'orange.300', color: 'white' }} _focus={{ boxShadow: 'none' }} backgroundColor={'orange.200'} color={'orange.500'} diff --git a/client/src/features/menu/MenuActionButtons.jsx b/client/src/features/menu/MenuActionButtons.jsx index eff31e739..4bb954e40 100644 --- a/client/src/features/menu/MenuActionButtons.jsx +++ b/client/src/features/menu/MenuActionButtons.jsx @@ -23,7 +23,7 @@ export default function MenuActionButtons(props) { aria-label='Options' size={props.size || 'xs'} icon={} - _expanded={{ bg: 'pink.300', color: 'white' }} + _expanded={{ bg: 'orange.300', color: 'white' }} _focus={{ boxShadow: 'none' }} backgroundColor={'orange.200'} color={'orange.500'} diff --git a/client/src/features/viewers/backstage/StageManager.jsx b/client/src/features/viewers/backstage/StageManager.jsx index 0fa299413..6ee5cadc8 100644 --- a/client/src/features/viewers/backstage/StageManager.jsx +++ b/client/src/features/viewers/backstage/StageManager.jsx @@ -42,7 +42,7 @@ export default function StageManager(props) { // Format messages const showPubl = publ.text !== '' && publ.visible; let stageTimer = formatDisplay(Math.abs(time.running), true); - if (time.running < 0) stageTimer = '-' + stageTimer; + if (time.running < 0) stageTimer = `-${stageTimer}`; // motion const titleVariants = { diff --git a/client/src/features/viewers/production/Pip.jsx b/client/src/features/viewers/production/Pip.jsx index c2dbe6d8d..15d6385a0 100644 --- a/client/src/features/viewers/production/Pip.jsx +++ b/client/src/features/viewers/production/Pip.jsx @@ -51,11 +51,8 @@ export default function Pip(props) { // Format messages const showInfo = general.backstageInfo !== '' && general.backstageInfo != null; - - const stageTimer = - time.currentSeconds != null && !isNaN(time.currentSeconds) - ? formatDisplay(time.currentSeconds, true) - : ''; + let stageTimer = formatDisplay(Math.abs(time.running), true); + if (time.running < 0) stageTimer = `-${stageTimer}`; return (
From 7c67d2e57d7c8cae3ad898c20f0ada78493b7339 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 22:33:14 +0200 Subject: [PATCH 39/55] update lib --- server/package.json | 2 +- server/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package.json b/server/package.json index 45025a56a..8ecb67af8 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,7 @@ "express-session": "~1.17.1", "lowdb": "^1.0.0", "nanoid": "^3.1.22", - "node-osc": "^6.0.1", + "node-osc": "6.0.2", "passport": "~0.4.1", "passport-local": "~1.0.0", "socket.io": "^4.0.0" diff --git a/server/yarn.lock b/server/yarn.lock index 4b7da8610..9778a0c2f 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -380,10 +380,10 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -node-osc@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/node-osc/-/node-osc-6.0.1.tgz#6f2c39996c64460435b678dc91c128d3a9711031" - integrity sha512-UZBQGH4vVTw3Ub5/xcnhlrhL6bpaYz3hhNjO4eup4tknyP5qOi2GVVnEmEn/qaTgTvs7SVyytj3OHg9JWRD4Aw== +node-osc@6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/node-osc/-/node-osc-6.0.2.tgz#185d387928b186a84e8d0eebcae9e54a8bf50729" + integrity sha512-RW8PRLMh4H14veC5gZVUTcRkrk5pXd+b4vmitWL55c9ZI+5yEvMXkrokfnRH0u7DYveqw4h+uCPIUDifY/vjfA== dependencies: osc-min "^1.1.1" From b61a82627e8aeac13906df6dabf16424e7837a52 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Wed, 26 May 2021 23:10:00 +0200 Subject: [PATCH 40/55] feat: download - able to download file from express --- client/src/app/api/eventsApi.js | 15 +++++++++++++++ .../components/buttons/DownloadIconBtn.jsx | 18 ++++++++++++++++++ client/src/features/editors/Editor.jsx | 8 ++++++++ client/src/features/editors/Editor.module.css | 6 ++++++ server/controllers/ontimeController.js | 11 +++++++++++ server/routes/ontimeRouter.js | 5 +++++ 6 files changed, 63 insertions(+) create mode 100644 client/src/common/components/buttons/DownloadIconBtn.jsx create mode 100644 server/controllers/ontimeController.js create mode 100644 server/routes/ontimeRouter.js diff --git a/client/src/app/api/eventsApi.js b/client/src/app/api/eventsApi.js index 17a2ada40..a185a54be 100644 --- a/client/src/app/api/eventsApi.js +++ b/client/src/app/api/eventsApi.js @@ -1,6 +1,21 @@ import axios from 'axios'; import { eventsURL } from '../api/apiConstants'; +export const downloadEvents = async () => { + await axios({ + url: eventsURL + '/download', + method: 'GET', + responseType: 'blob', // important + }).then((response) => { + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'events.json'); + document.body.appendChild(link); + link.click(); + }); +}; + export const fetchAllEvents = async () => { const res = await axios.get(eventsURL); return res.data; diff --git a/client/src/common/components/buttons/DownloadIconBtn.jsx b/client/src/common/components/buttons/DownloadIconBtn.jsx new file mode 100644 index 000000000..8b30023b4 --- /dev/null +++ b/client/src/common/components/buttons/DownloadIconBtn.jsx @@ -0,0 +1,18 @@ +import { IconButton } from '@chakra-ui/button'; +import { FiDownload } from 'react-icons/fi'; + +export default function DownloadIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + } + isRound + variant='outline' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + colorScheme='whiteAlpha' + {...rest} + /> + ); +} diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index bc727071b..c25a67f11 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -9,6 +9,8 @@ import { useDisclosure } from '@chakra-ui/hooks'; import SettingsModal from '../modals/SettingsModal'; import SettingsIconBtn from 'common/components/buttons/SettingsIconBtn'; import { useEffect } from 'react'; +import DownloadIconBtn from 'common/components/buttons/DownloadIconBtn'; +import { downloadEvents } from 'app/api/eventsApi'; export default function Editor() { const { isOpen, onOpen, onClose } = useDisclosure(); @@ -18,6 +20,11 @@ export default function Editor() { document.title = 'ontime - Editor'; }, []); + const handleDownload = () => { + console.log('debug download'); + downloadEvents(); + }; + return ( <> @@ -67,6 +74,7 @@ export default function Editor() {
+
diff --git a/client/src/features/editors/Editor.module.css b/client/src/features/editors/Editor.module.css index 3e20053c6..d29ab4a52 100644 --- a/client/src/features/editors/Editor.module.css +++ b/client/src/features/editors/Editor.module.css @@ -101,6 +101,12 @@ padding-top: 1.5em; } +.settings > .content { + display: flex; + flex-direction: column; + gap: 1em; +} + .cornerButtonContainer { position: relative; top: -4.5em; diff --git a/server/controllers/ontimeController.js b/server/controllers/ontimeController.js new file mode 100644 index 000000000..c7f3a55d2 --- /dev/null +++ b/server/controllers/ontimeController.js @@ -0,0 +1,11 @@ +// Create controller for GET request to '/ontime/download' +// Returns - +export const eventsDownload = async (req, res) => { + res.download('db.json', 'ontime events.json', (err) => { + if (err) { + res.status(500).send({ + message: 'Could not download the file. ' + err, + }); + } + }); +}; diff --git a/server/routes/ontimeRouter.js b/server/routes/ontimeRouter.js new file mode 100644 index 000000000..c45d1b3d7 --- /dev/null +++ b/server/routes/ontimeRouter.js @@ -0,0 +1,5 @@ +import express from 'express'; +export const router = express.Router(); + +// create route between controller and '/ontime/download' endpoint +router.get('/download', eventsDownload); From 9314594051ca772aca0627cc9d1a5dd6fc46b51d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 27 May 2021 15:01:34 +0200 Subject: [PATCH 41/55] handle error messages --- server/controllers/OscController.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/controllers/OscController.js b/server/controllers/OscController.js index dba6d9cc1..e4ddcb983 100644 --- a/server/controllers/OscController.js +++ b/server/controllers/OscController.js @@ -5,6 +5,9 @@ const initiateOSC = (config) => { console.log(`OSC Server is listening on port ${config.port}`); }); + // error + oscServer.on('error', console.error); + oscServer.on('message', function (msg) { // message should look like /ontime/{path}/{args} where // ontime: fixed message for app @@ -62,8 +65,8 @@ const initiateOSC = (config) => { console.log('calling goto with', args); try { let eventIndex = parseInt(args); - if (isNaN(eventIndex) || eventIndex <= 0) return; - global.timer.pause(); + if (isNaN(eventIndex) || eventIndex <= 0 || eventIndex == null) + return; global.timer.loadEvent(eventIndex - 1, undefined, true); } catch (error) { console.log('error calling goto: ', error); @@ -71,6 +74,7 @@ const initiateOSC = (config) => { break; case 'gotoid': console.log('calling gotoid with', args); + if (args == null) return; try { global.timer.loadEventById(args.toString().toLowerCase()); } catch (error) { From f887817a68592bc90c93ce1368c396e19d9bc825 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 27 May 2021 22:56:49 +0200 Subject: [PATCH 42/55] setup router + tidy up --- client/src/app/api/apiConstants.js | 1 + client/src/app/api/eventsApi.js | 15 --------------- client/src/app/api/ontimeApi.js | 17 +++++++++++++++++ server/app.js | 11 +++++++---- server/controllers/ontimeController.js | 9 +++++++-- server/routes/ontimeRouter.js | 6 ++++-- 6 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 client/src/app/api/ontimeApi.js diff --git a/client/src/app/api/apiConstants.js b/client/src/app/api/apiConstants.js index 33a92277f..ba1a35c67 100644 --- a/client/src/app/api/apiConstants.js +++ b/client/src/app/api/apiConstants.js @@ -10,3 +10,4 @@ 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'; diff --git a/client/src/app/api/eventsApi.js b/client/src/app/api/eventsApi.js index a185a54be..17a2ada40 100644 --- a/client/src/app/api/eventsApi.js +++ b/client/src/app/api/eventsApi.js @@ -1,21 +1,6 @@ import axios from 'axios'; import { eventsURL } from '../api/apiConstants'; -export const downloadEvents = async () => { - await axios({ - url: eventsURL + '/download', - method: 'GET', - responseType: 'blob', // important - }).then((response) => { - const url = window.URL.createObjectURL(new Blob([response.data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', 'events.json'); - document.body.appendChild(link); - link.click(); - }); -}; - export const fetchAllEvents = async () => { const res = await axios.get(eventsURL); return res.data; diff --git a/client/src/app/api/ontimeApi.js b/client/src/app/api/ontimeApi.js new file mode 100644 index 000000000..188981ddb --- /dev/null +++ b/client/src/app/api/ontimeApi.js @@ -0,0 +1,17 @@ +import axios from 'axios'; +import { ontimeURL } from './apiConstants'; + +export const downloadEvents = async () => { + await axios({ + url: ontimeURL + '/db', + method: 'GET', + responseType: 'blob', // important + }).then((response) => { + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'events.json'); + document.body.appendChild(link); + link.click(); + }); +}; diff --git a/server/app.js b/server/app.js index 034e11e90..c8e3bace4 100644 --- a/server/app.js +++ b/server/app.js @@ -11,6 +11,7 @@ export const db = new Low(adapter); import express from 'express'; import http from 'http'; import cors from 'cors'; +import multer from 'multer'; import { dbModel } from './data/dataModel.js'; // Read data from JSON file, this will set db.data content @@ -30,9 +31,7 @@ export const data = db.data; // Import Routes import { router as eventsRouter } from './routes/eventsRouter.js'; import { router as eventRouter } from './routes/eventRouter.js'; - -// No settings yet -// const settingsRouter = require('./routes/settingsRouter.js'); +import { router as ontimeRouter } from './routes/ontimeRouter.js'; // Setup default port const port = process.env.PORT || config.server.port; @@ -50,12 +49,16 @@ app.use(cors()); app.options('*', cors()); // Implement middleware +app.use(express.static('./public')); +app.use('/uploads', express.static('uploads')); + app.use(express.urlencoded({ extended: true })); -app.use(express.json()); +app.use(express.json({ limit: '1mb' })); // Implement route endpoints app.use('/events', eventsRouter); app.use('/event', eventRouter); +app.use('/ontime', ontimeRouter); // implement general router app.get('/', (req, res) => { diff --git a/server/controllers/ontimeController.js b/server/controllers/ontimeController.js index c7f3a55d2..4778e7e21 100644 --- a/server/controllers/ontimeController.js +++ b/server/controllers/ontimeController.js @@ -1,7 +1,12 @@ // Create controller for GET request to '/ontime/download' +import { db, data } from '../app.js'; +function getEventTitle() { + return data.event.title; +} // Returns - -export const eventsDownload = async (req, res) => { - res.download('db.json', 'ontime events.json', (err) => { +export const dbDownload = async (req, res) => { + const fileTitle = getEventTitle() || 'ontime events'; + res.download('db.json', `${fileTitle}.json`, (err) => { if (err) { res.status(500).send({ message: 'Could not download the file. ' + err, diff --git a/server/routes/ontimeRouter.js b/server/routes/ontimeRouter.js index c45d1b3d7..d07d9867f 100644 --- a/server/routes/ontimeRouter.js +++ b/server/routes/ontimeRouter.js @@ -1,5 +1,7 @@ import express from 'express'; export const router = express.Router(); -// create route between controller and '/ontime/download' endpoint -router.get('/download', eventsDownload); +import { dbDownload, dbUpload } from '../controllers/ontimeController.js'; + +// create route between controller and '/ontime/db' endpoint +router.get('/db', dbDownload); From e891e7a5385367c992097ab7748c6017c8a427fd Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Thu, 27 May 2021 23:00:32 +0200 Subject: [PATCH 43/55] feat: upload file --- client/src/app/api/ontimeApi.js | 14 +++ client/src/features/editors/Editor.jsx | 33 +++++- server/app.js | 2 - server/controllers/ontimeController.js | 60 +++++++++- server/package.json | 1 + server/routes/ontimeRouter.js | 4 + server/utils/upload.js | 23 ++++ server/yarn.lock | 147 ++++++++++++++++++++++++- 8 files changed, 272 insertions(+), 12 deletions(-) create mode 100644 server/utils/upload.js diff --git a/client/src/app/api/ontimeApi.js b/client/src/app/api/ontimeApi.js index 188981ddb..fa41a6bcf 100644 --- a/client/src/app/api/ontimeApi.js +++ b/client/src/app/api/ontimeApi.js @@ -15,3 +15,17 @@ export const downloadEvents = async () => { link.click(); }); }; + +export const uploadEvents = async (file) => { + console.log('uploading', file); + const formData = new FormData(); + formData.append('jsondb', file); // appending file + await axios + .post(ontimeURL + '/db', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then((res) => console.log(res.data)) + .catch((err) => console.error(err)); +}; diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index c25a67f11..21b7a0a9c 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -8,12 +8,13 @@ import EventListWrapper from './list/EventListWrapper'; import { useDisclosure } from '@chakra-ui/hooks'; import SettingsModal from '../modals/SettingsModal'; import SettingsIconBtn from 'common/components/buttons/SettingsIconBtn'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import DownloadIconBtn from 'common/components/buttons/DownloadIconBtn'; -import { downloadEvents } from 'app/api/eventsApi'; +import { downloadEvents, uploadEvents } from 'app/api/ontimeApi'; export default function Editor() { const { isOpen, onOpen, onClose } = useDisclosure(); + const [file, setFile] = useState(null); // Set window title useEffect(() => { @@ -21,10 +22,22 @@ export default function Editor() { }, []); const handleDownload = () => { - console.log('debug download'); downloadEvents(); }; + const handleFile = (e) => { + setFile(e.target.files[0]); + }; + + const handleUpload = (e) => { + e.preventDefault(); + console.log('gonna upload', file); + + if (file && file.type === 'application/json') { + if (file.size < 1048576) uploadEvents(file); + } + }; + return ( <> @@ -68,7 +81,19 @@ export default function Editor() { Info -
+
+
+ + +
+
diff --git a/server/app.js b/server/app.js index c8e3bace4..6a1acc63e 100644 --- a/server/app.js +++ b/server/app.js @@ -11,7 +11,6 @@ export const db = new Low(adapter); import express from 'express'; import http from 'http'; import cors from 'cors'; -import multer from 'multer'; import { dbModel } from './data/dataModel.js'; // Read data from JSON file, this will set db.data content @@ -49,7 +48,6 @@ app.use(cors()); app.options('*', cors()); // Implement middleware -app.use(express.static('./public')); app.use('/uploads', express.static('uploads')); app.use(express.urlencoded({ extended: true })); diff --git a/server/controllers/ontimeController.js b/server/controllers/ontimeController.js index 4778e7e21..a87d9aebc 100644 --- a/server/controllers/ontimeController.js +++ b/server/controllers/ontimeController.js @@ -1,8 +1,30 @@ -// Create controller for GET request to '/ontime/download' +// get database import { db, data } from '../app.js'; +import fs from 'fs'; + function getEventTitle() { return data.event.title; } + +async function deleteFile(file) { + // delete a file + fs.unlink(file, (err) => { + if (err) { + console.log(err); + } + }); +} + +// parses version 1 of the data system +async function parsev1(jsonData) { + // loop through events + // doublecheck unique ids + // merge to an event definition + // merge event info + // merge settings +} + +// Create controller for GET request to '/ontime/db' // Returns - export const dbDownload = async (req, res) => { const fileTitle = getEventTitle() || 'ontime events'; @@ -14,3 +36,39 @@ export const dbDownload = async (req, res) => { } }); }; + +// Create controller for POST request to '/ontime/db' +// Returns - +export const dbUpload = async (req, res) => { + if (!req.file) { + res.status(400).send({ message: 'File not found' }); + return; + } + + const file = req.file.path; + if (!fs.existsSync(file)) { + res.status(500).send({ message: 'Upload failed' }); + return; + } + + try { + // get file + let rawdata = fs.readFileSync(file); + let uploadedJson = JSON.parse(rawdata); + + // delete file + deleteFile(file); + + // check version + if (uploadedJson.settings.version === 1) parsev1(uploadedJson); + else { + res.status(400).send({ message: 'Error parsing file, version unknown' }); + return; + } + + res.sendStatus(200); + } catch (error) { + console.log('Error parsing file', error); + res.status(400).send({ message: error }); + } +}; diff --git a/server/package.json b/server/package.json index 5597927f8..fbe1ad4ae 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,7 @@ "express": "~4.17.1", "express-session": "~1.17.1", "lowdb": "2.1.0", + "multer": "^1.4.2", "nanoid": "^3.1.22", "node-osc": "^6.0.1", "passport": "~0.4.1", diff --git a/server/routes/ontimeRouter.js b/server/routes/ontimeRouter.js index d07d9867f..e5b5e5670 100644 --- a/server/routes/ontimeRouter.js +++ b/server/routes/ontimeRouter.js @@ -1,7 +1,11 @@ import express from 'express'; +import uploadJson from '../utils/upload.js'; export const router = express.Router(); import { dbDownload, dbUpload } from '../controllers/ontimeController.js'; // create route between controller and '/ontime/db' endpoint router.get('/db', dbDownload); + +// create route between controller and '/ontime/db' endpoint +router.post('/db', uploadJson, dbUpload); diff --git a/server/utils/upload.js b/server/utils/upload.js new file mode 100644 index 000000000..f53a492c0 --- /dev/null +++ b/server/utils/upload.js @@ -0,0 +1,23 @@ +import multer from 'multer'; + +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, './uploads'); + }, + filename: function (req, file, cb) { + cb(null, Date.now() + '--' + file.originalname); + }, +}); + +// filter only json +const filterJson = (req, file, cb) => { + if (file.mimetype.includes('application/json')) { + cb(null, true); + } else { + cb(null, false); + } +}; + +const uploadJson = multer({ storage: storage, fileFilter: filterJson }); + +export default uploadJson.single('jsondb'); diff --git a/server/yarn.lock b/server/yarn.lock index 7ef514f68..2e29b0705 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -30,6 +30,11 @@ accepts@~1.3.4, accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY= + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -66,6 +71,19 @@ body-parser@1.19.0, body-parser@~1.19.0: raw-body "2.4.0" type-is "~1.6.17" +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM= + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + bytes@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" @@ -76,6 +94,16 @@ component-emitter@~1.3.0: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -103,6 +131,11 @@ cookie@~0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -140,6 +173,14 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8= + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -287,7 +328,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -inherits@2.0.4: +inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -297,6 +338,16 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + lowdb@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-2.1.0.tgz#c8063e228b5ab3e082ece90e0512537ecb6e1e2a" @@ -336,6 +387,18 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +mkdirp@^0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -351,6 +414,20 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +multer@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a" + integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.1" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + nanoid@^3.1.22: version "3.1.22" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844" @@ -368,12 +445,12 @@ node-osc@^6.0.1: dependencies: osc-min "^1.1.1" -object-assign@^4: +object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= -on-finished@~2.3.0: +on-finished@^2.3.0, on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= @@ -427,6 +504,11 @@ pause@0.0.1: resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + proxy-addr@~2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" @@ -460,7 +542,30 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -safe-buffer@5.1.2: +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.2.2: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -548,12 +653,29 @@ steno@^1.0.0: resolved "https://registry.yarnpkg.com/steno/-/steno-1.0.0.tgz#475e32c6066ec9760229eaaf1550601764fbecba" integrity sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw== +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== -type-is@~1.6.17, type-is@~1.6.18: +type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -561,6 +683,11 @@ type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + uid-safe@~2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" @@ -573,6 +700,11 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -587,3 +719,8 @@ ws@~7.4.2: version "7.4.4" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From 1491fbbb97e60fa9cdae7c4a8148c67d5f7a503c Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 00:02:31 +0200 Subject: [PATCH 44/55] parse uploaded file --- server/controllers/ontimeController.js | 64 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/server/controllers/ontimeController.js b/server/controllers/ontimeController.js index a87d9aebc..7dabf952e 100644 --- a/server/controllers/ontimeController.js +++ b/server/controllers/ontimeController.js @@ -1,6 +1,12 @@ // get database import { db, data } from '../app.js'; import fs from 'fs'; +import { + event as eventDef, + delay as delayDef, + block as blockDef, +} from '../data/eventsDefinition.js'; +import { dbModel } from '../data/dataModel.js'; function getEventTitle() { return data.event.title; @@ -17,11 +23,59 @@ async function deleteFile(file) { // parses version 1 of the data system async function parsev1(jsonData) { - // loop through events - // doublecheck unique ids - // merge to an event definition - // merge event info - // merge settings + if ('events' in jsonData) { + let events = []; + let ids = []; + for (const e of jsonData.events) { + if (e.type === 'event') { + // doublecheck unique ids + if (e.id == null || ids.indexOf(e.id) !== -1) continue; + ids.push(e.id); + + // make sure all properties exits + // dont load any extra properties than the ones known + events.push({ + ...eventDef, + title: e.title, + subtitle: e.subtitle, + presenter: e.presenter, + note: e.note, + timeStart: e.timeStart, + timeEnd: e.timeEnd, + isPublic: e.isPublic, + id: e.id, + }); + } else if (e.type === 'delay') { + events.push({ ...delayDef, duration: e.duration }); + } else if (e.type === 'block') { + events.push({ ...blockDef }); + } + } + // write to db + db.data.events = events; + db.write(); + } + + if ('event' in jsonData) { + const e = jsonData.event; + // filter known properties + const event = { + ...dbModel.event, + title: e.title, + url: e.url, + publicInfo: e.publicInfo, + backstageInfo: e.backstageInfo, + }; + console.log(event); + // write to db + db.data.event = event; + db.write(); + } + + // Not handling settings yet + // let settings = {}; + // if ('settings' in jsonData) { + // } } // Create controller for GET request to '/ontime/db' From aa53f2c9cfa381de3e8f2d25d863688fc4c45f76 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 13:16:34 +0200 Subject: [PATCH 45/55] folder restructure --- server/data/eventsData.json | 57 ------------------- server/jsconfig.json | 3 + server/{ => src}/app.js | 2 +- server/{ => src}/classes/EventTimer.js | 0 server/{ => src}/classes/Timer.js | 0 server/{ => src/config}/config.js | 0 server/{ => src}/controllers/OscController.js | 0 .../{ => src}/controllers/eventController.js | 0 .../{ => src}/controllers/eventsController.js | 0 .../{ => src}/controllers/ontimeController.js | 2 +- .../controllers/playbackController.js | 22 +++---- server/{ => src}/data/dataModel.js | 0 server/{ => src}/data/eventsDefinition.js | 0 server/{ => src}/routes/eventRouter.js | 0 server/{ => src}/routes/eventsRouter.js | 0 server/{ => src}/routes/ontimeRouter.js | 0 server/{ => src}/routes/playbackRouter.js | 0 server/{ => src}/utils/upload.js | 2 +- 18 files changed, 17 insertions(+), 71 deletions(-) delete mode 100644 server/data/eventsData.json create mode 100644 server/jsconfig.json rename server/{ => src}/app.js (97%) rename server/{ => src}/classes/EventTimer.js (100%) rename server/{ => src}/classes/Timer.js (100%) rename server/{ => src/config}/config.js (100%) rename server/{ => src}/controllers/OscController.js (100%) rename server/{ => src}/controllers/eventController.js (100%) rename server/{ => src}/controllers/eventsController.js (100%) rename server/{ => src}/controllers/ontimeController.js (99%) rename server/{ => src}/controllers/playbackController.js (71%) rename server/{ => src}/data/dataModel.js (100%) rename server/{ => src}/data/eventsDefinition.js (100%) rename server/{ => src}/routes/eventRouter.js (100%) rename server/{ => src}/routes/eventsRouter.js (100%) rename server/{ => src}/routes/ontimeRouter.js (100%) rename server/{ => src}/routes/playbackRouter.js (100%) rename server/{ => src}/utils/upload.js (94%) diff --git a/server/data/eventsData.json b/server/data/eventsData.json deleted file mode 100644 index f5aadad75..000000000 --- a/server/data/eventsData.json +++ /dev/null @@ -1,57 +0,0 @@ -[ - { - "id":"xxxxx0", - "duration": 300000, - "type": "delay" - }, - { - "id":"xxxxx1", - "title": "Is the internet a fad?", - "subtitle": "It is", - "presenter": "Carlos Valente", - "timeStart": 32400000, - "timeEnd": 34200000, - "type": "event" - }, - { - "id":"xxxxx2", - "duration": 1500000, - "type": "delay" - }, - { - "id":"xxxxx3", - "title": "Is reddit a dictatorship?", - "subtitle": "It is", - "presenter": "Carlos Valente", - "timeStart": 34200000, - "timeEnd": 36000000, - "type": "event" - }, - { - "id":"xxxxx4", - "title": "Out of words", - "subtitle": "", - "presenter": "Carlos Valente", - "timeStart": 36000000, - "timeEnd": 39600000, - "type": "event" - }, - { - "id":"xxxxx5", - "title": "Really", - "subtitle": "", - "presenter": "Carlos Valente", - "timeStart": 39600000, - "timeEnd": 41400000, - "type": "event" - }, - { - "id":"xxxxx6", - "title": "...", - "subtitle": "", - "presenter": "Carlos Valente", - "timeStart": null, - "timeEnd": null, - "type": "event" - } -] \ No newline at end of file diff --git a/server/jsconfig.json b/server/jsconfig.json new file mode 100644 index 000000000..bbf408cd7 --- /dev/null +++ b/server/jsconfig.json @@ -0,0 +1,3 @@ +{ + "exclude": ["node_modules", "dist"] +} diff --git a/server/app.js b/server/src/app.js similarity index 97% rename from server/app.js rename to server/src/app.js index 6a1acc63e..a7882c50d 100644 --- a/server/app.js +++ b/server/src/app.js @@ -1,5 +1,5 @@ // get config -import { config } from './config.js'; +import { config } from './config/config.js'; // init database import { Low, JSONFile } from 'lowdb'; diff --git a/server/classes/EventTimer.js b/server/src/classes/EventTimer.js similarity index 100% rename from server/classes/EventTimer.js rename to server/src/classes/EventTimer.js diff --git a/server/classes/Timer.js b/server/src/classes/Timer.js similarity index 100% rename from server/classes/Timer.js rename to server/src/classes/Timer.js diff --git a/server/config.js b/server/src/config/config.js similarity index 100% rename from server/config.js rename to server/src/config/config.js diff --git a/server/controllers/OscController.js b/server/src/controllers/OscController.js similarity index 100% rename from server/controllers/OscController.js rename to server/src/controllers/OscController.js diff --git a/server/controllers/eventController.js b/server/src/controllers/eventController.js similarity index 100% rename from server/controllers/eventController.js rename to server/src/controllers/eventController.js diff --git a/server/controllers/eventsController.js b/server/src/controllers/eventsController.js similarity index 100% rename from server/controllers/eventsController.js rename to server/src/controllers/eventsController.js diff --git a/server/controllers/ontimeController.js b/server/src/controllers/ontimeController.js similarity index 99% rename from server/controllers/ontimeController.js rename to server/src/controllers/ontimeController.js index 7dabf952e..2d74bb73f 100644 --- a/server/controllers/ontimeController.js +++ b/server/src/controllers/ontimeController.js @@ -66,7 +66,7 @@ async function parsev1(jsonData) { publicInfo: e.publicInfo, backstageInfo: e.backstageInfo, }; - console.log(event); + // write to db db.data.event = event; db.write(); diff --git a/server/controllers/playbackController.js b/server/src/controllers/playbackController.js similarity index 71% rename from server/controllers/playbackController.js rename to server/src/controllers/playbackController.js index 4dcc45cb3..b8a72f5a5 100644 --- a/server/controllers/playbackController.js +++ b/server/src/controllers/playbackController.js @@ -1,64 +1,64 @@ // Create controller for GET request to '/playback' // Returns ACK message -exports.pbGet = async (req, res) => { +export const pbGet = async (req, res) => { res.send(global.timer.playState); }; // Create controller for GET request to '/playback/start' // Starts timer object -exports.pbStart = async (req, res) => { +export const pbStart = async (req, res) => { global.timer.start(); res.sendStatus(200); }; // Create controller for GET request to '/playback/pause' // Pauses timer object -exports.pbPause = async (req, res) => { +export const pbPause = async (req, res) => { global.timer.pause(); res.sendStatus(200); }; // Create controller for GET request to '/playback/stop' // Stops timer object -exports.pbStop = async (req, res) => { +export const pbStop = async (req, res) => { global.timer.stop(); res.sendStatus(200); }; // Create controller for GET request to '/playback/roll' // Sets timer object to roll mode -exports.pbRoll = async (req, res) => { +export const pbRoll = async (req, res) => { global.timer.roll(); res.sendStatus(501); }; // Create controller for GET request to '/playback/previous' // Sets timer object to roll mode -exports.pbPrevious = async (req, res) => { +export const pbPrevious = async (req, res) => { global.timer.previous(); res.sendStatus(200); }; // Create controller for GET request to '/playback/next' // Sets timer object to roll mode -exports.pbNext = async (req, res) => { +export const pbNext = async (req, res) => { global.timer.next(); res.sendStatus(200); }; // Create controller for GET request to '/playback/unload' // Unloads any events -exports.pbUnload = async (req, res) => { +export const pbUnload = async (req, res) => { global.timer.unload(); - console.log('debug: unload called') + console.log('debug: unload called'); res.sendStatus(200); }; // Create controller for GET request to '/playback/reload' // Reloads current event -exports.pbReload = async (req, res) => { +export const pbReload = async (req, res) => { global.timer.reload(); - console.log('debug: reload called') + console.log('debug: reload called'); res.sendStatus(200); }; diff --git a/server/data/dataModel.js b/server/src/data/dataModel.js similarity index 100% rename from server/data/dataModel.js rename to server/src/data/dataModel.js diff --git a/server/data/eventsDefinition.js b/server/src/data/eventsDefinition.js similarity index 100% rename from server/data/eventsDefinition.js rename to server/src/data/eventsDefinition.js diff --git a/server/routes/eventRouter.js b/server/src/routes/eventRouter.js similarity index 100% rename from server/routes/eventRouter.js rename to server/src/routes/eventRouter.js diff --git a/server/routes/eventsRouter.js b/server/src/routes/eventsRouter.js similarity index 100% rename from server/routes/eventsRouter.js rename to server/src/routes/eventsRouter.js diff --git a/server/routes/ontimeRouter.js b/server/src/routes/ontimeRouter.js similarity index 100% rename from server/routes/ontimeRouter.js rename to server/src/routes/ontimeRouter.js diff --git a/server/routes/playbackRouter.js b/server/src/routes/playbackRouter.js similarity index 100% rename from server/routes/playbackRouter.js rename to server/src/routes/playbackRouter.js diff --git a/server/utils/upload.js b/server/src/utils/upload.js similarity index 94% rename from server/utils/upload.js rename to server/src/utils/upload.js index f53a492c0..e489ac26f 100644 --- a/server/utils/upload.js +++ b/server/src/utils/upload.js @@ -2,7 +2,7 @@ import multer from 'multer'; const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, './uploads'); + cb(null, 'uploads/'); }, filename: function (req, file, cb) { cb(null, Date.now() + '--' + file.originalname); From 3c436007de4b1741810b44d22e98be8189c14856 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 13:18:03 +0200 Subject: [PATCH 46/55] cleanup - removing upload for now, should go somewhere else --- client/src/features/editors/Editor.jsx | 32 +++----------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index 21b7a0a9c..586f2bd2e 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -8,13 +8,12 @@ import EventListWrapper from './list/EventListWrapper'; import { useDisclosure } from '@chakra-ui/hooks'; import SettingsModal from '../modals/SettingsModal'; import SettingsIconBtn from 'common/components/buttons/SettingsIconBtn'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import DownloadIconBtn from 'common/components/buttons/DownloadIconBtn'; -import { downloadEvents, uploadEvents } from 'app/api/ontimeApi'; +import { downloadEvents } from 'app/api/ontimeApi'; export default function Editor() { const { isOpen, onOpen, onClose } = useDisclosure(); - const [file, setFile] = useState(null); // Set window title useEffect(() => { @@ -25,19 +24,6 @@ export default function Editor() { downloadEvents(); }; - const handleFile = (e) => { - setFile(e.target.files[0]); - }; - - const handleUpload = (e) => { - e.preventDefault(); - console.log('gonna upload', file); - - if (file && file.type === 'application/json') { - if (file.size < 1048576) uploadEvents(file); - } - }; - return ( <> @@ -81,19 +67,7 @@ export default function Editor() { Info -
-
- - -
-
+
From 59341669061ce598c03d2d3ca11efc3a01a9ceca Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 13:51:34 +0200 Subject: [PATCH 47/55] get name from header - attempt getting name from header. will depend on security policies --- client/src/app/api/ontimeApi.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/client/src/app/api/ontimeApi.js b/client/src/app/api/ontimeApi.js index fa41a6bcf..4ebc5f73b 100644 --- a/client/src/app/api/ontimeApi.js +++ b/client/src/app/api/ontimeApi.js @@ -7,10 +7,21 @@ export const downloadEvents = async () => { method: 'GET', responseType: 'blob', // important }).then((response) => { + let headerLine = response.headers['Content-Disposition']; + console.log(response); + let filename = 'events.json'; + + // try and get the filename from the response + if (headerLine != null) { + let startFileNameIndex = headerLine.indexOf('"') + 1; + let endFileNameIndex = headerLine.lastIndexOf('"'); + filename = headerLine.substring(startFileNameIndex, endFileNameIndex); + } + const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement('a'); link.href = url; - link.setAttribute('download', 'events.json'); + link.setAttribute('download', filename); document.body.appendChild(link); link.click(); }); From a47dfea56b225ae0268439a7d0bb9ecca5f3ea1f Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 14:00:13 +0200 Subject: [PATCH 48/55] refract: tidy up structure - db in folder --- .gitignore | 2 +- server/src/app.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index caa6f98fa..d771a4658 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ yarn.lock package.json package-lock.json db.json -server/db backup.json +db backup.json diff --git a/server/src/app.js b/server/src/app.js index a7882c50d..e6d4dd214 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -3,8 +3,10 @@ import { config } from './config/config.js'; // init database import { Low, JSONFile } from 'lowdb'; +import { join } from 'path'; -const adapter = new JSONFile(config.database.filename); +const file = join('data/', config.database.filename); +const adapter = new JSONFile(file); export const db = new Low(adapter); // dependencies From 0d78038797ca8883c12833be3f667e77dc5ee85f Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 14:22:40 +0200 Subject: [PATCH 49/55] Update package.json - yarn start correct file --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index fbe1ad4ae..2b399881d 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,6 @@ "socket.io": "^4.0.0" }, "scripts": { - "start": "node app.js" + "start": "node src/app.js" } } From 8624e2c383c9ac79f3606eacc444cc1986622f86 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 14:56:00 +0200 Subject: [PATCH 50/55] add data folder --- server/data/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 server/data/.gitkeep diff --git a/server/data/.gitkeep b/server/data/.gitkeep new file mode 100644 index 000000000..e69de29bb From 3d69dbab130551141aa96b413eaa1f4ffb6f2af5 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 15:00:45 +0200 Subject: [PATCH 51/55] fix: rename to .keep play nice with .git --- server/data/{.gitkeep => .keep} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/data/{.gitkeep => .keep} (100%) diff --git a/server/data/.gitkeep b/server/data/.keep similarity index 100% rename from server/data/.gitkeep rename to server/data/.keep From 81e3b0abbfc7ded326668ed40fbcd7824114012d Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Fri, 28 May 2021 23:59:08 +0200 Subject: [PATCH 52/55] cursor fixes - delete would use wrong index to relocate - cursor uses eventId instead of eventIndex - cursor would move on any item changes --- .../src/features/editors/list/EventList.jsx | 57 ++++++++++--------- server/src/classes/EventTimer.js | 2 +- server/src/controllers/eventsController.js | 4 +- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index 1ff4772c4..befda8cec 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -11,8 +11,8 @@ import { SelectSetting } from 'app/context/settingsAtom'; export default function EventList(props) { const { events, eventsHandler } = props; const socket = useSocket(); - const [selected, setSelected] = useState(null); - const [next, setNext] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [nextId, setNextId] = useState(null); const [cursor, setCursor] = useState(0); const [cursorSettings] = useAtom(useMemo(() => SelectSetting('cursor'), [])); @@ -61,10 +61,11 @@ export default function EventList(props) { // Handle playstate socket.on('selected', (data) => { - setSelected(data); + setSelectedId(data.id); }); + socket.on('next-id', (data) => { - setNext(data); + setNextId(data); }); // Clear listener @@ -74,32 +75,36 @@ export default function EventList(props) { }; }, [socket]); - // attach cursor to selected + // when cursor moves, view should follow useEffect(() => { - if (cursorSettings !== 'locked' || selected == null) return; - if (selected.index == null) return; - - let eventIndex = -1; - let gotoIndex = -1; - for (const e of events) { - gotoIndex++; - if (e.type === 'event') eventIndex++; - if (eventIndex === selected.index) break; - } - - setCursor(gotoIndex); - }, [events, selected, cursorSettings]); - - // attach scroll to cursor - useEffect(() => { - if (cursor == null || cursorRef.current == null) return; - cursorRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start', }); - }, [cursor, cursorRef]); + }, [cursor]); + + // if selected event + // or cursor settings changed + useEffect(() => { + // and if we are locked + if (cursorSettings !== 'locked' || selectedId == null) return; + + // move cursor + let gotoIndex = -1; + let found = false; + for (const e of events) { + gotoIndex++; + if (e.id === selectedId) { + found = true; + break; + } + } + if (found) { + // move cursor + setCursor(gotoIndex); + } + }, [selectedId, cursorSettings]); if (events.length < 1) { return ; @@ -159,8 +164,8 @@ export default function EventList(props) { index={index} eventIndex={eventIndex} data={e} - selected={selected?.id === e.id} - next={next === e.id} + selected={selectedId === e.id} + next={nextId === e.id} eventsHandler={eventsHandler} delay={cumulativeDelay} /> diff --git a/server/src/classes/EventTimer.js b/server/src/classes/EventTimer.js index 911ea8e58..e7bc9c0f5 100644 --- a/server/src/classes/EventTimer.js +++ b/server/src/classes/EventTimer.js @@ -476,7 +476,7 @@ export class EventTimer extends Timer { // update selected event index this.selectedEventIndex = this._eventlist.findIndex( - (e) => e.id === eventId + (e) => e.id === this.selectedEventId ); // reload titles if necessary diff --git a/server/src/controllers/eventsController.js b/server/src/controllers/eventsController.js index bbb2dcb51..92fe2bf71 100644 --- a/server/src/controllers/eventsController.js +++ b/server/src/controllers/eventsController.js @@ -152,7 +152,7 @@ export const eventsPut = async (req, res) => { } try { - const eventIndex = data.events.findIndex((e) => e.id === req.body.id); + const eventIndex = data.events.findIndex((e) => e.id === eventId); if (eventIndex === -1) { res.status(400).send(`No Id found found`); return; @@ -164,7 +164,7 @@ export const eventsPut = async (req, res) => { db.write(); // update timer - _updateTimersSingle(req.body.id, req.body); + _updateTimersSingle(eventId, req.body); res.sendStatus(200); } catch (error) { From 32396cd4273385aeeb1069c3c2c4ced47fbbfcba Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 29 May 2021 12:32:36 +0200 Subject: [PATCH 53/55] small fixes - paginator shows correctly past - now - future - uselayouteffect in pip - scroll into view would crash if event was deleted --- client/src/common/components/views/Paginator.jsx | 4 +++- client/src/features/editors/list/EventList.jsx | 1 + client/src/features/viewers/production/Pip.jsx | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/common/components/views/Paginator.jsx b/client/src/common/components/views/Paginator.jsx index 9fcb89762..28ed3e543 100644 --- a/client/src/common/components/views/Paginator.jsx +++ b/client/src/common/components/views/Paginator.jsx @@ -53,6 +53,8 @@ export default function Paginator(props) { } }, SCROLL_TIME); + let selectedState = 0; + return ( <>
@@ -66,7 +68,7 @@ export default function Paginator(props) {
{page.map((e) => { - let selectedState = 0; + if (selectedState === 1) selectedState = 2; if (e.id === selected) selectedState = 1; else if (e.id > selected) selectedState = 2; return ( diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index befda8cec..df66b4a9d 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -77,6 +77,7 @@ export default function EventList(props) { // when cursor moves, view should follow useEffect(() => { + if (cursorRef.current == null) return; cursorRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', diff --git a/client/src/features/viewers/production/Pip.jsx b/client/src/features/viewers/production/Pip.jsx index 15d6385a0..b793d6e2b 100644 --- a/client/src/features/viewers/production/Pip.jsx +++ b/client/src/features/viewers/production/Pip.jsx @@ -3,7 +3,7 @@ import style from './Pip.module.css'; import Paginator from 'common/components/views/Paginator'; import NavLogo from 'common/components/nav/NavLogo'; import { AnimatePresence, motion } from 'framer-motion'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { formatDisplay } from 'common/dateConfig'; import { ReactComponent as Emptyimage } from 'assets/images/empty.svg'; @@ -14,7 +14,7 @@ export default function Pip(props) { const [filteredEvents, setFilteredEvents] = useState(null); // calculcate pip size - useEffect(() => { + useLayoutEffect(() => { const h = ref.current.clientHeight; const w = ref.current.clientWidth; setSize(`${w} x ${h}`); From 455ac6bff44cedfe6a315fcae96fa9d3c613e3e1 Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 29 May 2021 22:46:05 +0200 Subject: [PATCH 54/55] style: menubar mockup --- .../components/buttons/DownloadIconBtn.jsx | 18 ----------- .../components/buttons/SettingsIconBtn.jsx | 18 ----------- client/src/features/editors/Editor.jsx | 19 ++++-------- client/src/features/editors/Editor.module.css | 24 +++++++-------- client/src/features/menu/MenuBar.jsx | 30 +++++++++++++++++++ client/src/features/menu/MenuBar.module.css | 3 ++ .../features/menu/buttons/DownloadIconBtn.jsx | 22 ++++++++++++++ .../src/features/menu/buttons/HelpIconBtn.jsx | 22 ++++++++++++++ .../src/features/menu/buttons/InfoIconBtn.jsx | 22 ++++++++++++++ .../src/features/menu/buttons/MaxIconBtn.jsx | 22 ++++++++++++++ .../src/features/menu/buttons/MinIconBtn.jsx | 22 ++++++++++++++ .../src/features/menu/buttons/QuitIconBtn.jsx | 21 +++++++++++++ .../features/menu/buttons/SettingsIconBtn.jsx | 22 ++++++++++++++ 13 files changed, 202 insertions(+), 63 deletions(-) delete mode 100644 client/src/common/components/buttons/DownloadIconBtn.jsx delete mode 100644 client/src/common/components/buttons/SettingsIconBtn.jsx create mode 100644 client/src/features/menu/MenuBar.jsx create mode 100644 client/src/features/menu/MenuBar.module.css create mode 100644 client/src/features/menu/buttons/DownloadIconBtn.jsx create mode 100644 client/src/features/menu/buttons/HelpIconBtn.jsx create mode 100644 client/src/features/menu/buttons/InfoIconBtn.jsx create mode 100644 client/src/features/menu/buttons/MaxIconBtn.jsx create mode 100644 client/src/features/menu/buttons/MinIconBtn.jsx create mode 100644 client/src/features/menu/buttons/QuitIconBtn.jsx create mode 100644 client/src/features/menu/buttons/SettingsIconBtn.jsx diff --git a/client/src/common/components/buttons/DownloadIconBtn.jsx b/client/src/common/components/buttons/DownloadIconBtn.jsx deleted file mode 100644 index 8b30023b4..000000000 --- a/client/src/common/components/buttons/DownloadIconBtn.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { IconButton } from '@chakra-ui/button'; -import { FiDownload } from 'react-icons/fi'; - -export default function DownloadIconBtn(props) { - const { clickhandler, ...rest } = props; - return ( - } - isRound - variant='outline' - onClick={clickhandler} - _focus={{ boxShadow: 'none' }} - colorScheme='whiteAlpha' - {...rest} - /> - ); -} diff --git a/client/src/common/components/buttons/SettingsIconBtn.jsx b/client/src/common/components/buttons/SettingsIconBtn.jsx deleted file mode 100644 index 8a4e3b65f..000000000 --- a/client/src/common/components/buttons/SettingsIconBtn.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { IconButton } from '@chakra-ui/button'; -import { FiSettings } from 'react-icons/fi'; - -export default function SettingsIconBtn(props) { - const { clickhandler, ...rest } = props; - return ( - } - isRound - variant='outline' - onClick={clickhandler} - _focus={{ boxShadow: 'none' }} - colorScheme='whiteAlpha' - {...rest} - /> - ); -} diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index 586f2bd2e..4dc2d2092 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -7,10 +7,8 @@ import styles from './Editor.module.css'; import EventListWrapper from './list/EventListWrapper'; import { useDisclosure } from '@chakra-ui/hooks'; import SettingsModal from '../modals/SettingsModal'; -import SettingsIconBtn from 'common/components/buttons/SettingsIconBtn'; import { useEffect } from 'react'; -import DownloadIconBtn from 'common/components/buttons/DownloadIconBtn'; -import { downloadEvents } from 'app/api/ontimeApi'; +import MenuBar from 'features/menu/MenuBar'; export default function Editor() { const { isOpen, onOpen, onClose } = useDisclosure(); @@ -20,15 +18,15 @@ export default function Editor() { document.title = 'ontime - Editor'; }, []); - const handleDownload = () => { - downloadEvents(); - }; - return ( <>
+ + + + Event List @@ -69,13 +67,6 @@ export default function Editor() {
- - -
- - -
-
); diff --git a/client/src/features/editors/Editor.module.css b/client/src/features/editors/Editor.module.css index d335b7aaa..00186eecf 100644 --- a/client/src/features/editors/Editor.module.css +++ b/client/src/features/editors/Editor.module.css @@ -8,10 +8,10 @@ display: grid; grid-template-rows: 38vh 1fr; - grid-template-columns: 48em 27vw 25vw 4vw; + grid-template-columns: 40px 48em 27vw 1fr; grid-template-areas: - 'even play info sett' - 'even mess info sett'; + 'sett even play info' + 'sett even mess info'; gap: 2vh; } @@ -20,7 +20,7 @@ .mainContainer { height: 100%; grid-template-rows: 1fr 1fr; - grid-template-columns: 48em 1fr 1fr; + grid-template-columns: 40px 48em 1fr 1fr; /* grid-template-areas: 'even play sett' 'even mess sett'; */ @@ -95,24 +95,22 @@ grid-area: play; } -.settings { +.mainContainer > .settings { grid-area: sett; + background-color: transparent; + padding: 0; + margin: 0; + width: fit-content; + display: flex; flex-direction: column; - align-items: center; - gap: 2.6em; + gap: 1em; } .content { padding-top: 1.5em; } -.settings > .content { - display: flex; - flex-direction: column; - gap: 1em; -} - .cornerButtonContainer { position: relative; top: -4.5em; diff --git a/client/src/features/menu/MenuBar.jsx b/client/src/features/menu/MenuBar.jsx new file mode 100644 index 000000000..f2679fc2b --- /dev/null +++ b/client/src/features/menu/MenuBar.jsx @@ -0,0 +1,30 @@ +import { downloadEvents } from 'app/api/ontimeApi'; +import DownloadIconBtn from './buttons/DownloadIconBtn'; +import SettingsIconBtn from './buttons/SettingsIconBtn'; +import InfoIconBtn from './buttons/InfoIconBtn'; +import MaxIconBtn from './buttons/MaxIconBtn'; +import MinIconBtn from './buttons/MinIconBtn'; +import QuitIconBtn from './buttons/QuitIconBtn'; +import style from './MenuBar.module.css'; +import HelpIconBtn from './buttons/HelpIconBtn'; + +export default function MenuBar(props) { + const { onOpen, onClose } = props; + + const handleDownload = () => { + downloadEvents(); + }; + + return ( + <> + + + +
+ + + + + + ); +} diff --git a/client/src/features/menu/MenuBar.module.css b/client/src/features/menu/MenuBar.module.css new file mode 100644 index 000000000..00d82cfe7 --- /dev/null +++ b/client/src/features/menu/MenuBar.module.css @@ -0,0 +1,3 @@ +.gap { + height: 1em; +} diff --git a/client/src/features/menu/buttons/DownloadIconBtn.jsx b/client/src/features/menu/buttons/DownloadIconBtn.jsx new file mode 100644 index 000000000..d55c41738 --- /dev/null +++ b/client/src/features/menu/buttons/DownloadIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiDownload } from 'react-icons/fi'; + +export default function DownloadIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/HelpIconBtn.jsx b/client/src/features/menu/buttons/HelpIconBtn.jsx new file mode 100644 index 000000000..99eb6d12a --- /dev/null +++ b/client/src/features/menu/buttons/HelpIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiHelpCircle } from 'react-icons/fi'; + +export default function HelpIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/InfoIconBtn.jsx b/client/src/features/menu/buttons/InfoIconBtn.jsx new file mode 100644 index 000000000..118342c9f --- /dev/null +++ b/client/src/features/menu/buttons/InfoIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiHome } from 'react-icons/fi'; + +export default function InfoIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/MaxIconBtn.jsx b/client/src/features/menu/buttons/MaxIconBtn.jsx new file mode 100644 index 000000000..9724b7a8a --- /dev/null +++ b/client/src/features/menu/buttons/MaxIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiMaximize } from 'react-icons/fi'; + +export default function MaxIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/MinIconBtn.jsx b/client/src/features/menu/buttons/MinIconBtn.jsx new file mode 100644 index 000000000..2c51306d8 --- /dev/null +++ b/client/src/features/menu/buttons/MinIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiMinimize } from 'react-icons/fi'; + +export default function MinIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/QuitIconBtn.jsx b/client/src/features/menu/buttons/QuitIconBtn.jsx new file mode 100644 index 000000000..e22b9cd05 --- /dev/null +++ b/client/src/features/menu/buttons/QuitIconBtn.jsx @@ -0,0 +1,21 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiPower } from 'react-icons/fi'; + +export default function QuitIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='red' + variant='outline' + isRound + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} diff --git a/client/src/features/menu/buttons/SettingsIconBtn.jsx b/client/src/features/menu/buttons/SettingsIconBtn.jsx new file mode 100644 index 000000000..a8a3233af --- /dev/null +++ b/client/src/features/menu/buttons/SettingsIconBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { FiSettings } from 'react-icons/fi'; + +export default function SettingsIconBtn(props) { + const { clickhandler, ...rest } = props; + return ( + + } + colorScheme='white' + variant='outline' + isRound + borderColor='#fff1' + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + {...rest} + /> + + ); +} From 6ad42dbbb7449b86b17c8865c49bbb624b1a064a Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Sat, 29 May 2021 22:48:25 +0200 Subject: [PATCH 55/55] quickfix: misnamed element --- client/src/features/editors/Editor.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index 4dc2d2092..7490f0439 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -24,7 +24,7 @@ export default function Editor() {
- +