mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
Feat/studio clock (#60)
This commit is contained in:
+12
-7
@@ -22,6 +22,8 @@ const Lower = lazy(() =>
|
||||
);
|
||||
const Pip = lazy(() => import('features/viewers/production/Pip'));
|
||||
|
||||
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
// Seemed to cause issues
|
||||
// broadcastQueryClient({
|
||||
@@ -29,12 +31,13 @@ const queryClient = new QueryClient();
|
||||
// broadcastChannel: 'ontime',
|
||||
// });
|
||||
|
||||
const SSpeaker = withSocket(PresenterView);
|
||||
const SSpeakerSimple = withSocket(PresenterSimple);
|
||||
const SPresenter = withSocket(PresenterView);
|
||||
const SPresenterSimple = withSocket(PresenterSimple);
|
||||
const SStageManager = withSocket(StageManager);
|
||||
const SPublic = withSocket(Public);
|
||||
const SLowerThird = withSocket(Lower);
|
||||
const SPip = withSocket(Pip);
|
||||
const SStudio = withSocket(StudioClock);
|
||||
|
||||
function App() {
|
||||
// Handle keyboard shortcuts
|
||||
@@ -69,18 +72,20 @@ function App() {
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route exact path='/' component={SSpeaker} />
|
||||
<Route exact path='/' component={SPresenter} />
|
||||
<Route exact path='/sm' component={SStageManager} />
|
||||
<Route exact path='/speaker' component={SSpeaker} />
|
||||
<Route exact path='/stage' component={SSpeaker} />
|
||||
<Route exact path='/speakersimple' component={SSpeakerSimple} />
|
||||
<Route exact path='/speaker' component={SPresenter} />
|
||||
<Route exact path='/presenter' component={SPresenter} />
|
||||
<Route exact path='/stage' component={SPresenter} />
|
||||
<Route exact path='/presentersimple' component={SPresenterSimple} />
|
||||
<Route exact path='/editor' component={Editor} />
|
||||
<Route exact path='/public' component={SPublic} />
|
||||
<Route exact path='/pip' component={SPip} />
|
||||
<Route exact path='/studio' component={SStudio} />
|
||||
{/* Lower cannot have fallback */}
|
||||
<Route exact path='/lower' component={SLowerThird} />
|
||||
{/* Send to default if nothing found */}
|
||||
<Route component={SSpeaker} />
|
||||
<Route component={SPresenter} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
Binary file not shown.
@@ -1,11 +1,13 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import { Image } from '@chakra-ui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import navlogo from 'assets/images/logos/LOGO-72.png';
|
||||
import style from './NavLogo.module.css';
|
||||
import style from './NavLogo.module.scss';
|
||||
|
||||
export default function NavLogo() {
|
||||
export default function NavLogo(props) {
|
||||
const {isHidden} = props;
|
||||
const [showNav, setShowNav] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -30,9 +32,10 @@ export default function NavLogo() {
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
const baseOpacity = (isHidden) ? 0 : 0.5
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0.5 }}
|
||||
initial={{ opacity: baseOpacity }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
className={style.navContainer}
|
||||
>
|
||||
@@ -51,12 +54,12 @@ export default function NavLogo() {
|
||||
className={showNav ? style.nav : style.navHidden}
|
||||
>
|
||||
<Link
|
||||
to='/speaker'
|
||||
to='/presenter'
|
||||
className={style.navItem}
|
||||
tabIndex={1}
|
||||
onKeyDownCapture={() => <Redirect push to='/speaker' />}
|
||||
onKeyDownCapture={() => <Redirect push to='/presenter' />}
|
||||
>
|
||||
Speaker
|
||||
Presenter
|
||||
</Link>
|
||||
<Link
|
||||
to='/sm'
|
||||
@@ -90,9 +93,21 @@ export default function NavLogo() {
|
||||
>
|
||||
PIP
|
||||
</Link>
|
||||
<Link
|
||||
to='/studio'
|
||||
className={style.navItem}
|
||||
tabIndex={5}
|
||||
onKeyDownCapture={() => <Redirect push to='/studio' />}
|
||||
>
|
||||
Studio Clock
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
NavLogo.propTypes = {
|
||||
isHidden: PropTypes.bool,
|
||||
}
|
||||
|
||||
+4
@@ -1,6 +1,9 @@
|
||||
$nav-color: #fff;
|
||||
|
||||
.navContainer {
|
||||
position: absolute;
|
||||
right: 2vw;
|
||||
top: 1vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
@@ -23,4 +26,5 @@
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 0.5vh 1vw;
|
||||
border-radius: 4px;
|
||||
color: $nav-color;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {formatEventList, getEventsWithDelay, trimEventlist} from "../eventsManager";
|
||||
|
||||
test('getEventsWithDelay function', () => {
|
||||
|
||||
const testData = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeStart": 28800000,
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"duration": 60000,
|
||||
"type": "delay",
|
||||
"id": "24240"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000,
|
||||
"timeEnd": 35520000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
},
|
||||
{
|
||||
"title": "Use simpler times to create a timer",
|
||||
"timeStart": 120000,
|
||||
"timeEnd": 720000,
|
||||
"type": "event",
|
||||
"id": "8222"
|
||||
},
|
||||
{
|
||||
"duration": 900000,
|
||||
"type": "delay",
|
||||
"revision": 0,
|
||||
"id": "a386"
|
||||
},
|
||||
{
|
||||
"title": "Add delay blocks to affect all events",
|
||||
"timeStart": 37320000,
|
||||
"timeEnd": 38520000,
|
||||
"type": "event",
|
||||
"id": "6dce"
|
||||
},
|
||||
{
|
||||
"title": "Add and remove events with [+] and [-]",
|
||||
"timeStart": 38520000,
|
||||
"timeEnd": 45120000,
|
||||
"type": "event",
|
||||
"id": "2651"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"id": "e6a1"
|
||||
},
|
||||
{
|
||||
"title": "And control whether they are public",
|
||||
"timeStart": 46800000,
|
||||
"timeEnd": 57600000,
|
||||
"type": "event",
|
||||
"id": "1358"
|
||||
}
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeStart": 28800000,
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000+60000,
|
||||
"timeEnd": 35520000+60000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
},
|
||||
{
|
||||
"title": "Use simpler times to create a timer",
|
||||
"timeStart": 120000+60000,
|
||||
"timeEnd": 720000+60000,
|
||||
"type": "event",
|
||||
"id": "8222"
|
||||
},
|
||||
{
|
||||
"title": "Add delay blocks to affect all events",
|
||||
"timeStart": 37320000+60000+900000,
|
||||
"timeEnd": 38520000+60000+900000,
|
||||
"type": "event",
|
||||
"id": "6dce"
|
||||
},
|
||||
{
|
||||
"title": "Add and remove events with [+] and [-]",
|
||||
"timeStart": 38520000+60000+900000,
|
||||
"timeEnd": 45120000+60000+900000,
|
||||
"type": "event",
|
||||
"id": "2651"
|
||||
},
|
||||
{
|
||||
"title": "And control whether they are public",
|
||||
"timeStart": 46800000,
|
||||
"timeEnd": 57600000,
|
||||
"type": "event",
|
||||
"id": "1358"
|
||||
}
|
||||
]
|
||||
|
||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
describe('getEventsWithDelay edge cases', () => {
|
||||
|
||||
test('given an empty array', () => {
|
||||
const emptyArray = {
|
||||
test: [],
|
||||
expect: [],
|
||||
}
|
||||
|
||||
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
|
||||
});
|
||||
|
||||
test('given an undefined object', () => {
|
||||
const withUndefined = {
|
||||
test: undefined,
|
||||
expect: [],
|
||||
}
|
||||
|
||||
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
|
||||
});
|
||||
|
||||
test('given a corrupted event object', () => {
|
||||
const testData = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"duration": 60000,
|
||||
"type": "delay",
|
||||
"id": "24240"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000,
|
||||
"timeEnd": 35520000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
}
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000+60000,
|
||||
"timeEnd": 35520000+60000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
}
|
||||
];
|
||||
|
||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('given a corrupted delay object', () => {
|
||||
const testData = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeStart": 28800000,
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"type": "delay",
|
||||
"id": "24240"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000,
|
||||
"timeEnd": 35520000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
}
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"timeStart": 28800000,
|
||||
"timeEnd": 30600000,
|
||||
"type": "event",
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"timeStart": 34920000,
|
||||
"timeEnd": 35520000,
|
||||
"type": "event",
|
||||
"id": "8ee5"
|
||||
}
|
||||
];
|
||||
|
||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test trimEventlist function', () => {
|
||||
|
||||
const limit = 8;
|
||||
const testData = [
|
||||
{id: '1'},
|
||||
{id: '2'},
|
||||
{id: '3'},
|
||||
{id: '4'},
|
||||
{id: '5'},
|
||||
{id: '6'},
|
||||
{id: '7'},
|
||||
{id: '8'},
|
||||
{id: '9'},
|
||||
{id: '10'},
|
||||
{id: '11'},
|
||||
{id: '12'},
|
||||
];
|
||||
|
||||
|
||||
test('when we use the first item', () => {
|
||||
const selectedId = '1';
|
||||
const expected = [
|
||||
{id: '1'},
|
||||
{id: '2'},
|
||||
{id: '3'},
|
||||
{id: '4'},
|
||||
{id: '5'},
|
||||
{id: '6'},
|
||||
{id: '7'},
|
||||
{id: '8'},
|
||||
];
|
||||
|
||||
const l = trimEventlist(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('when we use the third item', () => {
|
||||
const selectedId = '3';
|
||||
const expected = [
|
||||
{id: '1'},
|
||||
{id: '2'},
|
||||
{id: '3'},
|
||||
{id: '4'},
|
||||
{id: '5'},
|
||||
{id: '6'},
|
||||
{id: '7'},
|
||||
{id: '8'}
|
||||
];
|
||||
|
||||
const l = trimEventlist(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('when we use the fourth item', () => {
|
||||
const selectedId = '4';
|
||||
const expected = [
|
||||
{id: '2'},
|
||||
{id: '3'},
|
||||
{id: '4'},
|
||||
{id: '5'},
|
||||
{id: '6'},
|
||||
{id: '7'},
|
||||
{id: '8'},
|
||||
{id: '9'}
|
||||
];
|
||||
|
||||
const l = trimEventlist(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('if selected is not found', () => {
|
||||
const selectedId = '15';
|
||||
const expected = [
|
||||
{id: '1'},
|
||||
{id: '2'},
|
||||
{id: '3'},
|
||||
{id: '4'},
|
||||
{id: '5'},
|
||||
{id: '6'},
|
||||
{id: '7'},
|
||||
{id: '8'},
|
||||
];
|
||||
|
||||
const l = trimEventlist(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test formatEvents function', () => {
|
||||
const testEvent = [
|
||||
{
|
||||
"title": "Welcome to Ontime",
|
||||
"subtitle": "Subtitles are useful",
|
||||
"presenter": "cpvalente",
|
||||
"note": "Maybe a running note for the operator?",
|
||||
"timeStart": 28800000,
|
||||
"timeEnd": 30600000,
|
||||
"isPublic": false,
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "5946"
|
||||
},
|
||||
{
|
||||
"title": "Unless recalled by the OSC address",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "In green, below",
|
||||
"timeStart": 34800000,
|
||||
"timeEnd": 35400000,
|
||||
"isPublic": false,
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "8ee5"
|
||||
}
|
||||
];
|
||||
|
||||
test ('it parses correctly', () => {
|
||||
const selectedId = 'otherEvent';
|
||||
const nextId = 'notHere';
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId, true);
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test ('it handles selected correctly', () => {
|
||||
const selectedId = '5946';
|
||||
const nextId = '8ee5';
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: true,
|
||||
isNext: false,
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: false,
|
||||
isNext: true,
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId,true);
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test ('it handles next correctly', () => {
|
||||
const selectedId = '8ee5';
|
||||
const nextId = 'notHere';
|
||||
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: true,
|
||||
isNext: false,
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId, true);
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
})
|
||||
@@ -40,11 +40,11 @@ export const stringFromMillis = (
|
||||
* another go at simpler string formatting (counters)
|
||||
* @description Converts seconds to string representing time
|
||||
* @param {number} seconds - time in seconds
|
||||
* @param {boolean} hideZero - wether to show hours in case its 00
|
||||
* @param {boolean} [hideZero] - whether to show hours in case its 00
|
||||
* @returns {string} String representing absolute time 00:12:02
|
||||
*/
|
||||
|
||||
export function formatDisplay(seconds, hideZero) {
|
||||
export function formatDisplay(seconds, hideZero=false) {
|
||||
// add an extra 0 if necessary
|
||||
const format = (val) => `0${Math.floor(val)}`.slice(-2);
|
||||
|
||||
@@ -59,7 +59,6 @@ export function formatDisplay(seconds, hideZero) {
|
||||
/**
|
||||
* @description Converts milliseconds to seconds
|
||||
* @param {number} millis - time in seconds
|
||||
* @param {boolean} hideZero - wether to show hours in case its 00
|
||||
* @returns {number} Amount in seconds
|
||||
*/
|
||||
|
||||
@@ -71,7 +70,6 @@ export const millisToSeconds = (millis) => {
|
||||
/**
|
||||
* @description Converts milliseconds to seconds
|
||||
* @param {number} millis - time in seconds
|
||||
* @param {boolean} hideZero - wether to show hours in case its 00
|
||||
* @returns {number} Amount in seconds
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @description From a list of events, returns only events of type event with calculated delays
|
||||
* @param {Object[]} events - given events
|
||||
* @returns {Object[]} Filtered events with calculated delays
|
||||
*/
|
||||
import {stringFromMillis} from "./dateConfig";
|
||||
|
||||
export const getEventsWithDelay = (events) => {
|
||||
|
||||
if (events == null) return [];
|
||||
|
||||
const unfilteredEvents = [...events];
|
||||
|
||||
// Add running delay
|
||||
let delay = 0;
|
||||
for (const e of unfilteredEvents) {
|
||||
if (e.type === 'block') delay = 0;
|
||||
else if (e.type === 'delay') delay = delay + e.duration;
|
||||
else if (e.type === 'event' && delay > 0) {
|
||||
e.timeStart += delay;
|
||||
e.timeEnd += delay;
|
||||
}
|
||||
}
|
||||
|
||||
// filter just events
|
||||
return unfilteredEvents.filter((e) => e.type === 'event');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns trimmed event list array
|
||||
* @param {Object[]} events - given events
|
||||
* @param {string} selectedId - id of currently selected event
|
||||
* @param {number} limit - max number of events to return
|
||||
* @returns {Object[]} Event list with maximum <limit> objects
|
||||
*/
|
||||
export const trimEventlist = (events, selectedId, limit) => {
|
||||
if (events == null) return [];
|
||||
|
||||
const BEFORE = 2;
|
||||
const trimmedEvents = [...events];
|
||||
|
||||
// limit events length if necessary
|
||||
if (limit != null) {
|
||||
while (trimmedEvents.length > limit) {
|
||||
const idx = trimmedEvents.findIndex((e) => e.id === selectedId);
|
||||
if (idx <= BEFORE) { trimmedEvents.pop(); }
|
||||
else { trimmedEvents.shift(); }
|
||||
}
|
||||
}
|
||||
return trimmedEvents;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns list of events formatted to be displayed
|
||||
* @param {Object[]} events - given events
|
||||
* @param {string} selectedId - id of currently selected event
|
||||
* @param {string} nextId - id of next event
|
||||
* @param {boolean} [showEnd] - whether to show the end time
|
||||
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
|
||||
*/
|
||||
export const formatEventList = (events, selectedId, nextId, showEnd = false) => {
|
||||
if (events == null) return [];
|
||||
|
||||
const givenEvents = [...events];
|
||||
|
||||
// format list
|
||||
let formattedEvents = [];
|
||||
for (const g of givenEvents) {
|
||||
const start = stringFromMillis(g.timeStart, false);
|
||||
const end = stringFromMillis(g.timeEnd, false);
|
||||
|
||||
formattedEvents.push({
|
||||
id: g.id,
|
||||
time: showEnd ? `${start} - ${end}` : start,
|
||||
title: g.title,
|
||||
isNow: g.id === selectedId,
|
||||
isNext: g.id === nextId,
|
||||
});
|
||||
}
|
||||
|
||||
return formattedEvents;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from 'app/context/socketContext';
|
||||
import {Editable, EditableInput, EditablePreview} from '@chakra-ui/editable';
|
||||
import {Switch} from "@chakra-ui/react";
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useSocket} from 'app/context/socketContext';
|
||||
import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn';
|
||||
import style from './MessageControl.module.css';
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
const inputProps = {
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const InputRow = (props) => {
|
||||
const { label, placeholder, text, visible } = props;
|
||||
const {label, placeholder, text, visible} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -22,8 +23,8 @@ const InputRow = (props) => {
|
||||
className={style.inline}
|
||||
color={text === '' ? '#666' : 'inherit'}
|
||||
>
|
||||
<EditablePreview className={style.padleft} />
|
||||
<EditableInput className={style.padleft} />
|
||||
<EditablePreview className={style.padleft}/>
|
||||
<EditableInput className={style.padleft}/>
|
||||
</Editable>
|
||||
<VisibleIconBtn
|
||||
active={visible || undefined}
|
||||
@@ -49,33 +50,42 @@ export default function MessageControl() {
|
||||
text: '',
|
||||
visible: false,
|
||||
});
|
||||
const [onAir, setonAir] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket == null) return;
|
||||
|
||||
// Handle presenter messages
|
||||
socket.on('messages-presenter', (data) => {
|
||||
setPres({ ...data });
|
||||
setPres({...data});
|
||||
});
|
||||
|
||||
// Handle public messages
|
||||
socket.on('messages-public', (data) => {
|
||||
setPubl({ ...data });
|
||||
setPubl({...data});
|
||||
});
|
||||
|
||||
// Handle lower third messages
|
||||
socket.on('messages-lower', (data) => {
|
||||
setLower({ ...data });
|
||||
setLower({...data});
|
||||
});
|
||||
|
||||
// Handle lower third messages
|
||||
socket.on('onAir', (data) => {
|
||||
setonAir(data);
|
||||
});
|
||||
|
||||
// Ask for up to date data
|
||||
socket.emit('get-messages');
|
||||
// Ask for onAir state
|
||||
socket.emit('get-onAir');
|
||||
|
||||
// Clear listeners
|
||||
return () => {
|
||||
socket.off('messages-public');
|
||||
socket.off('messages-presenter');
|
||||
socket.off('messages-lower');
|
||||
socket.off('onAir');
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -99,38 +109,54 @@ export default function MessageControl() {
|
||||
case 'toggle-lower-visible':
|
||||
socket.emit('set-lower-visible', !lower.visible);
|
||||
break;
|
||||
|
||||
case 'toggle-onAir':
|
||||
socket.emit('set-onAir', !onAir);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Presenter screen message'
|
||||
placeholder='only the presenter screens see this'
|
||||
text={pres.text}
|
||||
visible={pres.visible}
|
||||
changeHandler={(event) => messageControl('pres-text', event)}
|
||||
actionHandler={() => messageControl('toggle-pres-visible')}
|
||||
/>
|
||||
<InputRow
|
||||
label='Public screen message'
|
||||
placeholder='public screens will render this'
|
||||
text={publ.text}
|
||||
visible={publ.visible}
|
||||
changeHandler={(event) => messageControl('publ-text', event)}
|
||||
actionHandler={() => messageControl('toggle-publ-visible')}
|
||||
/>
|
||||
<InputRow
|
||||
label='Lower third message'
|
||||
placeholder='visible in lower third screen'
|
||||
text={lower.text}
|
||||
visible={lower.visible}
|
||||
changeHandler={(event) => messageControl('lower-text', event)}
|
||||
actionHandler={() => messageControl('toggle-lower-visible')}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Presenter screen message'
|
||||
placeholder='only the presenter screens see this'
|
||||
text={pres.text}
|
||||
visible={pres.visible}
|
||||
changeHandler={(event) => messageControl('pres-text', event)}
|
||||
actionHandler={() => messageControl('toggle-pres-visible')}
|
||||
/>
|
||||
<InputRow
|
||||
label='Public screen message'
|
||||
placeholder='public screens will render this'
|
||||
text={publ.text}
|
||||
visible={publ.visible}
|
||||
changeHandler={(event) => messageControl('publ-text', event)}
|
||||
actionHandler={() => messageControl('toggle-publ-visible')}
|
||||
/>
|
||||
<InputRow
|
||||
label='Lower third message'
|
||||
placeholder='visible in lower third screen'
|
||||
text={lower.text}
|
||||
visible={lower.visible}
|
||||
changeHandler={(event) => messageControl('lower-text', event)}
|
||||
actionHandler={() => messageControl('toggle-lower-visible')}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.onAirToggle}>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
size='md'
|
||||
isChecked={onAir}
|
||||
onChange={() => messageControl('toggle-onAir')}>
|
||||
On Air?
|
||||
</Switch>
|
||||
<span className={style.oscLabel}>
|
||||
{`/ontime/offAir << OSC >> /ontime/onAir`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.messageContainer {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.inputItems {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.inline {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.padleft {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.messageContainer,
|
||||
.onAirToggle {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
padding: 0.5em;
|
||||
|
||||
}
|
||||
|
||||
.messageContainer {
|
||||
flex-direction: column;
|
||||
|
||||
.inputItems {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1em;
|
||||
}
|
||||
.label {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
}
|
||||
.inline {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.padleft {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.onAirToggle {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
line-height: 3em;
|
||||
|
||||
.onAirLabel {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.oscLabel {
|
||||
color: #4bffabcc;
|
||||
font-size: 0.8em;
|
||||
float: right;
|
||||
padding-right: 1em;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,9 @@ const withSocket = (Component) => {
|
||||
const WrappedComponent = (props) => {
|
||||
const {
|
||||
data: eventsData,
|
||||
status: eventsDataStatus,
|
||||
isError: eventsDataIsError,
|
||||
} = useFetch(EVENTS_TABLE, fetchAllEvents);
|
||||
const {
|
||||
data: genData,
|
||||
status: genDataStatus,
|
||||
isError: genDataIsError,
|
||||
} = useFetch(EVENT_TABLE, fetchEvent);
|
||||
|
||||
const [publicEvents, setPublicEvents] = useState([]);
|
||||
@@ -58,6 +54,7 @@ const withSocket = (Component) => {
|
||||
presenterNext: '',
|
||||
});
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [nextId, setNextId] = useState(null);
|
||||
const [publicSelectedId, setPublicSelectedId] = useState(null);
|
||||
const [general, setGeneral] = useState({
|
||||
title: '',
|
||||
@@ -67,6 +64,7 @@ const withSocket = (Component) => {
|
||||
endMessage: '',
|
||||
});
|
||||
const [playback, setPlayback] = useState(null);
|
||||
const [onAir, setOnAir] = useState(false);
|
||||
|
||||
// Ask for update on load
|
||||
useEffect(() => {
|
||||
@@ -96,6 +94,9 @@ const withSocket = (Component) => {
|
||||
socket.on('playstate', (data) => {
|
||||
setPlayback(data);
|
||||
});
|
||||
socket.on('onAir', (data) => {
|
||||
setOnAir(data);
|
||||
});
|
||||
|
||||
// Handle titles
|
||||
socket.on('titles', (data) => {
|
||||
@@ -112,6 +113,9 @@ const withSocket = (Component) => {
|
||||
socket.on('publicselected-id', (data) => {
|
||||
setPublicSelectedId(data);
|
||||
});
|
||||
socket.on('next-id', (data) => {
|
||||
setNextId(data);
|
||||
});
|
||||
|
||||
// Ask for up to date data
|
||||
socket.emit('get-messages');
|
||||
@@ -124,6 +128,7 @@ const withSocket = (Component) => {
|
||||
|
||||
// ask for playstate
|
||||
socket.emit('get-playstate');
|
||||
socket.emit('get-onAir')
|
||||
|
||||
// Ask for up titles
|
||||
socket.emit('get-titles');
|
||||
@@ -131,6 +136,7 @@ const withSocket = (Component) => {
|
||||
|
||||
// Ask for up selected
|
||||
socket.emit('get-selected-id');
|
||||
socket.emit('get-next-id');
|
||||
|
||||
// Clear listeners
|
||||
return () => {
|
||||
@@ -139,9 +145,11 @@ const withSocket = (Component) => {
|
||||
socket.off('messages-lower');
|
||||
socket.off('timer');
|
||||
socket.off('playstate');
|
||||
socket.off('onAir');
|
||||
socket.off('titles');
|
||||
socket.off('publictitles');
|
||||
socket.off('selected-id');
|
||||
socket.emit('next-id');
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -224,6 +232,7 @@ const withSocket = (Component) => {
|
||||
...timer,
|
||||
finished: playback === 'start' && timer.running <= 0 && timer.startedAt,
|
||||
clock: stringFromMillis(timer.clock),
|
||||
clockNoSeconds: stringFromMillis(timer.clock, false),
|
||||
playstate: playback,
|
||||
};
|
||||
|
||||
@@ -240,7 +249,9 @@ const withSocket = (Component) => {
|
||||
backstageEvents={backstageEvents}
|
||||
selectedId={selectedId}
|
||||
publicSelectedId={publicSelectedId}
|
||||
nextId={nextId}
|
||||
general={general}
|
||||
onAir={onAir}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import NavLogo from 'common/components/nav/NavLogo';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import TitleSide from 'common/components/views/TitleSide';
|
||||
import {getEventsWithDelay} from "../../../common/utils/eventsManager";
|
||||
|
||||
export default function StageManager(props) {
|
||||
const { publ, title, time, backstageEvents, selectedId, general } = props;
|
||||
@@ -20,23 +21,9 @@ export default function StageManager(props) {
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
|
||||
let events = [...backstageEvents];
|
||||
setFilteredEvents(getEventsWithDelay(backstageEvents));
|
||||
|
||||
// Add running delay
|
||||
let delay = 0;
|
||||
for (const e of events) {
|
||||
if (e.type === 'block') delay = 0;
|
||||
else if (e.type === 'delay') delay = delay + e.duration;
|
||||
else if (e.type === 'event' && delay > 0) {
|
||||
e.timeStart += delay;
|
||||
e.timeEnd += delay;
|
||||
}
|
||||
}
|
||||
|
||||
// filter just events
|
||||
let filtered = events.filter((e) => e.type === 'event');
|
||||
|
||||
setFilteredEvents(filtered);
|
||||
console.log('hhh', getEventsWithDelay(backstageEvents))
|
||||
}, [backstageEvents]);
|
||||
|
||||
// Format messages
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function PresenterView(props) {
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Speaker Screen';
|
||||
document.title = 'ontime - Presenter Screen';
|
||||
}, []);
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import style from './LowerClean.module.css';
|
||||
import NavLogo from "../../../../common/components/nav/NavLogo";
|
||||
|
||||
export default function LowerClean(props) {
|
||||
const { lower, title, options } = props;
|
||||
@@ -86,6 +87,9 @@ export default function LowerClean(props) {
|
||||
fontSize: `${sizeMultiplier}vh`,
|
||||
}}
|
||||
>
|
||||
|
||||
<NavLogo isHidden />
|
||||
|
||||
<AnimatePresence>
|
||||
{showLower && (
|
||||
<motion.div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import style from './LowerLines.module.css';
|
||||
import NavLogo from "../../../../common/components/nav/NavLogo";
|
||||
|
||||
export default function LowerLines(props) {
|
||||
const { lower, title, options } = props;
|
||||
@@ -126,6 +127,9 @@ export default function LowerLines(props) {
|
||||
fontSize: `${sizeMultiplier}vh`,
|
||||
}}
|
||||
>
|
||||
|
||||
<NavLogo isHidden />
|
||||
|
||||
<AnimatePresence>
|
||||
{showLower && (
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import style from "./StudioClock.module.scss";
|
||||
import useFitText from "use-fit-text";
|
||||
import NavLogo from "../../../common/components/nav/NavLogo";
|
||||
import {useEffect, useState} from "react";
|
||||
import {formatDisplay} from "../../../common/utils/dateConfig";
|
||||
import {formatEventList, trimEventlist} from "../../../common/utils/eventsManager";
|
||||
|
||||
export default function StudioClock(props) {
|
||||
const { title, time, backstageEvents, selectedId, nextId, onAir } = props;
|
||||
const { fontSize, ref } = useFitText({maxFontSize:500});
|
||||
const [hoursNow, minutesNow] = time.clockNoSeconds.split(':');
|
||||
const [schedule, setSchedule] = useState([]);
|
||||
|
||||
const hoursIndicators = [...Array(12).keys()];
|
||||
const minutesIndicators = [...Array(60).keys()];
|
||||
const MAX_TITLES = 8;
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Studio Clock';
|
||||
}, []);
|
||||
|
||||
// Prepare event list
|
||||
// Todo: useMemo()
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
|
||||
const events = backstageEvents.filter((e) => e.type === 'event');
|
||||
|
||||
let e = trimEventlist(events, selectedId, MAX_TITLES);
|
||||
e = formatEventList(e, selectedId, nextId);
|
||||
setSchedule(e);
|
||||
|
||||
}, [backstageEvents, selectedId, nextId]);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<NavLogo />
|
||||
<div className={style.clockContainer}>
|
||||
<div className={style.time}>{time.clockNoSeconds}</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={style.nextTitle}
|
||||
style={{fontSize, height:'100px', width:'100%', maxWidth:'680px'}}
|
||||
>
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div className={time.running > 0 ? style.nextCountdown: style.nextCountdown__overtime}>
|
||||
{selectedId != null && formatDisplay(time.running)}
|
||||
</div>
|
||||
<div className={style.indicators}>
|
||||
{hoursIndicators.map(i => (
|
||||
<div
|
||||
key = {i}
|
||||
className={i <= hoursNow ? style.hours__active : style.hours}
|
||||
style={{
|
||||
transform: `rotate(${360/12*i-90}deg) translateX(380px)`
|
||||
}}/>
|
||||
)
|
||||
)}
|
||||
{minutesIndicators.map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={i <= minutesNow ? style.min__active : style.min}
|
||||
style={{
|
||||
transform: `rotate(${360/60*i-90}deg) translateX(415px)`
|
||||
}}/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.scheduleContainer}>
|
||||
<div className={onAir ? style.onAir : style.onAir__idle}>ON AIR</div>
|
||||
<div className={style.schedule}>
|
||||
<ul>
|
||||
{schedule.map((s) => (
|
||||
<li key={s.id} className={s.isNow ? style.now : s.isNext ? style.next : ''}>
|
||||
<div className={s.isNow ? style.decorator__active : s.isNext ? style.decorator__next : style.decorator}/>{`${s.time} ${s.title}`}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
@font-face {
|
||||
font-family: "digital-clock";
|
||||
src: local('digital-7'), url('./../../../assets/fonts/digital-7.mono.ttf') format('truetype') ;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
padding: 1vw;
|
||||
|
||||
background: #111;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1000px 1fr;
|
||||
gap: 50px;
|
||||
grid-template-areas: "clck schd";
|
||||
|
||||
/* =============== CLOCK STUFF ==================*/
|
||||
|
||||
$clock-size: 900px;
|
||||
$size-hours: 20px;
|
||||
$half-hours: 10px;
|
||||
$size-min: 18px;
|
||||
$half-min: 9px;
|
||||
$red-active: #c53030;
|
||||
$red-idle: darken($red-active, 30%);
|
||||
$cyan-active: #0ff;
|
||||
$cyan-idle: #0aa;
|
||||
|
||||
.clockContainer {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
grid-area: clck;
|
||||
width: $clock-size;
|
||||
height: $clock-size;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
font-family: digital-clock, monospace;
|
||||
text-transform: uppercase;
|
||||
|
||||
|
||||
.time {
|
||||
margin-top: 175px;
|
||||
color: $red-active;
|
||||
font-size: 275px;
|
||||
line-height: 0.8em;
|
||||
text-shadow: rgb(180,0,0) 0 0 20px;
|
||||
}
|
||||
.nextTitle:after,
|
||||
.nextCountdown:after,
|
||||
.nextCountdown__overtime:after {
|
||||
content: '\200b';
|
||||
}
|
||||
.nextTitle {
|
||||
color:$cyan-idle;
|
||||
line-height: 100px;
|
||||
}
|
||||
.nextCountdown,
|
||||
.nextCountdown__overtime {
|
||||
font-size: 100px;
|
||||
line-height: 1em;
|
||||
}
|
||||
.nextCountdown {
|
||||
color: $cyan-active;
|
||||
text-shadow: rgb(0,100,100) 0 0 20px;
|
||||
}
|
||||
.nextCountdown::before {
|
||||
content: '-';
|
||||
}
|
||||
.nextCountdown__overtime {
|
||||
color: darken($red-active, 10%);
|
||||
}
|
||||
.indicators {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.min,
|
||||
.min__active,
|
||||
.hours,
|
||||
.hours__active {
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
background: $red-idle;
|
||||
}
|
||||
.min,
|
||||
.min__active {
|
||||
min-height: $size-min;
|
||||
width: $size-min;
|
||||
top: calc(50% - #{$half_min});
|
||||
left: calc(50% - #{$half_min});
|
||||
}
|
||||
.hours,
|
||||
.hours__active{
|
||||
min-height:$size-hours;
|
||||
width: $size-hours;
|
||||
top: calc(50% - #{$half_hours});
|
||||
left: calc(50% - #{$half_hours});
|
||||
}
|
||||
.min__active,
|
||||
.hours__active {
|
||||
background: $red-active;
|
||||
box-shadow: 0 0 10px 2px rgba(255,0,0,0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============= SCHEDULE STUFF =================*/
|
||||
|
||||
.scheduleContainer {
|
||||
grid-area: schd;
|
||||
margin: 50px 0;
|
||||
padding-right: 50px;
|
||||
font-family: digital-clock, monospace;
|
||||
text-transform: uppercase;
|
||||
|
||||
|
||||
.onAir,
|
||||
.onAir__idle{
|
||||
padding-bottom: 50px;
|
||||
font-size: 190px;
|
||||
line-height: 0.9em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.onAir {
|
||||
color: $red-active;
|
||||
text-shadow: rgb(150,0,0) 0 0 20px;
|
||||
}
|
||||
.onAir__idle {
|
||||
color: $red-idle;
|
||||
}
|
||||
.schedule {
|
||||
|
||||
ul {
|
||||
color: $cyan-idle;
|
||||
line-height: 1em;
|
||||
list-style: none;
|
||||
font-size: 40px;
|
||||
}
|
||||
li {
|
||||
padding-bottom: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.now {
|
||||
color: $cyan-active;
|
||||
text-shadow: rgb(0,100,100) 0 0 20px;
|
||||
}
|
||||
.next {
|
||||
color: $red-active;
|
||||
text-shadow: rgb(100,0,0) 0 0 20px;
|
||||
}
|
||||
.decorator,
|
||||
.decorator__active,
|
||||
.decorator__next {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: $red-idle;
|
||||
min-height: $size-hours;
|
||||
max-height: $size-hours;
|
||||
width: $size-hours;
|
||||
}
|
||||
.decorator__active {
|
||||
background: $cyan-active;
|
||||
box-shadow: 0 0 10px 2px rgba(0,255,255,0.25);
|
||||
}
|
||||
.decorator__next{
|
||||
background: $red-active;
|
||||
box-shadow: 0 0 10px 2px rgba(255,0,0,0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1600px) {
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-areas: "clck";
|
||||
grid-template-columns: 100%;
|
||||
place-content: center;
|
||||
}
|
||||
.scheduleContainer{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user