From 6a6f68b2f9025a5a920066a84f0d8146e560aabf Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sat, 22 Jan 2022 23:23:31 +0100 Subject: [PATCH] Feat/block improvements (#95) * feat/89-block-improvements style: replace reload icon * feat/block-feat/block-improvements add cursor buttons * feat/block-improvements hover to create * feat/block-improvements change settings in modal * feat/block-improvements version bump --- README.md | 16 +-- client/src/app/api/eventsApi.js | 8 +- client/src/app/context/CursorContext.js | 63 ++++++++ .../app/context/LocalEventSettingsContext.js | 32 +++++ client/src/app/hooks/useLocalStorage.js | 24 ++++ client/src/app/hooks/useTimeout.js | 38 ----- .../components/buttons/ApplyIconBtn.jsx | 4 +- .../components/buttons/CursorDownBtn.jsx | 22 +++ .../{LockIconBtn.jsx => CursorLockedBtn.jsx} | 2 +- .../common/components/buttons/CursorUpBtn.jsx | 22 +++ .../components/buttons/ReloadIconBtn.jsx | 4 +- .../common/components/countdown/Countdown.jsx | 2 +- client/src/common/input/DelayInput.jsx | 2 +- client/src/common/input/EditableText.jsx | 7 +- ...xt.module.css => EditableText.module.scss} | 11 +- .../src/common/input/EditableTimer.module.css | 3 +- .../editors/BlockBlock/BlockBlock.jsx | 2 +- ...lock.module.css => BlockBlock.module.scss} | 6 +- .../editors/DelayBlock/DelayBlock.jsx | 2 +- ...lock.module.css => DelayBlock.module.scss} | 8 +- client/src/features/editors/Editor.jsx | 94 ++++++------ .../editors/EntryBlock/EntryBlock.jsx | 73 ++++++++++ .../editors/EntryBlock/EntryBlock.module.scss | 92 ++++++++++++ .../editors/EventBlock/EventBlock.module.css | 6 +- .../src/features/editors/list/EventList.jsx | 79 +++++----- .../features/editors/list/EventListItem.jsx | 16 ++- .../editors/list/EventListWrapper.jsx | 65 ++++----- .../features/editors/list/List.module.scss | 10 +- client/src/features/menu/EventListMenu.jsx | 40 +++--- .../src/features/modals/AppSettingsModal.jsx | 135 ++++++++++++++---- client/src/features/modals/Modals.module.scss | 8 ++ .../src/features/modals/SubmitContainer.jsx | 1 - client/src/styles/_main.scss | 8 ++ server/package.json | 2 +- 34 files changed, 652 insertions(+), 255 deletions(-) create mode 100644 client/src/app/context/CursorContext.js create mode 100644 client/src/app/context/LocalEventSettingsContext.js create mode 100644 client/src/app/hooks/useLocalStorage.js delete mode 100644 client/src/app/hooks/useTimeout.js create mode 100644 client/src/common/components/buttons/CursorDownBtn.jsx rename client/src/common/components/buttons/{LockIconBtn.jsx => CursorLockedBtn.jsx} (92%) create mode 100644 client/src/common/components/buttons/CursorUpBtn.jsx rename client/src/common/input/{EditableText.module.css => EditableText.module.scss} (62%) rename client/src/features/editors/BlockBlock/{BlockBlock.module.css => BlockBlock.module.scss} (90%) rename client/src/features/editors/DelayBlock/{DelayBlock.module.css => DelayBlock.module.scss} (89%) create mode 100644 client/src/features/editors/EntryBlock/EntryBlock.jsx create mode 100644 client/src/features/editors/EntryBlock/EntryBlock.module.scss diff --git a/README.md b/README.md index c187d3171..a653b5c68 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@ and extend with using the URL aliases feature ``` For the presentation views... ------------------------------------------------------------- -IP.ADDRESS:4001 > Web server default to presenter timer view -IP.ADDRESS:4001/preseter > Presenter / Stage timer view -IP.ADDRESS:4001/sm > Stage Manager / Backstage view -IP.ADDRESS:4001/public > Public / Foyer view -IP.ADDRESS:4001/pip > Picture in Picture view -IP.ADDRESS:4001/lower > Lower Thirds -IP.ADDRESS:4001/studio > Studio Clock +IP.ADDRESS:4001 > Web server default to presenter timer view +IP.ADDRESS:4001/presenter > Presenter / Stage timer view +IP.ADDRESS:4001/sm > Stage Manager / Backstage view +IP.ADDRESS:4001/public > Public / Foyer view +IP.ADDRESS:4001/pip > Picture in Picture view +IP.ADDRESS:4001/lower > Lower Thirds +IP.ADDRESS:4001/studio > Studio Clock ...and for the editor (the control interface, same as the app) ------------------------------------------------------------- -IP.ADDRESS:4001/studio > Studio Clock +IP.ADDRESS:4001/studio > Studio Clock ``` diff --git a/client/src/app/api/eventsApi.js b/client/src/app/api/eventsApi.js index 70f2f946f..ac603716b 100644 --- a/client/src/app/api/eventsApi.js +++ b/client/src/app/api/eventsApi.js @@ -20,18 +20,18 @@ export const requestPatch = async (data) => { export const requestReorder = async (data) => { const action = 'reorder'; - return await axios.patch(eventsURL + '/' + action, data); + return await axios.patch(`${eventsURL}/${action}`, data); }; export const requestApplyDelay = async (eventId) => { const action = 'applydelay'; - return await axios.patch(eventsURL + '/' + action + '/' + eventId); + return await axios.patch(`${eventsURL}/${action}/${eventId}`); }; export const requestDelete = async (eventId) => { - return await axios.delete(eventsURL + '/' + eventId); + return await axios.delete(`${eventsURL}/${eventId}`); }; export const requestDeleteAll = async () => { - return await axios.delete(eventsURL + '/all'); + return await axios.delete(`${eventsURL}/all`); }; diff --git a/client/src/app/context/CursorContext.js b/client/src/app/context/CursorContext.js new file mode 100644 index 000000000..4e4e6e61d --- /dev/null +++ b/client/src/app/context/CursorContext.js @@ -0,0 +1,63 @@ +import { createContext, useCallback, useMemo, useState } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +export const CursorContext = createContext({ + cursor: 0, + isCursorLocked: false, + + setCursor: () => undefined, + moveCursorUp: () => undefined, + moveCursorDown: () => undefined, +}); + +export const CursorProvider = (props) => { + const [cursor, setCursor] = useState(0); + const [_cursorLocked, _setCursorLocked] = useLocalStorage('isCursorLocked', 'locked'); + const isCursorLocked = useMemo(() => _cursorLocked === 'locked', [_cursorLocked]); + + const cursorLockedOff = useCallback(() => _setCursorLocked('unlocked'), [_setCursorLocked]); + const cursorLockedOn = useCallback(() => _setCursorLocked('locked'), [_setCursorLocked]); + + const moveCursorUp = useCallback(() => { + setCursor((prev) => Math.max(prev - 1, 0)); + }, []); + + const moveCursorDown = useCallback(() => { + setCursor((prev) => prev + 1); + }, []); + + /** + * @param {boolean | undefined} newValue + */ + const toggleCursorLocked = useCallback( + (newValue = undefined) => { + if (newValue === undefined) { + if (isCursorLocked) { + cursorLockedOff(); + } else { + cursorLockedOn(); + } + } else if (!newValue) { + cursorLockedOff(); + } else if (newValue) { + cursorLockedOn(); + } + }, + [cursorLockedOff, cursorLockedOn, isCursorLocked] + ); + + return ( + + {props.children} + + ); +}; diff --git a/client/src/app/context/LocalEventSettingsContext.js b/client/src/app/context/LocalEventSettingsContext.js new file mode 100644 index 000000000..10cffd3f6 --- /dev/null +++ b/client/src/app/context/LocalEventSettingsContext.js @@ -0,0 +1,32 @@ +import { createContext, useState } from 'react'; + +export const LocalEventSettingsContext = createContext({ + showQuickEntry: false, + starTimeIsLastEnd: true, + defaultPublic: true, + + setShowQuickEntry: () => undefined, + setStarTimeIsLastEnd: () => undefined, + setDefaultPublic: () => undefined, +}); + +export const LocalEventSettingsProvider = (props) => { + const [showQuickEntry, setShowQuickEntry] = useState(false); + const [starTimeIsLastEnd, setStarTimeIsLastEnd] = useState(true); + const [defaultPublic, setDefaultPublic] = useState(false); + + return ( + + {props.children} + + ); +}; diff --git a/client/src/app/hooks/useLocalStorage.js b/client/src/app/hooks/useLocalStorage.js new file mode 100644 index 000000000..70b4b0fd4 --- /dev/null +++ b/client/src/app/hooks/useLocalStorage.js @@ -0,0 +1,24 @@ +import { useState } from 'react'; + +// Roughly from useHooks - useLocalStorage + +export const useLocalStorage = (key, initialValue) => { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + return initialValue; + } + }); + + const setValue = (value) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.log(error); + } + }; + return [storedValue, setValue]; +} diff --git a/client/src/app/hooks/useTimeout.js b/client/src/app/hooks/useTimeout.js deleted file mode 100644 index ae5681d5f..000000000 --- a/client/src/app/hooks/useTimeout.js +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect } from 'react'; - -// -// useTimeout React Hook -// -// React hook for delaying calls with time -export const useTimeout = ( - callback, // function to call. No args passed. - timeout = 0, // delay, ms (default: immediately put into JS Event Queue) - { - // manage re-render behavior. - // by default, a re-render in your component will re-define the callback, - // which will cause this timeout to cancel itself. - // to avoid cancelling on re-renders (but still cancel on unmounts), - // set `persistRenders: true,`. - persistRenders = false, - } = {}, - // These dependencies are injected for testing purposes. - // (pure functions - where all dependencies are arguments - is often easier to test) - _setTimeout = setTimeout, - _clearTimeout = clearTimeout, - _useEffect = useEffect -) => { - let timeoutId; - const cancel = () => timeoutId && _clearTimeout(timeoutId); - - _useEffect( - () => { - timeoutId = _setTimeout(callback, timeout); - return cancel; - }, - persistRenders - ? [_setTimeout, _clearTimeout] - : [callback, timeout, _setTimeout, _clearTimeout] - ); - - return cancel; -}; diff --git a/client/src/common/components/buttons/ApplyIconBtn.jsx b/client/src/common/components/buttons/ApplyIconBtn.jsx index 57a7e4609..c2228ef22 100644 --- a/client/src/common/components/buttons/ApplyIconBtn.jsx +++ b/client/src/common/components/buttons/ApplyIconBtn.jsx @@ -1,6 +1,6 @@ import { IconButton } from '@chakra-ui/button'; import { Tooltip } from '@chakra-ui/tooltip'; -import { FiChevronsDown } from 'react-icons/fi'; +import { FiCheck } from 'react-icons/fi'; export default function ApplyIconBtn(props) { const { clickhandler, ...rest } = props; @@ -8,7 +8,7 @@ export default function ApplyIconBtn(props) { } + icon={} colorScheme='orange' onClick={clickhandler} _focus={{ boxShadow: 'none' }} diff --git a/client/src/common/components/buttons/CursorDownBtn.jsx b/client/src/common/components/buttons/CursorDownBtn.jsx new file mode 100644 index 000000000..5335e4d46 --- /dev/null +++ b/client/src/common/components/buttons/CursorDownBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { IoCaretDown } from 'react-icons/io5'; + +export default function CursorDownBtn(props) { + const { clickhandler, active, ref } = props; + return ( + + } + color={active ? 'pink.100' : 'pink.300'} + borderColor={active ? undefined : 'pink.300'} + backgroundColor={active ? 'pink.400' : undefined} + variant={active ? 'solid' : 'outline'} + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + /> + + ); +} diff --git a/client/src/common/components/buttons/LockIconBtn.jsx b/client/src/common/components/buttons/CursorLockedBtn.jsx similarity index 92% rename from client/src/common/components/buttons/LockIconBtn.jsx rename to client/src/common/components/buttons/CursorLockedBtn.jsx index 55ab04b35..b05d67655 100644 --- a/client/src/common/components/buttons/LockIconBtn.jsx +++ b/client/src/common/components/buttons/CursorLockedBtn.jsx @@ -2,7 +2,7 @@ import { IconButton } from '@chakra-ui/button'; import { Tooltip } from '@chakra-ui/tooltip'; import { FiTarget } from 'react-icons/fi'; -export default function LockIconBtn(props) { +export default function CursorLockedBtn(props) { const { clickhandler, active, ref } = props; return ( diff --git a/client/src/common/components/buttons/CursorUpBtn.jsx b/client/src/common/components/buttons/CursorUpBtn.jsx new file mode 100644 index 000000000..b8e6c2417 --- /dev/null +++ b/client/src/common/components/buttons/CursorUpBtn.jsx @@ -0,0 +1,22 @@ +import { IconButton } from '@chakra-ui/button'; +import { Tooltip } from '@chakra-ui/tooltip'; +import { IoCaretUp } from 'react-icons/io5'; + +export default function CursorUpBtn(props) { + const { clickhandler, active, ref } = props; + return ( + + } + color={active ? 'pink.100' : 'pink.300'} + borderColor={active ? undefined : 'pink.300'} + backgroundColor={active ? 'pink.400' : undefined} + variant={active ? 'solid' : 'outline'} + onClick={clickhandler} + _focus={{ boxShadow: 'none' }} + /> + + ); +} diff --git a/client/src/common/components/buttons/ReloadIconBtn.jsx b/client/src/common/components/buttons/ReloadIconBtn.jsx index 17ab489b0..fa7c79029 100644 --- a/client/src/common/components/buttons/ReloadIconBtn.jsx +++ b/client/src/common/components/buttons/ReloadIconBtn.jsx @@ -1,5 +1,5 @@ import { IconButton } from '@chakra-ui/button'; -import { IoReload } from 'react-icons/io5'; +import { IoArrowUndo } from 'react-icons/io5'; import { Tooltip } from '@chakra-ui/tooltip'; export default function ReloadIconButton(props) { @@ -7,7 +7,7 @@ export default function ReloadIconButton(props) { return ( } + icon={} colorScheme='whiteAlpha' backgroundColor='#ffffff05' variant='outline' diff --git a/client/src/common/components/countdown/Countdown.jsx b/client/src/common/components/countdown/Countdown.jsx index 33ca67aef..fdadea772 100644 --- a/client/src/common/components/countdown/Countdown.jsx +++ b/client/src/common/components/countdown/Countdown.jsx @@ -25,7 +25,7 @@ const Countdown = ({ time, small, isNegative, hideZeroHours }) => { export default memo(Countdown); Countdown.propTypes = { - time: PropTypes.number.isRequired, + time: PropTypes.number, small: PropTypes.bool, isNegative: PropTypes.bool, hideZeroHour: PropTypes.bool, diff --git a/client/src/common/input/DelayInput.jsx b/client/src/common/input/DelayInput.jsx index 8a1fba8e9..20514a47c 100644 --- a/client/src/common/input/DelayInput.jsx +++ b/client/src/common/input/DelayInput.jsx @@ -8,7 +8,7 @@ const inputProps = { backgroundColor: 'rgba(0,0,0,0.05)', color: '#fff', border: '1px solid #ecc94b55', - borderRadius: '4px', + borderRadius: '8px', placeholder: '-', textAlign: 'center', }; diff --git a/client/src/common/input/EditableText.jsx b/client/src/common/input/EditableText.jsx index ff97bae73..370c0297b 100644 --- a/client/src/common/input/EditableText.jsx +++ b/client/src/common/input/EditableText.jsx @@ -1,6 +1,6 @@ import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable'; import { useEffect, useState } from 'react'; -import style from './EditableText.module.css'; +import style from './EditableText.module.scss'; export default function EditableText(props) { const { label, defaultValue, placeholder, submitHandler, ...rest } = props; @@ -33,10 +33,7 @@ export default function EditableText(props) { className={style.inline} {...rest} > - + diff --git a/client/src/common/input/EditableText.module.css b/client/src/common/input/EditableText.module.scss similarity index 62% rename from client/src/common/input/EditableText.module.css rename to client/src/common/input/EditableText.module.scss index 7cd579d4b..d5648b2ad 100644 --- a/client/src/common/input/EditableText.module.css +++ b/client/src/common/input/EditableText.module.scss @@ -7,8 +7,17 @@ } .block { - display: 'block'; overflow: hidden; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + + *:nth-of-type(2) { + flex: 1; + width: 20em; + } + } .inline { diff --git a/client/src/common/input/EditableTimer.module.css b/client/src/common/input/EditableTimer.module.css index 0a4392446..1f35dcad8 100644 --- a/client/src/common/input/EditableTimer.module.css +++ b/client/src/common/input/EditableTimer.module.css @@ -2,13 +2,12 @@ .delayedEditable { background-color: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 4px; width: 6.5em; letter-spacing: 1px; height: fit-content; text-align: center; - border-radius: 4px; + border-radius: 8px; } .delayedEditable { diff --git a/client/src/features/editors/BlockBlock/BlockBlock.jsx b/client/src/features/editors/BlockBlock/BlockBlock.jsx index c66aed7f8..8eee5f44a 100644 --- a/client/src/features/editors/BlockBlock/BlockBlock.jsx +++ b/client/src/features/editors/BlockBlock/BlockBlock.jsx @@ -2,7 +2,7 @@ import { Draggable } from 'react-beautiful-dnd'; import { FiMoreVertical } from 'react-icons/fi'; import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn'; import ActionButtons from '../list/ActionButtons'; -import style from './BlockBlock.module.css'; +import style from './BlockBlock.module.scss'; import PropTypes from 'prop-types'; export default function BlockBlock(props) { diff --git a/client/src/features/editors/BlockBlock/BlockBlock.module.css b/client/src/features/editors/BlockBlock/BlockBlock.module.scss similarity index 90% rename from client/src/features/editors/BlockBlock/BlockBlock.module.css rename to client/src/features/editors/BlockBlock/BlockBlock.module.scss index eadc8bcae..c3e2d4011 100644 --- a/client/src/features/editors/BlockBlock/BlockBlock.module.css +++ b/client/src/features/editors/BlockBlock/BlockBlock.module.scss @@ -1,3 +1,4 @@ +@use '../../../styles/main' as *; /* ============= COMMON ============= */ .block { @@ -8,7 +9,7 @@ width: 100%; border-radius: 8px; font-size: 15px; - border: 1px solid rgba(255, 255, 255, 0.05); + border: $block-border; display: grid; grid-template-columns: 2em auto; @@ -16,7 +17,7 @@ align-items: baseline; gap: 0.5em; - background-color: #805ad5; + background-color: $block-block-color; background: linear-gradient( 0deg, rgba(107, 70, 193, 0.4) 0%, @@ -34,7 +35,6 @@ /* ============== ACTION ================ */ .actionOverlay { - display: flex; display: flex; flex-direction: row; flex-wrap: wrap; diff --git a/client/src/features/editors/DelayBlock/DelayBlock.jsx b/client/src/features/editors/DelayBlock/DelayBlock.jsx index 473413269..1c8a9054a 100644 --- a/client/src/features/editors/DelayBlock/DelayBlock.jsx +++ b/client/src/features/editors/DelayBlock/DelayBlock.jsx @@ -5,7 +5,7 @@ import ActionButtons from '../list/ActionButtons'; import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn'; import ApplyIconBtn from 'common/components/buttons/ApplyIconBtn'; import DelayInput from 'common/input/DelayInput'; -import style from './DelayBlock.module.css'; +import style from './DelayBlock.module.scss'; import PropTypes from 'prop-types'; export default function DelayBlock(props) { diff --git a/client/src/features/editors/DelayBlock/DelayBlock.module.css b/client/src/features/editors/DelayBlock/DelayBlock.module.scss similarity index 89% rename from client/src/features/editors/DelayBlock/DelayBlock.module.css rename to client/src/features/editors/DelayBlock/DelayBlock.module.scss index a1b226c98..3b3e47b28 100644 --- a/client/src/features/editors/DelayBlock/DelayBlock.module.css +++ b/client/src/features/editors/DelayBlock/DelayBlock.module.scss @@ -1,3 +1,5 @@ +@use '../../../styles/main' as *; + /* ============= COMMON ============= */ .delay { @@ -7,7 +9,7 @@ width: 100%; border-radius: 8px; font-size: 15px; - border: 1px solid rgba(255, 255, 255, 0.05); + border: $block-border; display: grid; grid-template-columns: 2em 1fr auto; @@ -17,7 +19,7 @@ align-items: baseline; gap: 0.5em; - background-color: #ecc94b; + background-color: $block-delay-color; background: linear-gradient( 180deg, rgba(214, 158, 46, 0.4) 0%, @@ -30,7 +32,7 @@ .drag { grid-area: drag; - color: rgba(255, 255, 255, 0.67); + color: $block-icon-drag; align-self: center; } diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx index d2c20f0ea..1779526fe 100644 --- a/client/src/features/editors/Editor.jsx +++ b/client/src/features/editors/Editor.jsx @@ -6,10 +6,10 @@ import MenuBar from 'features/menu/MenuBar'; import ModalManager from 'features/modals/ModalManager'; import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary'; import { LoggingProvider } from '../../app/context/LoggingContext'; +import { LocalEventSettingsProvider } from '../../app/context/LocalEventSettingsContext'; +import { CursorProvider } from '../../app/context/CursorContext'; -const EventListWrapper = lazy(() => - import('features/editors/list/EventListWrapper') -); +const EventListWrapper = lazy(() => import('features/editors/list/EventListWrapper')); const PlaybackControl = lazy(() => import('features/control/PlaybackControl')); const MessageControl = lazy(() => import('features/control/MessageControl')); const Info = lazy(() => import('features/info/Info')); @@ -24,53 +24,57 @@ export default function Editor() { return ( - - - + + + + -
- - - - - +
+ + + + + + - -

Event List

-
- - - -
-
+ +

Event List

+
+ + + +
+
+
- -

Display Messages

-
- - - -
-
+ +

Display Messages

+
+ + + +
+
- -

Timer Control

-
- - - -
-
+ +

Timer Control

+
+ + + +
+
- -

Info

-
- - - -
-
-
+ +

Info

+
+ + + +
+
+
+
); } diff --git a/client/src/features/editors/EntryBlock/EntryBlock.jsx b/client/src/features/editors/EntryBlock/EntryBlock.jsx new file mode 100644 index 000000000..0be8730f1 --- /dev/null +++ b/client/src/features/editors/EntryBlock/EntryBlock.jsx @@ -0,0 +1,73 @@ +import { Tooltip } from '@chakra-ui/tooltip'; +import { Checkbox } from '@chakra-ui/react'; +import style from './EntryBlock.module.scss'; +import { useContext, useEffect, useState } from 'react'; +import { LocalEventSettingsContext } from '../../../app/context/LocalEventSettingsContext'; + +export default function EntryBlock(props) { + const { showKbd, index, eventsHandler } = props; + const { starTimeIsLastEnd, defaultPublic } = useContext(LocalEventSettingsContext); + const [doStartTime, setStartTime] = useState(starTimeIsLastEnd); + const [doPublic, setPublic] = useState(defaultPublic); + + useEffect(() => { + setStartTime(starTimeIsLastEnd); + }, [starTimeIsLastEnd]); + + useEffect(() => { + setPublic(defaultPublic); + }, [defaultPublic]); + + return ( +
+ + + eventsHandler( + 'add', + { type: 'event', order: index + 1, isPublic: doPublic }, + { startIsLastEnd: doStartTime ? index : undefined } + ) + } + > + E{showKbd && Alt + E} + + + + eventsHandler('add', { type: 'delay', order: index + 1 })} + > + D{showKbd && Alt + D} + + + + eventsHandler('add', { type: 'block', order: index + 1 })} + > + B{showKbd && Alt + B} + + +
+ setStartTime(e.target.checked)} + > + Start time is last end + + setPublic(e.target.checked)} + > + Default public + +
+
+ ); +} diff --git a/client/src/features/editors/EntryBlock/EntryBlock.module.scss b/client/src/features/editors/EntryBlock/EntryBlock.module.scss new file mode 100644 index 000000000..b6c66d9ab --- /dev/null +++ b/client/src/features/editors/EntryBlock/EntryBlock.module.scss @@ -0,0 +1,92 @@ +@use '../../../styles/main' as *; + +.create { + padding: 0 0.5em; + box-sizing: border-box; + + width: 100%; + border-radius: 8px; + font-size: 12px; + + position: relative; + height: 20px; + margin: -8px 0; + transition: height 0.1s ease; + + * { + display: none; + } + + &:hover { + opacity: 1; + height: calc(2.5em + 20px); + display: flex; + align-items: center; + justify-content: center; + gap: 5%; + transition: height 0.15s ease; + + * { + display: flex; + } + } + + .createEvent, + .createDelay, + .createBlock { + width: auto; + padding: 0 16px; + height: 24px; + text-align: center; + vertical-align: center; + font-weight: 600; + line-height: 21px; + border-radius: 4px; + opacity: 0.6; + + .keyboard { + margin-left: 4px; + padding: 0 4px; + color: #ccc; + border-radius: 2px; + font-family: Monospaced, sans-serif; + background-color: rgba(0, 0, 0, 0.24); + } + } + + .createEvent { + border: 1px solid #2b6cb0; + color: lighten(#2b6cb0, 30%); + + &:hover { + background-color: #2b6cb0; + opacity: 1; + } + } + + .createDelay { + border: 1px solid #ecc94b;; + color: lighten(#ecc94b, 30%); + + &:hover { + background-color: #ecc94b; + opacity: 1; + } + } + + .createBlock { + border: 1px solid #805ad5; + color: lighten(#805ad5, 30%); + + &:hover { + background-color: #805ad5; + opacity: 1; + } + } + + .options { + display: flex; + flex-direction: column; + opacity: 0.65; + } +} diff --git a/client/src/features/editors/EventBlock/EventBlock.module.css b/client/src/features/editors/EventBlock/EventBlock.module.css index 8507b923d..c9cd728fe 100644 --- a/client/src/features/editors/EventBlock/EventBlock.module.css +++ b/client/src/features/editors/EventBlock/EventBlock.module.css @@ -142,7 +142,7 @@ background-color: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 4px; + border-radius: 8px; width: 100%; display: block; @@ -150,11 +150,13 @@ overflow: hidden; } -.collapsed .titleContasiner { +.collapsed .titleContainer { height: fit-content; } + .expanded .titleContainer { height: 100%; + border-radius: 4px; } .oscLabel { diff --git a/client/src/features/editors/list/EventList.jsx b/client/src/features/editors/list/EventList.jsx index d255ec312..0079d354f 100644 --- a/client/src/features/editors/list/EventList.jsx +++ b/client/src/features/editors/list/EventList.jsx @@ -1,21 +1,22 @@ import style from './List.module.scss'; -import { createRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { createRef, useCallback, useContext, useEffect, useState } from 'react'; import { useSocket } from 'app/context/socketContext'; import Empty from 'common/state/Empty'; import EventListItem from './EventListItem'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; -import { useAtom } from 'jotai'; -import { SelectSetting } from 'app/context/settingsAtom'; +import EntryBlock from '../EntryBlock/EntryBlock'; +import { CursorContext } from '../../../app/context/CursorContext'; +import { LocalEventSettingsContext } from '../../../app/context/LocalEventSettingsContext'; export default function EventList(props) { const { events, eventsHandler } = props; + const { cursor, moveCursorUp, moveCursorDown, setCursor, isCursorLocked } = + useContext(CursorContext); const socket = useSocket(); const [selectedId, setSelectedId] = useState(null); const [nextId, setNextId] = useState(null); - const [cursor, setCursor] = useState(0); - const [cursorSettings] = useAtom(useMemo(() => SelectSetting('cursor'), [])); - const cursorRef = createRef(); + const { showQuickEntry } = useContext(LocalEventSettingsContext); // Handle keyboard shortcuts const handleKeyPress = useCallback( @@ -26,13 +27,11 @@ export default function EventList(props) { if (e.altKey && (!e.ctrlKey || !e.shiftKey)) { // Arrow down if (e.keyCode === 40) { - if (cursor == null) setCursor(0); - else if (cursor < events.length - 1) setCursor(cursor + 1); + if (cursor < events.length - 1) moveCursorDown(); } // Arrow up if (e.keyCode === 38) { - if (cursor == null) setCursor(0); - else if (cursor > 0) setCursor(cursor - 1); + if (cursor > 0) moveCursorUp(); } // E if (e.key === 'e' || e.key === 'E') { @@ -54,7 +53,7 @@ export default function EventList(props) { } } }, - [cursor, events, eventsHandler] + [cursor, events.length, eventsHandler, moveCursorDown, moveCursorUp] ); useEffect(() => { @@ -67,7 +66,7 @@ export default function EventList(props) { return () => { document.removeEventListener('keydown', handleKeyPress); }; - }, [handleKeyPress, cursor, events]); + }, [handleKeyPress, cursor, events, setCursor]); // handle incoming messages useEffect(() => { @@ -101,13 +100,14 @@ export default function EventList(props) { block: 'nearest', inline: 'start', }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [cursor]); // if selected event // or cursor settings changed useEffect(() => { // and if we are locked - if (cursorSettings !== 'locked' || selectedId == null) return; + if (!isCursorLocked || selectedId == null) return; // move cursor let gotoIndex = -1; @@ -123,7 +123,8 @@ export default function EventList(props) { // move cursor setCursor(gotoIndex); } - }, [selectedId, cursorSettings]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId, isCursorLocked]); if (events.length < 1) { return ; @@ -155,11 +156,7 @@ export default function EventList(props) { {(provided) => ( -
+
{events.map((e, index) => { if (index === 0) { cumulativeDelay = 0; @@ -174,24 +171,34 @@ export default function EventList(props) { previousEnd = thisEnd; thisEnd = e.timeEnd; } - return ( -
- +
+ {index === 0 && showQuickEntry && ( + + )} +
+ +
+ {showQuickEntry && ( + + )}
); })} diff --git a/client/src/features/editors/list/EventListItem.jsx b/client/src/features/editors/list/EventListItem.jsx index 6f0e0c088..ff88a1a86 100644 --- a/client/src/features/editors/list/EventListItem.jsx +++ b/client/src/features/editors/list/EventListItem.jsx @@ -3,6 +3,7 @@ import BlockBlock from '../BlockBlock/BlockBlock'; import EventBlock from '../EventBlock/EventBlock'; import { memo, useContext } from 'react'; import { LoggingContext } from '../../../app/context/LoggingContext'; +import { LocalEventSettingsContext } from '../../../app/context/LocalEventSettingsContext'; const areEqual = (prevProps, nextProps) => { return ( @@ -29,12 +30,21 @@ const EventListItem = (props) => { ...rest } = props; const { emitError } = useContext(LoggingContext); + const { starTimeIsLastEnd, defaultPublic } = useContext(LocalEventSettingsContext); // Create / delete new events const actionHandler = (action, payload) => { switch (action) { case 'event': - eventsHandler('add', { type: 'event', order: index + 1 }); + eventsHandler( + 'add', + { + type: 'event', + order: index + 1, + isPublic: defaultPublic, + }, + { startIsLastEnd: starTimeIsLastEnd ? index : undefined } + ); break; case 'delay': eventsHandler('add', { type: 'delay', order: index + 1 }); @@ -85,9 +95,7 @@ const EventListItem = (props) => { /> ); case 'block': - return ( - - ); + return ; case 'delay': return ( { - queryClient.setQueryData( - [EVENTS_TABLE, context.newEvent.id], - context.previousEvent - ); + queryClient.setQueryData([EVENTS_TABLE, context.newEvent.id], context.previousEvent); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -105,10 +96,7 @@ export default function EventListWrapper() { queryClient.cancelQueries([EVENTS_TABLE, newEvent.id]); // Snapshot the previous value - const previousEvent = queryClient.getQueryData([ - EVENTS_TABLE, - newEvent.id, - ]); + const previousEvent = queryClient.getQueryData([EVENTS_TABLE, newEvent.id]); // optimistically update object queryClient.setQueryData([EVENTS_TABLE, newEvent.id], newEvent); @@ -119,10 +107,7 @@ export default function EventListWrapper() { // Mutation fails, rollback undos optimist update onError: (error, newEvent, context) => { - queryClient.setQueryData( - [EVENTS_TABLE, context.newEvent.id], - context.previousEvent - ); + queryClient.setQueryData([EVENTS_TABLE, context.newEvent.id], context.previousEvent); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -237,11 +222,16 @@ export default function EventListWrapper() { // Events API const eventsHandler = useCallback( - async (action, payload) => { + async (action, payload, options = undefined) => { switch (action) { case 'add': try { - await addEvent.mutateAsync(payload); + let newEvent = { ...payload }; + // there is an option to pass an index of an array to use as start time + if (options?.startIsLastEnd !== undefined) { + newEvent.timeStart = data[options.startIsLastEnd].timeEnd || 0; + } + await addEvent.mutateAsync(newEvent); } catch (error) { emitError(`Error fetching data: ${error.message}`); } @@ -326,6 +316,7 @@ export default function EventListWrapper() { break; } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [data] ); diff --git a/client/src/features/editors/list/List.module.scss b/client/src/features/editors/list/List.module.scss index 417dea6b8..e0655f421 100644 --- a/client/src/features/editors/list/List.module.scss +++ b/client/src/features/editors/list/List.module.scss @@ -23,11 +23,11 @@ .cursor { width: 100%; background: linear-gradient( - 180deg, - #ff7597 2%, - #0001 3%, - #0001 97%, - #ff7597 98% + 180deg, + #ff7597 2%, + #0001 3%, + #0001 97%, + #ff7597 98% ); border-radius: 14px; diff --git a/client/src/features/menu/EventListMenu.jsx b/client/src/features/menu/EventListMenu.jsx index 54bc4f46d..b25159b64 100644 --- a/client/src/features/menu/EventListMenu.jsx +++ b/client/src/features/menu/EventListMenu.jsx @@ -1,16 +1,16 @@ -import { memo, useMemo } from 'react'; +import { memo, useContext } from 'react'; import { Divider } from '@chakra-ui/react'; -import style from './EventListMenu.module.css'; +import { CursorContext } from '../../app/context/CursorContext'; import MenuActionButtons from './MenuActionButtons'; import CollapseBtn from 'common/components/buttons/CollapseBtn'; -import ExpandBtn from 'common/components/buttons/ExpandBtn'; -import { SelectSetting, HandleOptions } from 'app/context/settingsAtom'; -import { useAtom } from 'jotai'; -import LockIconBtn from 'common/components/buttons/LockIconBtn'; +import CursorUpBtn from '../../common/components/buttons/CursorUpBtn'; +import CursorDownBtn from '../../common/components/buttons/CursorDownBtn'; +import CursorLockedBtn from 'common/components/buttons/CursorLockedBtn'; +import style from './EventListMenu.module.css'; const EventListMenu = ({ eventsHandler }) => { - const [cursorSettings] = useAtom(useMemo(() => SelectSetting('cursor'), [])); - const [, SetOption] = useAtom(HandleOptions); + const { isCursorLocked, toggleCursorLocked, moveCursorUp, moveCursorDown } = + useContext(CursorContext); const actionHandler = (action) => { switch (action) { @@ -23,12 +23,14 @@ const EventListMenu = ({ eventsHandler }) => { case 'block': eventsHandler('add', { type: action, order: 0 }); break; + case 'cursorUp': + moveCursorUp(); + break; + case 'cursorDown': + moveCursorDown(); + break; case 'togglelock': - let newSet = 'locked'; - if (cursorSettings === 'locked') { - newSet = 'unlocked'; - } - SetOption({ cursor: newSet }); + toggleCursorLocked(); break; case 'deleteall': eventsHandler('deleteall'); @@ -40,16 +42,14 @@ const EventListMenu = ({ eventsHandler }) => { return (
- eventsHandler('collapseall')} - /> - eventsHandler('expandall')} /> + eventsHandler('collapseall')} /> - actionHandler('cursorUp')} /> + actionHandler('cursorDown')} /> + actionHandler('togglelock')} - active={cursorSettings === 'locked'} + active={isCursorLocked} /> diff --git a/client/src/features/modals/AppSettingsModal.jsx b/client/src/features/modals/AppSettingsModal.jsx index c5ba0130f..bc9e3aac1 100644 --- a/client/src/features/modals/AppSettingsModal.jsx +++ b/client/src/features/modals/AppSettingsModal.jsx @@ -1,16 +1,6 @@ import { ModalBody } from '@chakra-ui/modal'; -import { - FormControl, - FormLabel, - Input, - PinInput, - PinInputField, -} from '@chakra-ui/react'; -import { - getSettings, - ontimePlaceholderSettings, - postSettings, -} from 'app/api/ontimeApi'; +import { Checkbox, FormControl, FormLabel, Input, PinInput, PinInputField } from '@chakra-ui/react'; +import { getSettings, ontimePlaceholderSettings, postSettings } from 'app/api/ontimeApi'; import { useContext, useEffect, useState } from 'react'; import { useFetch } from 'app/hooks/useFetch'; import { APP_SETTINGS } from 'app/api/apiConstants'; @@ -20,6 +10,7 @@ import { IconButton } from '@chakra-ui/button'; import { FiEye } from 'react-icons/fi'; import SubmitContainer from './SubmitContainer'; import { inputProps } from './modalHelper'; +import { LocalEventSettingsContext } from '../../app/context/LocalEventSettingsContext'; export default function AppSettingsModal() { const { data, status, refetch } = useFetch(APP_SETTINGS, getSettings); @@ -29,6 +20,19 @@ export default function AppSettingsModal() { const [submitting, setSubmitting] = useState(false); const [hidePin, setHidePin] = useState(true); + const { + showQuickEntry, + setShowQuickEntry, + starTimeIsLastEnd, + setStarTimeIsLastEnd, + defaultPublic, + setDefaultPublic, + } = useContext(LocalEventSettingsContext); + + const [doShowQuickEntry, setDoShowQuickEntry] = useState(showQuickEntry); + const [doStarTimeIsLastEnd, setDoStarTimeIsLastEnd] = useState(starTimeIsLastEnd); + const [doDefaultPublic, setDoDefaultPublic] = useState(defaultPublic); + /** * Set formdata from server state */ @@ -40,6 +44,24 @@ export default function AppSettingsModal() { }); }, [changed, data]); + /** + * Set formdata from context + */ + useEffect(() => { + if (showQuickEntry == null) return; + setDoShowQuickEntry(showQuickEntry); + }, [showQuickEntry]); + + useEffect(() => { + if (starTimeIsLastEnd == null) return; + setDoStarTimeIsLastEnd(starTimeIsLastEnd); + }, [starTimeIsLastEnd]); + + useEffect(() => { + if (defaultPublic == null) return; + setDoDefaultPublic(defaultPublic); + }, [defaultPublic]); + /** * Validate and submit data */ @@ -47,29 +69,38 @@ export default function AppSettingsModal() { event.preventDefault(); setSubmitting(true); + // set context + setShowQuickEntry(doShowQuickEntry); + setStarTimeIsLastEnd(doStarTimeIsLastEnd); + setDefaultPublic(doDefaultPublic); + const f = formData; - let e = { status: false, message: '' }; - // Validate fields - if (f.pinCode === '' || f.pinCode == null) { - e.status = true; - e.message += 'App pin code removed'; - } else { - e.status = true; - e.message += 'App pin code added'; + // we might not have changed this + if (f.pinCode !== data.pinCode) { + let e = { status: false, message: '' }; + + // Validate fields + if (f.pinCode === '' || f.pinCode == null) { + e.status = true; + e.message += 'App pin code removed'; + } else { + e.status = true; + e.message += 'App pin code added'; + } + + // set fields with error + if (!e.status) { + emitError(`Invalid Input: ${e.message}`); + } else { + await postSettings(formData); + await refetch(); + emitWarning(e.message); + setChanged(false); + } } - - // set fields with error - if (!e.status) { - emitError(`Invalid Input: ${e.message}`); - } else { - await postSettings(formData); - await refetch(); - emitWarning(e.message); - setChanged(false); - } - setSubmitting(false); + setChanged(false); }; /** @@ -78,6 +109,11 @@ export default function AppSettingsModal() { const revert = async () => { setChanged(false); await refetch(); + + // set from context + setDoShowQuickEntry(showQuickEntry); + setDoStarTimeIsLastEnd(starTimeIsLastEnd); + setDoDefaultPublic(defaultPublic); }; /** @@ -92,6 +128,13 @@ export default function AppSettingsModal() { setChanged(true); }; + /** + * Sets changed flag to true + */ + const handleContextChange = () => { + setChanged(true); + }; + const disableModal = status !== 'success'; return ( @@ -157,6 +200,36 @@ export default function AppSettingsModal() {
+
Create Event Default Settings
+
+ { + setDoShowQuickEntry(e.target.checked); + handleContextChange(); + }} + > + Show quick entry on hover + + { + setDoStarTimeIsLastEnd(e.target.checked); + handleContextChange(); + }} + > + Start time is last end + + { + setDoDefaultPublic(e.target.checked); + handleContextChange(); + }} + > + Event default public + +