v2 alpha5 (#315)

* fix: fetch in offline environments (#295)

* add arm platforms to docker build

---------

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

* fix: docker build (#298)

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

* Timer: fix too many renders error when using ?progress (#305)

* ux: remove duplicate showing of selected event

* several small fixes and UX improvements
---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Marks Polakovs <github@markspolakovs.me>
This commit is contained in:
Carlos Valente
2023-03-24 08:19:47 +01:00
committed by GitHub
parent e937af62b1
commit bca7ad30b1
44 changed files with 434 additions and 799 deletions
@@ -1,23 +0,0 @@
import { atom } from 'jotai';
import { atomWithStorage, selectAtom } from 'jotai/utils';
export const eventSettingsAtom = atomWithStorage('ontime-eventSettings', {
showQuickEntry: false,
startTimeIsLastEnd: false,
defaultPublic: false,
});
export const showQuickEntryAtom = selectAtom(
eventSettingsAtom,
(settings) => settings.showQuickEntry
);
export const startTimeIsLastEndAtom = selectAtom(
eventSettingsAtom,
(settings) => settings.startTimeIsLastEnd
);
export const defaultPublicAtom = selectAtom(
eventSettingsAtom,
(settings) => settings.defaultPublic
);
export const editorEventId = atom<string | null>(null);
@@ -1,3 +0,0 @@
import { atomWithStorage } from 'jotai/utils';
export const mirrorViewersAtom = atomWithStorage('ontime-viewers-mirrorViewers', false);
@@ -35,7 +35,7 @@ export default function TextInput(props: TextInputProps) {
<Textarea
ref={inputRef}
size={size}
resize='none'
resize={resize}
variant='ontime-filled'
{...textAreaProps}
style={{ height: isFullHeight ? '100%' : undefined }}
@@ -1,4 +1,4 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
@@ -21,9 +21,7 @@ interface TimeInputProps {
}
export default function TimeInput(props: TimeInputProps) {
const {
name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0,
} = props;
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState('');
@@ -51,74 +49,90 @@ export default function TimeInput(props: TimeInputProps) {
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback((newValue: string) => {
// Check if there is anything there
if (newValue === '') {
return false;
}
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
const handleSubmit = useCallback(
(newValue: string) => {
// Check if there is anything there
if (newValue === '') {
return false;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
// Time now and time submittedVal
const originalMillis = time + delay;
let newValMillis = 0;
// check if time is different from before
if (newValMillis === originalMillis) return false;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// Time now and time submittedVal
const originalMillis = time + delay;
// update entry
submitHandler(name, newValMillis);
// check if time is different from before
if (newValMillis === originalMillis) return false;
return true;
}, [delay, name, previousEnd, submitHandler, time, validationHandler]);
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[delay, name, previousEnd, submitHandler, time, validationHandler],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback((newValue: string) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(millisToString(ms + delay));
} else {
resetValue();
}
}, [delay, handleSubmit, resetValue]);
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(millisToString(ms + delay));
} else {
resetValue();
}
},
[delay, handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback((event:KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
inputRef.current?.blur();
resetValue();
}
},
[resetValue, validateAndSubmit],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
inputRef.current?.blur();
resetValue();
}
}, [resetValue, validateAndSubmit]);
},
[validateAndSubmit],
);
useEffect(() => {
if (time == null) return;
@@ -167,7 +181,7 @@ export default function TimeInput(props: TimeInputProps) {
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={resetValue}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
@@ -6,13 +6,12 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { useAtom } from 'jotai';
import { navigatorConstants } from '../../../viewerConfig';
import { mirrorViewersAtom } from '../../atoms/ViewerSettings';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import style from './NavigationMenu.module.scss';
@@ -20,7 +19,7 @@ export default function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const [isMirrored, setMirrored] = useAtom(mirrorViewersAtom);
const { mirror, toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -49,10 +48,10 @@ export default function NavigationMenu() {
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => setMirrored((prev) => !prev);
const handleMirror = () => toggleMirror();
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={isMirrored ? style.mirror : ''}>
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -83,7 +82,8 @@ export default function NavigationMenu() {
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
}}>
}}
>
Flip Screen
<IoSwapVertical />
</div>
@@ -97,12 +97,15 @@ export default function NavigationMenu() {
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}>
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
</div>, document.body);
</div>,
document.body,
);
}
@@ -17,23 +17,9 @@ export default function Schedule({ className }: ScheduleProps) {
}
let selectedState: 'past' | 'now' | 'future' = 'past';
const selectedEvent = paginatedEvents.find((event) => event.id === selectedEventId);
return (
<ul className={`schedule ${className}`}>
{selectedEvent && (
<ScheduleItem
key={selectedEvent.id}
selected='now'
timeStart={selectedEvent.timeStart}
timeEnd={selectedEvent.timeEnd}
title={selectedEvent.title}
presenter={selectedEvent.presenter}
colour={isBackstage ? selectedEvent.colour : ''}
backstageEvent={!selectedEvent.isPublic}
skip={selectedEvent.skip}
/>
)}
{paginatedEvents.map((event) => {
if (event.id === selectedEventId) {
selectedState = 'now';
@@ -17,4 +17,4 @@ export default function useEventData() {
});
return { data, status, isError, refetch };
}
}
@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
import { useAtomValue } from 'jotai';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
@@ -14,7 +13,7 @@ import {
requestPutEvent,
requestReorderEvent,
} from '../api/eventsApi';
import { defaultPublicAtom, startTimeIsLastEndAtom } from '../atoms/LocalEventSettings';
import { useLocalEvent } from '../stores/localEvent';
import { useEmitLog } from '../stores/logger';
/**
@@ -23,8 +22,9 @@ import { useEmitLog } from '../stores/logger';
export const useEventAction = () => {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const defaultPublic = useAtomValue(defaultPublicAtom);
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
const { eventSettings } = useLocalEvent();
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
/**
* Calls mutation to add new event
@@ -1,37 +0,0 @@
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,53 @@
import { useEffect, useState } from 'react';
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea === window.localStorage && event.key === key) {
try {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
setStoredValue(newValue);
} catch (_) {
/* empty */
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
/**
* @description Set value to local storage
* @param value
*/
const setValue = (value: T | ((val: T) => T)) => {
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];
};
+5 -2
View File
@@ -77,7 +77,10 @@ export const useInfoPanel = () => {
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventIndex: state.loaded.selectedEventId,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow,
});
@@ -92,7 +95,7 @@ export const setEventPlayback = {
export const useTimer = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.timer,
...state.timer,
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -1,16 +0,0 @@
export default function createStore<T>(initialState: T) {
let currentState = initialState;
const listeners = new Set<(state: T) => void>();
return {
get: () => currentState,
set: (newState: T) => {
currentState = newState;
listeners.forEach((listener) => listener(currentState));
},
subscribe: (listener: (state: T) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -0,0 +1,13 @@
import { create } from 'zustand';
type EventEditorStore = {
openId: string | null;
setOpenEvent: (eventId: string) => void;
removeOpenEvent: () => void;
};
export const useEventEditorStore = create<EventEditorStore>()((set) => ({
openId: null,
setOpenEvent: (eventId: string | null) => set({ openId: eventId }),
removeOpenEvent: () => set({ openId: null }),
}));
@@ -0,0 +1,57 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EventSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
};
type LocalEventStore = {
eventSettings: EventSettings;
setLocalEventSettings: (newState: EventSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
enum LocalEventKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
}
export const useLocalEvent = create<LocalEventStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
}));
+1 -1
View File
@@ -19,7 +19,7 @@ export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [...state.logs, log],
logs: [log, ...state.logs],
}));
export const clearLogs = () => logger.setState({ logs: [] });
+1
View File
@@ -16,6 +16,7 @@ export const runtimeStorePlaceholder = {
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
},
playback: Playback.Stop,
timerMessage: {
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
enum LocalEventKeys {
Mirror = 'ontime-view-mirror',
}
type ViewOptionsStore = {
mirror: boolean;
toggleMirror: (newValue?: boolean) => void;
};
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false),
toggleMirror: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
localStorage.setItem(LocalEventKeys.Mirror, String(val));
return { mirror: val };
}),
}));
@@ -0,0 +1,9 @@
export function booleanFromLocalStorage(key: string, fallback: boolean): boolean {
const valueInStorage = localStorage.getItem(key);
if (valueInStorage) {
return valueInStorage === 'true';
} else {
localStorage.setItem(key, String(fallback));
return fallback;
}
}