V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
@@ -0,0 +1,11 @@
export default function useElectronEvent() {
const isElectron = window?.process?.type === 'renderer';
const sendToElectron = (channel: string, args?: string | Record<string, any>) => {
if (isElectron) {
window?.ipcRenderer.send(channel, args);
}
};
return { isElectron, sendToElectron };
}
@@ -0,0 +1,339 @@
import { useCallback, useContext } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
import { useAtomValue } from 'jotai';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import {
ReorderEntry,
requestApplyDelay,
requestDelete,
requestDeleteAll,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
} from '../api/eventsApi';
import { defaultPublicAtom, startTimeIsLastEndAtom } from '../atoms/LocalEventSettings';
import { LoggingContext } from '../context/LoggingContext';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from '../models/EventTypes';
/**
* @description Set of utilities for events
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const { emitError } = useContext(LoggingContext);
const defaultPublic = useAtomValue(defaultPublicAtom);
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
/**
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation(requestPostEvent, {
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
});
type AddOptions = {
defaultPublic?: boolean;
startTimeIsLastEnd?: boolean;
lastEventId?: string;
after?: string;
}
/**
* Adds an event to rundown
*/
const addEvent = useCallback(
async (event: Partial<OntimeRundownEntry>, options?: AddOptions) => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS
// there is an option to pass an index of an array to use as start time
// only events have options
if (newEvent.type === SupportedEvent.Event) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
after: options?.after,
};
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
}
}
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (applicationOptions?.after) {
newEvent.after = applicationOptions.after;
}
}
try {
// @ts-expect-error we know that the event here is one of the defined types
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error fetching data: ${(error as AxiosError).message}`);
} else {
emitError(`Error fetching data: ${error}`);
}
}
},
[_addEventMutation, defaultPublic, emitError, queryClient, startTimeIsLastEnd],
);
/**
* Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation(requestPutEvent, {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
// optimistically update object
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
// Return a context with the previous and new events
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
},
});
/**
* Updates existing event
*/
const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error updating event: ${(error as AxiosError).message}`);
} else {
emitError(`Error updating event: ${error}`);
}
}
},
[_updateEventMutation, emitError],
);
/**
* Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation(requestDelete, {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
});
/**
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventId: string) => {
try {
await _deleteEventMutation.mutateAsync(eventId);
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error deleting event: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting event: ${error}`);
}
}
},
[_deleteEventMutation, emitError],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, []);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
});
/**
* Deletes all events from list
*/
const deleteAllEvents = useCallback(async () => {
try {
await _deleteAllEventsMutation.mutateAsync();
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error deleting events: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting events: ${error}`);
}
}
}, [_deleteAllEventsMutation, emitError]);
/**
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation(requestApplyDelay, {
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
});
/**
* Applies a given delay block
*/
const applyDelay = useCallback(
async (delayEventId: string) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error applying delay: ${(error as AxiosError).message}`);
} else {
emitError(`Error applying delay: ${error}`);
}
}
},
[_applyDelayMutation, emitError],
);
/**
* Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation(requestReorderEvent, {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const e = [...(previousEvents as OntimeRundown)];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, e);
// Return a context with the previous and new events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
});
/**
* Reorders a given event
*/
const reorderEvent = useCallback(
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId: eventId,
from: from,
to: to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
if(!axios.isAxiosError(error)){
emitError(`Error re-ordering event: ${(error as AxiosError).message}`);
} else {
emitError(`Error re-ordering event: ${error}`);
}
}
},
[_reorderEventMutation, emitError],
);
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
};
+177
View File
@@ -0,0 +1,177 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight ||
ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(
`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`,
);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [
calcKey,
fontSize,
fontSizeMax,
fontSizeMin,
fontSizePrev,
onFinish,
ref,
resolution,
]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
export default function useFullscreen() {
const [isFullScreen, setFullScreen] = useState(document.fullscreenElement);
useEffect(() => {
const handleChange = () => {
setFullScreen(document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleChange, { passive: true });
document.addEventListener('resize', handleChange, { passive: true });
return () => {
document.removeEventListener('fullscreenchange', handleChange, { passive: true });
document.removeEventListener('resize', handleChange, { passive: true });
};
}, []);
const toggleFullScreen = useCallback(() => {
if (!document.fullscreenElement && !document.webkitIsFullScreen) {
// Fullscreen mode is not active, so we can enter fullscreen mode
if (document.documentElement.requestFullscreen) {
// Standard fullscreen API is supported
document.documentElement.requestFullscreen();
} else if (document.documentElement.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
document.documentElement.webkitRequestFullscreen();
}
} else {
// Fullscreen mode is active, so we can exit fullscreen mode
if (document.exitFullscreen) {
// Standard fullscreen API is supported
document.exitFullscreen();
} else if (document.webkitCancelFullscreen) {
// iOS Safari fullscreen API is supported
document.webkitCancelFullscreen();
}
}
}, []);
return { isFullScreen, toggleFullScreen };
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
/**
* @description utility hook to around setInterval
* @param callback
* @param delay
*/
export const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
/**
* @description function to be called
*/
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
export const useKeyDown = (callback: () => void, targetKey: string) => {
const onKeyDown = (event: KeyboardEvent) => {
const targetKeyPressed = event.key === targetKey && !event.repeat;
if (targetKeyPressed) {
event.preventDefault();
callback();
}
};
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
};
@@ -0,0 +1,37 @@
import { useState } from 'react';
// Roughly from useHooks - useLocalStorage
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
/**
* @description Set value to local storage
* @param value
*/
const setValue = (value) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
const scriptTagId = 'ontime-override';
export const useRuntimeStylesheet = (pathToFile) => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(pathToFile);
if (response.ok) {
return response.text();
}
};
if (!pathToFile) {
document.getElementById(scriptTagId)?.remove();
setShouldRender(true);
return;
}
if (document.getElementById(scriptTagId)) {
setShouldRender(true);
return;
}
setShouldRender(false);
const styleSheet = document.createElement('style');
styleSheet.rel = 'stylesheet';
styleSheet.setAttribute('id', scriptTagId);
fetchData()
.then((data) => {
styleSheet.innerHTML = data;
document.head.append(styleSheet);
})
.catch((error) => {
console.error(`Error loading stylesheet: ${error}`);
})
.finally(() => {
// schedule render for next tick
setTimeout(() => setShouldRender(true), 0);
});
}, [pathToFile]);
return { shouldRender };
};
+143
View File
@@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query';
import { ontimeQueryClient as queryClient } from '../queryClient';
import socket, { subscribeOnce } from '../utils/socket';
import {
FEAT_CUESHEET,
FEAT_INFO,
FEAT_MESSAGECONTROL,
FEAT_PLAYBACKCONTROL,
FEAT_RUNDOWN,
TIMER,
} from '../api/apiConstants';
import { Playback } from '../models/OntimeTypes';
function createSocketHook<T>(key: string, defaultValue: T | null = null) {
subscribeOnce<T>(key, (data) => queryClient.setQueryData([key], data));
// retrieves data from the cache or null if non-existent
// we need the null because useQuery can't receive undefined
const fetcher = () => (queryClient.getQueryData([key]) ?? defaultValue) as T | null;
return () => useQuery({ queryKey: [key], queryFn: fetcher, placeholderData: defaultValue });
}
interface IRundown {
selectedEventId: string | null;
nextEventId: string | null;
playback: Playback | null;
}
const emptyRundown: IRundown = {
selectedEventId: null,
nextEventId: null,
playback: null,
};
export const useRundownEditor = createSocketHook(FEAT_RUNDOWN, emptyRundown);
const emptyMessageControl = {
presenter: {
text: '',
visible: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
onAir: false,
};
export const useMessageControl = createSocketHook(FEAT_MESSAGECONTROL, emptyMessageControl);
export const setMessage = {
presenterText: (payload: string) => socket.emit('set-timer-message-text', payload),
presenterVisible: (payload: boolean) => socket.emit('set-timer-message-visible', payload),
publicText: (payload: string) => socket.emit('set-public-message-text', payload),
publicVisible: (payload: boolean) => socket.emit('set-public-message-visible', payload),
lowerText: (payload: string) => socket.emit('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socket.emit('set-lower-message-visible', payload),
onAir: (payload: boolean) => socket.emit('set-onAir', payload),
};
export const emptyPlaybackControl = {
playback: 'stop',
selectedEventId: null,
numEvents: 0,
};
export const usePlaybackControl = createSocketHook(FEAT_PLAYBACKCONTROL, emptyPlaybackControl);
export const resetPlayback = () => {
const cacheData = queryClient.getQueryData([FEAT_PLAYBACKCONTROL]) as Record<string, unknown>;
queryClient.setQueryData([FEAT_PLAYBACKCONTROL], {
...cacheData,
playback: 'stop',
selectedEventId: null,
});
};
export const setPlayback = {
start: () => socket.emit('set-start'),
pause: () => socket.emit('set-pause'),
roll: () => socket.emit('set-roll'),
previous: () => {
socket.emit('set-previous');
},
next: () => {
socket.emit('set-next');
},
stop: () => {
socket.emit('set-stop');
},
reload: () => {
socket.emit('set-reload');
},
delay: (amount: number) => {
socket.emit('set-delay', amount);
},
};
export const emptyInfo = {
titles: {
titleNow: '',
subtitleNow: '',
presenterNow: '',
noteNow: '',
titleNext: '',
subtitleNext: '',
presenterNext: '',
noteNext: '',
},
playback: 'stop',
selectedEventId: null,
selectedEventIndex: null,
numEvents: 0,
};
export const useInfoPanel = createSocketHook(FEAT_INFO, emptyInfo);
export const emptyCuesheet = {
selectedEventId: null,
titleNow: '',
};
export const useCuesheet = createSocketHook(FEAT_CUESHEET, emptyCuesheet);
export const setEventPlayback = {
loadEvent: (eventId: string) => socket.emit('set-loadid', eventId),
startEvent: (eventId: string) => socket.emit('set-startid', eventId),
pause: () => socket.emit('set-pause'),
};
const emptyTimer = {
clock: 0,
current: 0,
secondaryTimer: null,
duration: null,
startedAt: null,
expectedFinish: null,
};
export const useTimer = createSocketHook(TIMER, emptyTimer);
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import socket from '../utils/socket';
export default function useSubscription<T>(topic: string, initialState: T, requestString?: string) {
const [state, setState] = useState<T>(initialState);
useEffect(() => {
if (requestString) {
socket.emit(requestString);
} else {
socket.emit(`get-${topic}`);
}
socket.on(topic, setState);
return () => {
socket.off(topic);
};
}, [requestString, topic]);
return [state, setState] as const;
};