node server + separate client folder

This commit is contained in:
cv
2021-04-05 22:33:31 +02:00
parent d0c8567022
commit c39f3f2dde
56 changed files with 724 additions and 6 deletions
+47
View File
@@ -0,0 +1,47 @@
{
"name": "ontime",
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/icons": "^1.0.7",
"@chakra-ui/react": "^1.4.1",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"date-fns": "^2.19.0",
"framer-motion": "^4",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"socket.io-client": "^4.0.1",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+9
View File
@@ -0,0 +1,9 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.App {
background-image: linear-gradient(rgb(20, 20, 20) 1.7%, rgb(48, 48, 48) 97%);
height: 100vh;
}
+24
View File
@@ -0,0 +1,24 @@
import { Route } from 'react-router';
import './App.css';
import { EventProvider } from './app/context/eventContext';
import { EventListProvider } from './app/context/eventListContext';
import { PresenterMessageProvider } from './app/context/presenterMessageContext';
import Editor from './features/editors/Editor';
import DefaultPresenter from './features/viewers/DefaultPresenter';
function App() {
return (
<PresenterMessageProvider>
<EventProvider>
<div className='App'>
<Route path='/' exact component={DefaultPresenter} />
<EventListProvider>
<Route path='/editor' exact component={Editor} />
</EventListProvider>
</div>
</EventProvider>
</PresenterMessageProvider>
);
}
export default App;
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+13
View File
@@ -0,0 +1,13 @@
import { createContext, useState } from 'react';
export const EventContext = createContext([[], () => {}]);
export function EventProvider(props) {
const [event, setEvent] = useState(null);
return (
<EventContext.Provider value={[event, setEvent]}>
{props.children}
</EventContext.Provider>
);
}
@@ -0,0 +1,14 @@
import { createContext, useState } from 'react';
import { sampleData } from '../sampleData';
export const EventListContext = createContext([[], () => {}]);
export function EventListProvider(props) {
const [events, setEvents] = useState(sampleData.events);
return (
<EventListContext.Provider value={[events, setEvents]}>
{props.children}
</EventListContext.Provider>
);
}
@@ -0,0 +1,16 @@
import { createContext, useState } from 'react';
export const PresenterMessagesContext = createContext([[], () => {}]);
export function PresenterMessageProvider(props) {
const [presMessage, setPresMessage] = useState({
text: '',
show: '',
});
return (
<PresenterMessagesContext.Provider value={[presMessage, setPresMessage]}>
{props.children}
</PresenterMessagesContext.Provider>
);
}
+56
View File
@@ -0,0 +1,56 @@
const dummy = new Date();
export const sampleData = {
presenterMessage: {
text: 'Only the presenter sees this',
active: false,
},
publicMessage: {
text: 'Everyone sees this',
active: false,
},
events: [
{
id: '1',
order: 1,
title: 'Is the internet a fad?',
subtitle: 'It is',
presenter: 'Carlos Valente',
timeStart: new Date('October 13, 2014 11:30:00'),
timeEnd: new Date('October 13, 2014 11:50:00'),
clockStarted: dummy,
timerDuration: 60,
type: 'event',
},
{
id: 0.4849093424577693,
order: 2,
timerDuration: 25,
type: 'delay',
},
{
id: '2',
order: 3,
title: 'Is reddit a dictatorship?',
subtitle: 'It is',
presenter: 'Carlos Valente',
timeStart: new Date('October 13, 2014 12:30:00'),
timeEnd: new Date('October 13, 2014 12:50:00'),
clockStarted: dummy,
timerDuration: 60,
type: 'event',
},
{
id: '3',
order: 4,
title: 'Out of words',
subtitle: '',
presenter: 'Carlos Valente',
timeStart: new Date('October 13, 2014 13:30:00'),
timeEnd: new Date('October 13, 2014 13:50:00'),
clockStarted: dummy,
timerDuration: 60,
type: 'event',
},
],
};
+7
View File
@@ -0,0 +1,7 @@
export const userConfig = {
timerColorOnPause: '#555',
timerColorOnRunning: '#FFF',
timerColorOnMessage: '#CCC',
timerColorOnTimeOver: '#F00',
overTimeText: '',
}
+2
View File
@@ -0,0 +1,2 @@
export const clamp = (num, a, b) =>
Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
@@ -0,0 +1,18 @@
import styles from './Countdown.module.css';
function display(seconds) {
const format = (val) => `0${Math.floor(val)}`.slice(-2);
const hours = seconds / 3600;
const minutes = (seconds % 3600) / 60;
if (hours < 1) return [minutes, seconds % 60].map(format).join(':');
else return [hours, minutes, seconds % 60].map(format).join(':');
}
export default function Countdown({ time, small }) {
return (
<div className={small ? styles.countdownClockSmall : styles.countdownClock}>
{time === null ? '- -:- -' : display(time * 60)}
</div>
);
}
@@ -0,0 +1,21 @@
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;800&display=swap');
.countdownClock {
font-family: 'Open Sans', sans-serif;
font-size: 18vw;
margin-top: 6vh;
margin-bottom: 5vh;
text-align: center;
letter-spacing: 0.125em;
line-height: 0.9;
}
.countdownClockSmall {
font-family: 'Open Sans', sans-serif;
font-size: 6em;
text-align: center;
letter-spacing: 0.1em;
line-height: 0.9;
font-weight: 600;
color: #333;
}
@@ -0,0 +1,16 @@
import { clamp } from '../../../app/utils';
import styles from './MyProgressBar.module.css';
export default function MyProgressBar({ normalisedComplete }) {
const percentComplete = clamp(100 - normalisedComplete * 100, 0, 100);
const completeWidth = `${percentComplete}%`;
return (
<div className={styles.progress}>
<div
className={styles.progressBar}
style={{ width: completeWidth }}
></div>
</div>
);
}
@@ -0,0 +1,22 @@
.progress {
padding: 1vh;
width: 90%;
margin: 0 auto;
background: rgba(0, 0, 0, 0.25);
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25), 0 1px rgba(255, 255, 255, 0.08);
}
.progressBar {
height: 2vh;
background: linear-gradient(
90deg,
rgb(145, 255, 191, 1) 0%,
rgba(145, 255, 191, 0.8) 20%,
rgba(145, 255, 191, 0.5) 50%,
rgba(145, 255, 191, 0.2) 100%
);
border-radius: 4px;
transition: 0.4s linear;
transition-property: width, background-color;
}
@@ -0,0 +1,10 @@
import styles from './SmallTimer.module.css';
export default function SmallTimer({ label, time }) {
return (
<div className={styles.SmallTimer}>
<div className={styles.label}>{label}</div>
<div className={styles.timer}>{time}</div>
</div>
);
}
@@ -0,0 +1,25 @@
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;800&display=swap');
.smallTimer {
display: flex;
flex-direction: column;
}
.label,
.timer {
font-size: 1vw;
text-align: center;
}
.label {
font-size: 1.5vw;
color: #888;
margin-bottom: 0;
}
.timer {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.125em;
}
@@ -0,0 +1,11 @@
import { Flex, Text } from '@chakra-ui/layout';
import styles from './NumberedText.module.css';
export default function NumberedText({ number = 1, text = '' }) {
return (
<Flex>
<div className={styles.stylednumber}>{number}</div>
<Text>{text}</Text>
</Flex>
);
}
@@ -0,0 +1,12 @@
.stylednumber {
display: inline;
text-align: center;
color: white;
font-weight: 600;
background-color: #319795;
width: 1.5em;
border-radius: 1em;
margin-right: 0.5em;
}
+7
View File
@@ -0,0 +1,7 @@
export const timeFormat = 'HH:mm';
// make date with string
export const timeToDate = (time) => {
const today = new Date();
return new Date(today.toDateString() + ' ' + time);
};
+22
View File
@@ -0,0 +1,22 @@
import { FormErrorMessage } from '@chakra-ui/form-control';
import { FormLabel } from '@chakra-ui/form-control';
import { FormControl } from '@chakra-ui/form-control';
import { Input } from '@chakra-ui/input';
import { Field } from 'formik';
export default function ChakraInput(props) {
const { label, name, ...rest } = props;
return (
<Field name={name}>
{({ field, form }) => {
return (
<FormControl isInvalid={form.errors[name] && form.touched[name]}>
<FormLabel htmlFor={name}>{label}</FormLabel>
<Input id={name} {...rest} {...field} />
<FormErrorMessage>{form.errors[name]}</FormErrorMessage>
</FormControl>
);
}}
</Field>
);
}
@@ -0,0 +1,77 @@
import { Button } from '@chakra-ui/button';
import style from './PlaybackControl.module.css';
import Countdown from '../../common/components/countdown/Countdown';
import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons';
// BUTTON DEFINITION
const defProps = {
colorScheme: 'blackAlpha',
variant: 'outline',
};
const bigSize = 120;
export default function PlaybackControl(props) {
const { time, roll } = props;
return (
<div className={style.mainContainer}>
<div className={style.timeContainer}>
<div className={style.timer}>
<Countdown time={time} small />
</div>
</div>
<div className={style.playbackContainer}>
<Button
width={bigSize}
colorScheme='green'
className={style.start}
disabled={roll}
onClick={() => props.playbackControl('play')}
>
Start
</Button>
<Button
width={bigSize}
colorScheme='orange'
className={style.pause}
disabled={roll}
onClick={() => props.playbackControl('pause')}
>
Pause
</Button>
</div>
<div className={style.trackContainer}>
<Button
width={bigSize}
{...defProps}
leftIcon={<ArrowBackIcon />}
className={style.prev}
disabled={roll}
onClick={() => props.playbackControl('previous')}
>
Prev
</Button>
<Button
width={bigSize}
{...defProps}
rightIcon={<ArrowForwardIcon />}
className={style.next}
disabled={roll}
onClick={() => props.playbackControl('next')}
>
Next
</Button>
<Button
width={bigSize}
colorScheme='blue'
className={style.reset}
onClick={() => props.playbackControl('roll')}
disabled
>
Roll
</Button>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
.mainContainer {
width: 90%;
display: flex;
margin: 0 auto;
flex-direction: column;
gap: 5px;
}
.timeContainer {
display: grid;
gap: 5px;
grid-template-areas:
'timer plus1 plus5'
'timer minu1 minu5';
grid-template-columns: auto 50px 50px;
}
.timer {
grid-area: timer;
}
.plus1 {
grid-area: plus1;
}
.plus5 {
grid-area: plus5;
}
.minu1 {
grid-area: minu1;
}
.minu5 {
grid-area: minu5;
}
.playbackContainer,
.trackContainer {
display: flex;
justify-content: space-evenly;
padding-top: 0.5em;
}
+389
View File
@@ -0,0 +1,389 @@
import { IconButton } from '@chakra-ui/button';
import { SettingsIcon } from '@chakra-ui/icons';
import { Grid, GridItem, Heading } from '@chakra-ui/layout';
import { Box } from '@chakra-ui/layout';
import { addSeconds, getHours, getMinutes, getSeconds } from 'date-fns';
import { differenceInSeconds } from 'date-fns/esm';
import { useContext } from 'react';
import { useEffect, useState } from 'react';
import { EventContext } from '../../app/context/eventContext';
import { EventListContext } from '../../app/context/eventListContext';
import NumberedText from '../../common/components/text/NumberedText';
import PlaybackControl from '../control/PlaybackControl';
import MessageForm from '../form/MessageForm';
import PreviewContainer from '../viewers/PreviewContainer';
import styles from './Editor.module.css';
import EventList from './list/EventList';
import { io } from 'socket.io-client';
export default function Editor() {
const [formMode, setFormMode] = useState(null);
const [events] = useContext(EventListContext);
const [event, setEvent] = useContext(EventContext);
const [playback, setPlayback] = useState({
current: null,
next: null,
currentTimer: null,
numEvents: 0,
state: 'pause',
prevState: 'pause',
});
const [response, setResponse] = useState('');
// WEBSOCKETZ
useEffect(() => {
// TODO: add namespace?
const socket = io('http://localhost:4001', { transport: ['websocket'] });
console.log('websocket started');
socket.on('FromAPI', (data) => {
setResponse(data);
console.log('websocket stuff', data);
});
return () => socket.disconnect();
}, []);
// Timer stuff
const [timer, setTimer] = useState({
TIMER_UPDATE_INTERVAL: 250,
currentTime: null,
currentTimeSeconds: null,
playMode: 'stop',
isStarted: false,
isRunning: false,
startTime: null,
pauseTime: null,
lastRun: null,
elapsedTime: 0,
elapsedStartedTime: 0,
elapsedRunningTime: 0,
totalElapsedPausedTime: 0,
periodElapsedPausedTime: 0,
elapsedResumeTime: 0,
targetTime: null,
});
// update timer object
const updateTimer = (vals) => {
setTimer({ ...timer, ...vals });
};
// set timer target
const setTimerTargetinSeconds = (target) => {
const t = addSeconds(new Date(), target);
updateTimer({ currentTime: t, currentTimeSeconds: target, targetTime: t });
};
// timer playback control
const setTimerState = (state) => {
const now = new Date();
switch (state) {
case 'play': {
updateTimer({
playMode: state,
isStarted: true,
startTime: now,
lastRun: now,
});
break;
}
case 'pause': {
updateTimer({ playMode: state, pauseTime: now, isRunning: false });
break;
}
default:
break;
}
};
// update
const updateTime = () => {
// exit if we are not ready
if (timer.startTime == null) return;
// aux
const now = new Date();
// how long has the time been running
const elapsedTime = now - timer.startTime;
if (timer.playMode === 'play') {
// current time here
const currentTime = timer.targetTime - elapsedTime;
updateTimer({
currentTime: currentTime,
currentTimeSeconds: getSeconds(currentTime),
elapsedTime: elapsedTime,
lastRun: now,
});
}
if (timer.playMode === 'pause') {
const pausedTime = now - timer.pauseTime;
updateTimer({
totalElapsedPausedTime: timer.totalElapsedPausedTime + pausedTime,
periodElapsedPausedTime: timer.periodElapsedPausedTime + now,
elapsedTime: elapsedTime,
lastRun: now,
});
}
// call again
setTimeout(updateTime, timer.TIMER_UPDATE_INTERVAL);
};
// when playmode changes, we might schedule a timer
// is this enough for change or should i check prevstate?
useEffect(() => {
if (playback.state !== 'stop' || playback.state !== null) {
updateTime();
}
}, [playback.state]);
const updatePlayback = (vals) => {
setPlayback({ ...playback, ...vals });
};
// gets timer on current
// ?? I have already done this a few times,
// maybe loop once and get all data?
const getCurrentTime = (target) => {
if (events !== null) {
// loop through events to find target
const filteredEvents = events.filter((e) => e.type === 'event');
const curEvent = filteredEvents[target];
// set as event
setEvent(curEvent);
// extract time only from dates
let minStart = getHours(curEvent.timeStart) * 60;
minStart = minStart + getMinutes(curEvent.timeStart);
let minEnd = getHours(curEvent.timeEnd) * 60;
minEnd = minEnd + getMinutes(curEvent.timeEnd);
// return time in seconds
return (minEnd - minStart) * 60;
}
};
const playbackControl = (action, payload) => {
switch (action) {
case 'play': {
if (playback.state !== 'play') {
updatePlayback({ state: 'play', prevState: playback.state });
setTimerState('play');
}
break;
}
case 'pause': {
if (playback.state !== 'pause') {
updatePlayback({ state: 'pause', prevState: playback.state });
setTimerState('pause');
}
break;
}
case 'roll': {
if (playback.state === 'roll' && playback.prevState !== 'roll') {
updatePlayback({ state: playback.prevState, prevState: 'roll' });
} else {
updatePlayback({ state: 'roll', prevState: playback.state });
}
break;
}
case 'previous': {
if (playback.numEvents !== null) {
let cur = null,
nxt = null;
if (playback.current === null || playback.current === 0) {
cur = 0;
} else {
cur = playback.current - 1;
}
if (playback.numEvents > 1) nxt = cur + 1;
if (nxt > playback.numEvents) nxt = playback.numEvents;
// get time
let time = getCurrentTime(cur);
// update playback
updatePlayback({ current: cur, next: nxt, currentTimer: time });
// set timer
setTimerTargetinSeconds(time);
}
break;
}
case 'next': {
if (playback.numEvents !== null) {
let cur = null,
nxt = null;
if (playback.current === null) {
cur = 0;
} else {
cur = playback.next;
}
if (playback.numEvents > 1) nxt = cur + 1;
if (nxt >= playback.numEvents) nxt = playback.numEvents - 1;
// get time
let time = getCurrentTime(cur);
// update playback
updatePlayback({ current: cur, next: nxt, currentTimer: time });
// set timer
setTimerTargetinSeconds(time);
}
break;
}
default:
break;
}
};
console.log('playback here', playback);
console.log('timer here', timer);
return (
<Grid
templateRows='1fr 1fr 1fr'
templateColumns='1fr 25vw 25vw 5vw'
gap={5}
className={styles.mainContainer}
>
<GridItem rowSpan={3}>
<Box className={styles.editor} borderRadius='0.5em'>
<Heading size='lg' style={{ paddingBottom: '0.25em' }}>
Event List
</Heading>
<NumberedText number={1} text={'Manage and select event to run'} />
<div className={styles.content}>
<EventList
formMode={formMode}
setFormMode={setFormMode}
selected={playback.current}
updatePlayback={updatePlayback}
/>
</div>
</Box>
</GridItem>
<GridItem colStart={2} rowStart={2} rowSpan={2} colSpan={2}>
<Box className={styles.editor} borderRadius='0.5em' overflowX='auto'>
<Heading size='lg' style={{ paddingBottom: '0.25em' }}>
Preview Displays
</Heading>
<NumberedText number={4} text={'Realtime screen preview'} />
<div className={styles.content}>
<PreviewContainer />
</div>
</Box>
</GridItem>
<GridItem colStart={2} rowStart={1} rowSpan={1} colSpan={1}>
<Box className={styles.editor} borderRadius='0.5em'>
<Heading size='lg' style={{ paddingBottom: '0.25em' }}>
Display Messages
</Heading>
<NumberedText
number={2}
text={'Show realtime messages on separate screen types'}
/>
<div className={styles.content}>
<MessageForm />
</div>
</Box>
</GridItem>
<GridItem colStart={3} rowStart={1}>
<Box className={styles.editor} borderRadius='0.5em'>
<Heading size='lg' style={{ paddingBottom: '0.25em' }}>
Time Control
</Heading>
<NumberedText number={3} text={'Control Timer'} />
<div className={styles.content}>
<PlaybackControl
playback={playback}
playbackControl={playbackControl}
time={timer.currentTimeSeconds}
roll={playback.state === 'roll'}
/>
</div>
</Box>
</GridItem>
<GridItem colStart={4} rowSpan={3}>
<Box className={styles.editor} borderRadius='0.5em'>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2em',
paddingTop: '3em',
}}
>
<IconButton icon={<SettingsIcon />} isRound variant='outline' />
<div
style={{
width: 35,
height: 35,
backgroundColor: '#3b8cd8',
borderRadius: '50%',
}}
/>
<div
style={{
width: 35,
height: 35,
backgroundColor: '#3182ce',
borderRadius: '50%',
}}
/>
<div
style={{
width: 35,
height: 35,
backgroundColor: '#2778c4',
borderRadius: '50%',
}}
/>
<div
style={{
width: 35,
height: 35,
backgroundColor: '#1d6eba',
borderRadius: '50%',
}}
/>
<div
style={{
width: 35,
height: 35,
backgroundColor: '#1364b0',
borderRadius: '50%',
}}
/>
<div
style={{
width: 35,
height: 35,
backgroundColor: '#095aa6',
borderRadius: '50%',
}}
/>
</div>
</Box>
</GridItem>
</Grid>
);
}
@@ -0,0 +1,33 @@
.mainContainer {
width: 95%;
margin: auto;
height: 95vh;
}
.editor {
height: 100%;
margin-top: 1.5vh;
background-color: #fcfcfa;
/* 011627 */
padding: 1em 1.5em;
display: flex;
flex-direction: column;
}
.content {
padding-top: 1.5em;
}
.cornerButtonContainer {
position: relative;
top: -4.5em;
display: flex;
justify-content: flex-end;
}
.buttonContainer {
padding-bottom: 2em;
display: flex;
justify-content: flex-end;
}
@@ -0,0 +1,24 @@
import { IconButton } from '@chakra-ui/button';
import { AddIcon, MinusIcon } from '@chakra-ui/icons';
import style from './List.module.css';
export default function BlockBlock(props) {
return (
<div className={style.blockContainer}>
<div className={style.actionOverlay}>
<IconButton
size='xs'
icon={<MinusIcon />}
colorScheme='red'
onClick={() => props.deleteEvent(props.index)}
/>
<IconButton
size='xs'
icon={<AddIcon />}
colorScheme='blue'
onClick={() => props.createEvent(props.index)}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { IconButton } from '@chakra-ui/button';
import { AddIcon, MinusIcon } from '@chakra-ui/icons';
import { Box } from '@chakra-ui/layout';
import {
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
} from '@chakra-ui/slider';
import { useEffect, useState } from 'react';
import style from './List.module.css';
export default function DelayBlock(props) {
const [delay, setDelay] = useState(0);
const { data, ...rest } = props;
// update delay value in parent
const populateDelay = (value) => {
// check if value has changed
if (data.timerDuration !== value) {
// create object with new field
const newData = { ...data, timerDuration: delay };
// request update in parent
props.updateData(props.index, newData);
}
};
// update delay value in state
useEffect(() => {
setDelay(data.timerDuration);
}, [data]);
return (
<div className={style.delayContainer}>
<div className={style.delayValue}>{`${delay} min`}</div>
<Slider
defaultValue={data.timerDuration}
min={0}
max={60}
step={5}
onChange={(value) => setDelay(value)}
onChangeEnd={(value) => populateDelay(value)}
>
<SliderTrack bg='orange.100'>
<Box position='relative' right={10} />
<SliderFilledTrack bg='orange' />
</SliderTrack>
<SliderThumb boxSize={4} />
</Slider>
<div className={style.actionOverlay}>
<IconButton
size='xs'
icon={<MinusIcon />}
colorScheme='red'
onClick={() => props.deleteEvent(props.index)}
/>
<IconButton
size='xs'
icon={<AddIcon />}
colorScheme='blue'
onClick={() => props.createEvent(props.index)}
/>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
import { useContext, useEffect } from 'react';
import { EventContext } from '../../../app/context/eventContext';
import { EventListContext } from '../../../app/context/eventListContext';
import EventListItem from './EventListItem';
import style from './List.module.css';
import DelayBlock from './DelayBlock';
import BlockBlock from './BlockBlock';
import EventListMenu from '../../menu/EventListMenu';
export default function EventList(props) {
const [events, setEvents] = useContext(EventListContext);
const [event] = useContext(EventContext);
// update number of events
useEffect(() => {
const f = events.filter((e) => e.type === 'event');
props.updatePlayback({ numEvents: f.length });
}, [events]);
const insertItemAt = (item, index) => {
// handle insert at beggining of array
if (index === -1) {
// move all items one element down, starting from new position
events.forEach((e) => {
e.order = e.order + 1;
});
return [item, ...events];
} else {
let before = events.slice(0, index + 1);
let after = events.slice(index + 1);
// move all items one element down, starting from new position
after.forEach((e) => {
e.order = e.order + 1;
});
return [...before, item, ...after];
}
};
const createEvent = (itemIndex = -1) => {
// make an event
// TODO: Replace this with global def somewhere
// TODO: handle random ids better
let newEvent = {
id: Math.random(),
order: itemIndex + 1,
title: '',
subtitle: '',
presenter: '',
timeStart: new Date(),
timeEnd: new Date(),
clockStarted: null,
timerDuration: 0,
type: 'event',
};
// set to state
setEvents(insertItemAt(newEvent, itemIndex));
};
const createDelay = (itemIndex = 0) => {
// make an event
// TODO: Replace this with global def somewhere
// TODO: handle random ids better
let newEvent = {
id: Math.random(),
order: itemIndex + 1,
timerDuration: 0,
type: 'delay',
};
// set to state
setEvents(insertItemAt(newEvent, itemIndex));
};
const createBlock = (itemIndex = 0) => {
// make an event
// TODO: Replace this with global def somewhere
// TODO: handle random ids better
let newEvent = {
id: Math.random(),
order: itemIndex + 1,
type: 'block',
};
// set to state
setEvents(insertItemAt(newEvent, itemIndex));
};
const deleteEvent = (index) => {
// TODO: This feels weird?
let e = events.splice(index, 1);
setEvents([...events]);
};
const replaceAt = (array, index, value) => {
const ret = array.slice(0);
ret[index] = value;
return ret;
};
const updateData = (itemIndex, data) => {
const newEvents = replaceAt(events, itemIndex, data);
setEvents(newEvents);
};
console.log('events in event list', events);
let cumulativeDelay = 0;
let eventCount = -1;
return (
<>
<EventListMenu createEvent={createEvent} />
<div className={style.eventContainer}>
{events.map((e, index) => {
if (e.type === 'event') {
eventCount = eventCount + 1;
return (
<EventListItem
key={e.id}
index={index}
data={e}
selected={props.selected === eventCount}
createEvent={createEvent}
deleteEvent={deleteEvent}
createDelay={createDelay}
createBlock={createBlock}
updateData={updateData}
delay={cumulativeDelay}
/>
);
} else if (e.type === 'block') {
cumulativeDelay = 0;
return (
<BlockBlock
key={e.id}
index={index}
createEvent={createEvent}
deleteEvent={deleteEvent}
/>
);
} else if (e.type === 'delay') {
cumulativeDelay = cumulativeDelay + e.timerDuration;
return (
<DelayBlock
key={e.id}
index={index}
data={e}
createEvent={createEvent}
deleteEvent={deleteEvent}
updateData={updateData}
/>
);
}
})}
</div>
</>
);
}
@@ -0,0 +1,175 @@
import {
AddIcon,
ChevronDownIcon,
ChevronUpIcon,
MinusIcon,
NotAllowedIcon,
TimeIcon,
} from '@chakra-ui/icons';
import {
IconButton,
Editable,
EditablePreview,
EditableInput,
} from '@chakra-ui/react';
import { addMinutes, format } from 'date-fns';
import { useEffect, useState } from 'react';
import { timeFormat, timeToDate } from '../../../common/dateConfig';
import style from './List.module.css';
// small shorthand for adding delay and formatting date
const addAndFormat = (time, delay) => {
return format(addMinutes(time, delay), timeFormat);
};
export default function EventListItem(props) {
const [more, setMore] = useState(false);
// const [timeStart, setTimeStart] = useState(
// addAndFormat(props.data.timeStart, props.delay)
// );
const [timeStart, setTimeStart] = useState(0);
const [timeEnd, setTimeEnd] = useState(
addAndFormat(props.data.timeEnd, props.delay)
);
const { data, selected, delay, ...rest } = props;
// prepare time fields
useEffect(() => {
setTimeStart(addAndFormat(props.data.timeStart, props.delay));
setTimeEnd(addAndFormat(props.data.timeEnd, props.delay));
}, [props.data, props.delay]);
const updateValues = (field, value) => {
// validate field
if (field in data) {
// create object with new field
const newData = { ...data, [field]: value };
// request update in parent
props.updateData(props.index, newData);
} else {
console.log('field error', field);
}
};
return (
<form>
<div className={selected ? style.eventRowActive : style.eventRow}>
<div className={style.time}>
<Editable
onSubmit={(v) => updateValues('timeStart', timeToDate(v))}
value={timeStart}
onChange={(val) => setTimeStart(val)}
placeholder='--:--'
style={{ textAlign: 'center' }}
className={delay > 0 && style.delayedEditable}
>
<EditablePreview />
<EditableInput type='time' min='00:00' max='23:59' />
</Editable>
</div>
<div className={style.time}>
<Editable
onSubmit={(v) => updateValues('timeEnd', timeToDate(v))}
value={timeEnd}
onChange={(val) => setTimeEnd(val)}
placeholder='--:--'
style={{ textAlign: 'center' }}
className={delay > 0 && style.delayedEditable}
>
<EditablePreview />
<EditableInput type='time' min='00:00' max='23:59' />
</Editable>
</div>
<div className={style.rowDetailed}>
{more ? (
<div className={style.detailedContainer}>
<div style={{ display: 'block' }}>
<span className={style.detailedTitleUnderlined}>Title</span>
<Editable
onSubmit={(v) => updateValues('title', v)}
defaultValue={data.title}
placeholder='Add title'
style={{ display: 'inline' }}
>
<EditablePreview />
<EditableInput style={{ width: '13em' }} />
</Editable>
</div>
<div style={{ display: 'block' }}>
<span className={style.detailedTitleUnderlined}>Subtitle</span>
<Editable
onSubmit={(v) => updateValues('subtitle', v)}
defaultValue={data.subtitle}
placeholder='Add subtitle'
style={{ display: 'inline' }}
>
<EditablePreview />
<EditableInput style={{ width: '13em', minWidth: '13em' }} />
</Editable>
</div>
<div style={{ display: 'block' }}>
<span className={style.detailedTitleUnderlined}>Presenter</span>
<Editable
onSubmit={(v) => updateValues('presenter', v)}
defaultValue={data.presenter}
placeholder='Add presenter name'
style={{ display: 'inline' }}
>
<EditablePreview style={{}} />
<EditableInput style={{ width: '13em' }} />
</Editable>
</div>
</div>
) : (
<div className={style.titleContainer}>
<div>
<span className={style.detailedTitle}>Title</span>
<Editable
onSubmit={(v) => updateValues('title', v)}
defaultValue={data.title}
placeholder='Add title'
style={{ display: 'inline' }}
id='title'
>
<EditablePreview />
<EditableInput style={{ width: '13em' }} />
</Editable>
</div>
</div>
)}
<div className={style.more} onClick={() => setMore(!more)}>
{more ? <ChevronUpIcon /> : <ChevronDownIcon />}
</div>
</div>
<div className={style.actionOverlay}>
<IconButton
size='xs'
icon={<MinusIcon />}
colorScheme='red'
onClick={() => props.deleteEvent(props.index)}
/>
<IconButton
size='xs'
icon={<AddIcon />}
colorScheme='blue'
onClick={() => props.createEvent(props.index)}
/>
<IconButton
size='xs'
icon={<TimeIcon />}
colorScheme='yellow'
onClick={() => props.createDelay(props.index)}
/>
<IconButton
size='xs'
icon={<NotAllowedIcon />}
colorScheme='purple'
onClick={() => props.createBlock(props.index)}
/>
</div>
</div>
</form>
);
}
@@ -0,0 +1,184 @@
/* ============= EVENT LIST ============= */
.eventContainer {
}
/* ============= EVENT ITEM ============= */
.eventRow,
.eventRowActive {
margin: 0.2em 0;
padding: 0.2em 0.5em;
position: relative;
top: 1em;
left: 0;
width: 100%;
display: grid;
grid-template-columns: auto 5em 1fr auto;
padding-left: 1em;
gap: 0.5em;
justify-content: center;
align-content: center;
align-items: baseline;
border-radius: 8px;
font-size: 15px;
}
.eventRow {
background-color: #0001;
border-top: 2px solid #0001;
border-bottom: 2px solid #0001;
}
.eventRowActive {
background-color: #b2f5eaaa;
border-top: 2px solid #b2f5ea;
border-bottom: 2px solid #b2f5ea;
}
.arm,
.armActive {
width: 18px;
height: 18px;
border-radius: 10px;
cursor: pointer;
}
.arm {
background-color: #888;
border: 1px solid #444;
}
.armActive {
background: radial-gradient(
circle,
rgba(22, 255, 0, 1) 0%,
rgba(50, 255, 31, 1) 49%,
rgba(0, 0, 0, 1) 65%,
rgba(0, 255, 156, 0.7083333333333333) 81%
);
border: 1px solid #256e62;
}
.time,
.titleContainer,
.detailedContainer {
background-color: #fff8;
border: 1px solid #0001;
border-radius: 3px;
}
.time {
width: 5em;
height: fit-content;
}
.rowDetailed {
display: inline-flex;
position: relative;
}
.titleContainer,
.detailedContainer {
width: 90%;
display: block;
}
.detailedContainer > div,
.titleContainer > div {
display: inline;
}
.detailedTitle,
.detailedTitleUnderlined {
padding-left: 1em;
font-size: 0.8em;
color: #888;
display: inline-block;
width: 6em;
cursor: default;
}
.detailedTitleUnderlined {
border-bottom: 1px solid #0001;
}
.more {
position: absolute;
right: 0.25em;
top: 0em;
}
.actionOverlay {
display: flex;
flex-direction: row;
gap: 0.5em;
align-content: center;
align-self: center;
opacity: 0.6;
transition: linear 0.1s;
}
.eventRow:hover > .actionOverlay {
opacity: 0.9;
transition: linear 0.1s;
}
.actionOverlay:hover {
opacity: 1;
}
/* ============= DELAY BLOCK ============= */
.delayContainer {
position: relative;
top: 1em;
margin: 0.2em 0;
padding: 0.2em 1em;
background-color: #ecc94baa;
border-radius: 8px;
color: white;
border: 1px solid #0001;
border-top: 4px solid #ecc94b;
box-sizing: border-box;
width: 100%;
display: flex;
margin: auto;
gap: 1em;
}
.delayValue {
color: #000;
background-color: #fff5;
border-radius: 4px;
padding: 0.2em;
text-align: center;
width: 6em;
border: 1px solid #0001;
}
.delayedEditable {
background-color: #ecc94b55;
}
/* ============= BLOCK BLOCK ============= */
.blockContainer {
position: relative;
top: 1em;
margin: 0.2em 0;
padding: 0.2em 1em;
width: 100%;
background-color: #805ad5aa;
border-radius: 8px;
color: white;
height: 3em;
border: 1px solid #0001;
border-bottom: 8px solid #805ad5;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: row-reverse;
margin: auto;
gap: 1em;
}
@@ -0,0 +1,13 @@
export const sortByDate = (arr) => {
const sorter = (a, b) => {
return new Date(a.timeStart).getTime() - new Date(b.timeStart).getTime();
};
return arr.sort(sorter);
};
export const sortByOrderVal = (arr) => {
const sorter = (a, b) => {
return a.order - b.order;
};
return arr.sort(sorter);
};
+66
View File
@@ -0,0 +1,66 @@
import { IconButton } from '@chakra-ui/button';
import { FormControl, FormLabel } from '@chakra-ui/form-control';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { Input } from '@chakra-ui/input';
import { useContext } from 'react';
import { useState } from 'react';
import { PresenterMessagesContext } from '../../app/context/presenterMessageContext';
export default function MessageForm() {
const [presenterShow, setPresenterShow] = useState(false);
const [publicShow, setPublicShow] = useState(false);
const [presMessage, setPresMessage] = useContext(PresenterMessagesContext);
const handleSetPresenter = () => {
setPresenterShow(!presenterShow);
setPresMessage((prev) => ({ ...prev, show: !presMessage.show }));
};
const handlePresenterChange = (val) => {
setPresMessage((prev) => ({ ...prev, text: val }));
};
const handleSetPublic = () => {
setPublicShow(!publicShow);
};
const handlePublicChange = (val) => {};
return (
<>
<form style={{ display: 'flex', gap: '1em', fontSize: '15px' }}>
<FormControl id='presenterMessage'>
<FormLabel>Presenter screen message</FormLabel>
<Input
placeholder='only the presenter screens see this'
onChange={(event) => handlePresenterChange(event.target.value)}
/>
</FormControl>
<IconButton
style={{ alignSelf: 'flex-end' }}
colorScheme='teal'
variant={presenterShow ? 'solid' : 'outline'}
onClick={handleSetPresenter}
icon={presenterShow ? <ViewOffIcon /> : <ViewIcon />}
/>
</form>
<form style={{ display: 'flex', gap: '1em', paddingTop: '1em' }}>
<FormControl id='generalMessage'>
<FormLabel>Public screen message</FormLabel>
<Input
placeholder='all screens will render this'
onChange={(event) => handlePublicChange(event.target.value)}
/>
</FormControl>
<IconButton
style={{ alignSelf: 'flex-end' }}
colorScheme='teal'
variant={publicShow ? 'solid' : 'outline'}
onClick={handleSetPublic}
icon={publicShow ? <ViewOffIcon /> : <ViewIcon />}
/>
</form>
</>
);
}
@@ -0,0 +1,54 @@
import { AddIcon, ChevronDownIcon } from '@chakra-ui/icons';
import {
Button,
ButtonGroup,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
} from '@chakra-ui/react';
import style from './EventListMenu.module.css';
export default function EventListMenu(props) {
const buttonProps = {
size: 'sm',
variant: 'outline',
};
return (
<div className={style.headerButtons}>
<Menu>
<ButtonGroup isAttached>
<Button size='sm' variant='outline'>
Upload
</Button>
<MenuButton as={Button} {...buttonProps}>
<ChevronDownIcon />
</MenuButton>
</ButtonGroup>
<MenuList>
<MenuItem>Upload Excel</MenuItem>
<MenuItem>Upload CSV</MenuItem>
</MenuList>
</Menu>
<Menu>
<ButtonGroup isAttached>
<Button {...buttonProps}>Save</Button>
<MenuButton as={Button} {...buttonProps}>
<ChevronDownIcon />
</MenuButton>
</ButtonGroup>
<MenuList>
<MenuItem>Download Excel</MenuItem>
<MenuItem>Download CSV</MenuItem>
</MenuList>
</Menu>
<IconButton
size='sm'
icon={<AddIcon />}
colorScheme='blue'
onClick={() => props.createEvent()}
/>
</div>
);
}
@@ -0,0 +1,6 @@
.headerButtons {
display: flex;
gap: 0.5em;
align-content: center;
justify-content: flex-end;
}
@@ -0,0 +1,63 @@
import { differenceInSeconds, format, subMinutes } from 'date-fns';
import addMinutes from 'date-fns/addMinutes';
import { useContext, useEffect, useState } from 'react';
import { EventContext } from '../../app/context/eventContext';
import { PresenterMessagesContext } from '../../app/context/presenterMessageContext';
import Countdown from '../../common/components/countdown/Countdown';
import MyProgressBar from '../../common/components/myProgressBar/MyProgressBar';
import SmallTimer from '../../common/components/smallTimer/SmallTimer';
import './viewers.css';
export default function DefaultPresenter() {
const [event] = useContext(EventContext);
const [presMessage] = useContext(PresenterMessagesContext);
const [initialValues, setInitialValues] = useState(null);
const now = new Date();
let values = event ?? {
title: 'Presentation Title',
subtitle: 'Presentation Subtitle',
presenter: 'Presenter Name',
timerDuration: 10,
timeStart: now,
timeEnd: now,
};
useEffect(() => {
values = event;
}, [event]);
useEffect(() => {
setInitialValues(event);
}, [event]);
// NOTE: test only
const clockStarted = addMinutes(now, 6);
const timer = differenceInSeconds(
now,
subMinutes(clockStarted, values.timerDuration)
);
const timeStart = format(values.timeStart, 'HH:mm');
const currentTime = format(now, 'HH:mm');
const timeEnd = format(values.timeEnd, 'HH:mm');
const elapsed = timer / (values.timerDuration * 60);
return (
<div className='presenter'>
<div className='presentationTitle'>{values.title}</div>
<div className='presentationSub'>{values.subtitle}</div>
<Countdown time={timer} />
{!presMessage.show && <MyProgressBar normalisedComplete={elapsed} />}
<div className={presMessage.show ? 'userMessage' : 'userMessage hidden'}>
{presMessage.text}
</div>
<div className='extra'>
<SmallTimer label='Scheduled Start' time={timeStart} />
<SmallTimer label='Current Time' time={currentTime} />
<SmallTimer label='Scheduled End' time={timeEnd} />
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { AspectRatio } from '@chakra-ui/layout';
import { CircularProgress } from '@chakra-ui/progress';
import { useState } from 'react';
import style from './IFrameLoader.module.css';
export default function IFrameLoader(props) {
const [loading, setLoading] = useState(true);
const { title, src } = props;
return (
<AspectRatio maxW='300' ratio={16 / 9} className={style.iframeContainer}>
<>
{loading && (
<CircularProgress
className={style.loader}
isIndeterminate
color='orange.300'
trackColor='#FFF0'
/>
)}
<iframe
className={style.iframe}
title={title}
src={src}
onLoad={() => setLoading(false)}
/>
</>
</AspectRatio>
);
}
@@ -0,0 +1,13 @@
.iframeContainer {
margin: auto;
background-color: #0001;
border: 1px solid #0001;
}
.loader {
z-index: 100;
}
.iframe {
width: inherit;
}
@@ -0,0 +1,25 @@
import styles from './PreviewContainer.module.css';
import IFrameLoader from './IFrameLoader';
export default function PreviewContainer() {
return (
<div className={styles.previewContainer}>
<div className={styles.previewItem}>
<IFrameLoader title='Default Presenter' src='http://localhost:3000/' />
<div className={styles.label}>Default Presenter</div>
</div>
<div className={styles.previewItem}>
<IFrameLoader title='Audience' src='http://localhost:3000/' />
<div className={styles.label}>Audience</div>
</div>
<div className={styles.previewItem}>
<IFrameLoader title='Stage Manager' src='http://localhost:3000/' />
<div className={styles.label}>Stage Manager</div>
</div>
<div className={styles.previewItem}>
<IFrameLoader title='Lower third' src='http://localhost:3000/' />
<div className={styles.label}>Lower third</div>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
.previewContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-content: center;
gap: 1em;
}
.previewItem {
width: 45%;
}
.label {
padding: 0.1em 4em;
}
+41
View File
@@ -0,0 +1,41 @@
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;800&display=swap');
.presenter {
font-size: 1vh;
height: 100%;
color: #ffffff;
display: flex;
flex-direction: column;
padding-left: 4vh;
}
.presentationTitle {
font-size: 5vw;
text-align: left;
}
.presentationSub {
font-size: 3vw;
text-align: left;
}
.userMessage {
font-size: 10vw;
text-align: center;
}
.hidden {
display: none;
}
.extra {
width: 100%;
position: absolute;
bottom: 3vh;
left: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
// 1. import `ChakraProvider` component
import { ChakraProvider } from '@chakra-ui/react';
ReactDOM.render(
<React.StrictMode>
<ChakraProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+12577
View File
File diff suppressed because it is too large Load Diff