mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 04:43:35 +00:00
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:
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user