Merge pull request #11 from GRA0007/dev

Sync with Google calendar
This commit is contained in:
Benjamin Grant 2021-05-16 18:38:07 +10:00 committed by GitHub
commit 1d570fb06c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 404 additions and 78 deletions

View file

@ -14,6 +14,7 @@
"@types/react-dom": "^17.0.1", "@types/react-dom": "^17.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"gapi-script": "^1.2.0",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-hook-form": "^6.15.4", "react-hook-form": "^6.15.4",

View file

@ -2,9 +2,11 @@ import { useState, useRef, Fragment } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import { import {
Wrapper, Wrapper,
ScrollWrapper,
Container, Container,
Date, Date,
Times, Times,
@ -14,16 +16,21 @@ import {
TimeLabels, TimeLabels,
TimeLabel, TimeLabel,
TimeSpace, TimeSpace,
StyledMain,
} from 'components/AvailabilityViewer/availabilityViewerStyle'; } from 'components/AvailabilityViewer/availabilityViewerStyle';
import { Time } from './availabilityEditorStyle'; import { Time } from './availabilityEditorStyle';
import { GoogleCalendar } from 'components';
dayjs.extend(localeData); dayjs.extend(localeData);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
dayjs.extend(isBetween);
const AvailabilityEditor = ({ const AvailabilityEditor = ({
times, times,
timeLabels, timeLabels,
dates, dates,
timezone,
isSpecificDates, isSpecificDates,
value = [], value = [],
onChange, onChange,
@ -45,7 +52,24 @@ const AvailabilityEditor = ({
}; };
return ( return (
<>
{isSpecificDates && (
<StyledMain>
<GoogleCalendar
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
timeZone={timezone}
onImport={busyArray => onChange(
times.filter(time => !busyArray.some(busy =>
dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)')
))
)}
/>
</StyledMain>
)}
<Wrapper> <Wrapper>
<ScrollWrapper>
<Container> <Container>
<TimeLabels> <TimeLabels>
{!!timeLabels.length && timeLabels.map((label, i) => {!!timeLabels.length && timeLabels.map((label, i) =>
@ -120,7 +144,9 @@ const AvailabilityEditor = ({
); );
})} })}
</Container> </Container>
</ScrollWrapper>
</Wrapper> </Wrapper>
</>
); );
}; };

View file

@ -3,11 +3,13 @@ import { Wrapper, Top, Bottom } from './buttonStyle';
const Button = ({ const Button = ({
buttonHeight, buttonHeight,
buttonWidth, buttonWidth,
primaryColor,
secondaryColor,
...props ...props
}) => ( }) => (
<Wrapper buttonHeight={buttonHeight} buttonWidth={buttonWidth}> <Wrapper buttonHeight={buttonHeight} buttonWidth={buttonWidth}>
<Top {...props} /> <Top primaryColor={primaryColor} secondaryColor={secondaryColor} {...props} />
<Bottom /> <Bottom secondaryColor={secondaryColor} />
</Wrapper> </Wrapper>
); );

View file

@ -16,10 +16,10 @@ export const Top = styled.button`
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
box-sizing: border-box; box-sizing: border-box;
background: ${props => props.theme.primary}; background: ${props => props.primaryColor || props.theme.primary};
color: #FFF; color: #FFF;
font-weight: 600; font-weight: 600;
text-shadow: 0 -1.5px .5px ${props => props.theme.primaryDark}; text-shadow: 0 -1.5px .5px ${props => props.secondaryColor || props.theme.primaryDark};
padding: 0; padding: 0;
border-radius: 3px; border-radius: 3px;
height: var(--btn-height); height: var(--btn-height);
@ -43,6 +43,10 @@ export const Top = styled.button`
color: transparent; color: transparent;
cursor: wait; cursor: wait;
& img {
opacity: 0;
}
@keyframes load { @keyframes load {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@ -69,7 +73,7 @@ export const Top = styled.button`
export const Bottom = styled.div` export const Bottom = styled.div`
box-sizing: border-box; box-sizing: border-box;
background: ${props => props.theme.primaryDark}; background: ${props => props.secondaryColor || props.theme.primaryDark};
border-radius: 3px; border-radius: 3px;
height: var(--btn-height); height: var(--btn-height);
width: var(--btn-width); width: var(--btn-width);

View file

@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { loadGapiInsideDOM } from 'gapi-script';
import { Button, Center } from 'components';
import { Loader } from '../Loading/loadingStyle';
import {
LoginButton,
CalendarList,
CheckboxInput,
CheckboxLabel,
CalendarLabel,
Info,
Options,
} from './googleCalendarStyle';
import googleLogo from 'res/google.svg';
const signIn = () => window.gapi.auth2.getAuthInstance().signIn();
const signOut = () => window.gapi.auth2.getAuthInstance().signOut();
const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [signedIn, setSignedIn] = useState(undefined);
const [calendars, setCalendars] = useState(undefined);
const [freeBusyLoading, setFreeBusyLoading] = useState(false);
const calendarLogin = async () => {
const gapi = await loadGapiInsideDOM();
gapi.load('client:auth2', () => {
window.gapi.client.init({
clientId: '276505195333-9kjl7e48m272dljbspkobctqrpet0n8m.apps.googleusercontent.com',
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
scope: 'https://www.googleapis.com/auth/calendar.readonly',
})
.then(() => {
// Listen for state changes
window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn));
// Handle initial sign-in state
setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get());
})
.catch(e => {
console.error(e);
setSignedIn(false);
});
});
};
const importAvailability = () => {
setFreeBusyLoading(true);
window.gapi.client.calendar.freebusy.query({
timeMin,
timeMax,
timeZone,
items: calendars.filter(c => c.checked).map(c => ({id: c.id})),
})
.then(response => {
onImport(response.result.calendars ? Object.values(response.result.calendars).reduce((busy, c) => [...busy, ...c.busy], []) : []);
setFreeBusyLoading(false);
}, e => {
console.error(e);
setFreeBusyLoading(false);
});
};
useEffect(() => calendarLogin(), []);
useEffect(() => {
if (signedIn) {
window.gapi.client.calendar.calendarList.list({
'minAccessRole': 'freeBusyReader'
})
.then(response => {
setCalendars(response.result.items.map(item => ({
'name': item.summary,
'description': item.description,
'id': item.id,
'color': item.backgroundColor,
'checked': item.hasOwnProperty('primary') && item.primary === true,
})));
})
.catch(e => {
console.error(e);
signOut();
});
}
}, [signedIn]);
return (
<>
{!signedIn ? (
<Center>
<Button
onClick={() => signIn()}
isLoading={signedIn === undefined}
buttonWidth="270px"
primaryColor="#4286F5"
secondaryColor="#3367BD">
<LoginButton>
<img src={googleLogo} alt="" />
<span>Sync with Google Calendar</span>
</LoginButton>
</Button>
</Center>
) : (
<CalendarList>
<p>
{/* eslint-disable-next-line */}
<strong>Sync with Google Calendar</strong> (<a href="#" onClick={e => {
e.preventDefault();
signOut();
}}>log out</a>)
</p>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
/* eslint-disable-next-line */
<a href="#" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: true})));
}}>Select all</a>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
/* eslint-disable-next-line */
<a href="#" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: false})));
}}>Select none</a>
)}
</Options>
{calendars !== undefined ? calendars.map(calendar => (
<div key={calendar.id}>
<CheckboxInput
type="checkbox"
role="checkbox"
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={e => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
</div>
)) : (
<Loader />
)}
{calendars !== undefined && (
<>
<Info>Importing will overwrite your currently inputted availability</Info>
<Button
buttonWidth="170px"
buttonHeight="35px"
isLoading={freeBusyLoading}
onClick={() => importAvailability()}
>Import availability</Button>
</>
)}
</CalendarList>
)}
</>
);
};
export default GoogleCalendar;

View file

@ -0,0 +1,112 @@
import styled from '@emotion/styled';
export const LoginButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
& img {
height: 1em;
margin-right: .8em;
}
`;
export const CalendarList = styled.div`
& > div {
display: flex;
margin: 2px 0;
}
`;
export const CheckboxInput = styled.input`
height: 0px;
width: 0px;
margin: 0;
padding: 0;
border: 0;
background: 0;
font-size: 0;
transform: scale(0);
position: absolute;
&:checked + label::after {
opacity: 1;
transform: scale(1);
}
&[disabled] + label {
opacity: .6;
}
&[disabled] + label:after {
border: 2px solid ${props => props.theme.text};
background-color: ${props => props.theme.text};
}
&:focus + label {
box-shadow: 0 0 0 2px ${props => props.theme.text}44;
background-color: ${props => props.theme.text}44;
outline: none;
}
&:checked:focus + label {
box-shadow: 0 0 0 2px ${props => props.color || props.theme.primary}44;
background-color: ${props => props.color || props.theme.primary}44;
}
`;
export const CheckboxLabel = styled.label`
display: inline-block;
height: 24px;
width: 24px;
min-width: 24px;
position: relative;
border-radius: 3px;
transition: background-color 0.2s, box-shadow 0.2s;
&::before {
content: '';
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.theme.text};
border-radius: 2px;
position: absolute;
top: 3px;
left: 3px;
}
&::after {
content: '';
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.color || props.theme.primary};
background-color: ${props => props.color || props.theme.primary};
border-radius: 2px;
position: absolute;
top: 3px;
left: 3px;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
transform: scale(.5);
transition: opacity 0.15s, transform 0.15s;
}
`;
export const CalendarLabel = styled.label`
margin-left: .6em;
font-size: 15px;
font-weight: 500;
line-height: 24px;
`;
export const Info = styled.div`
font-size: 14px;
opacity: .6;
font-weight: 500;
padding: 14px 0 10px;
`;
export const Options = styled.div`
font-size: 14px;
padding: 0 0 5px;
`;

View file

@ -10,6 +10,7 @@ export { default as AvailabilityViewer } from './AvailabilityViewer/Availability
export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'; export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor';
export { default as Error } from './Error/Error'; export { default as Error } from './Error/Error';
export { default as Loading } from './Loading/Loading'; export { default as Loading } from './Loading/Loading';
export { default as GoogleCalendar } from './GoogleCalendar/GoogleCalendar';
export { default as Center } from './Center/Center'; export { default as Center } from './Center/Center';
export { default as Donate } from './Donate/Donate'; export { default as Donate } from './Donate/Donate';

View file

@ -370,6 +370,7 @@ const Event = (props) => {
onChange={event => setTimezone(event.currentTarget.value)} onChange={event => setTimezone(event.currentTarget.value)}
options={timezones} options={timezones}
/> />
{/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p>This event was created in the timezone <strong>{event.timezone}</strong>. <a href="#" onClick={e => { {event?.timezone && event.timezone !== timezone && <p>This event was created in the timezone <strong>{event.timezone}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(event.timezone); setTimezone(event.timezone);
@ -381,6 +382,7 @@ const Event = (props) => {
event?.timezone === undefined event?.timezone === undefined
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone && Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
)) && ( )) && (
/* eslint-disable-next-line */
<p>Your local timezone is detected to be <strong>{Intl.DateTimeFormat().resolvedOptions().timeZone}</strong>. <a href="#" onClick={e => { <p>Your local timezone is detected to be <strong>{Intl.DateTimeFormat().resolvedOptions().timeZone}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
@ -435,6 +437,7 @@ const Event = (props) => {
times={times} times={times}
timeLabels={timeLabels} timeLabels={timeLabels}
dates={dates} dates={dates}
timezone={timezone}
isSpecificDates={!!dates.length && dates[0].length === 8} isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability} value={user.availability}
onChange={async availability => { onChange={async availability => {

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve">
<path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 854 B

View file

@ -5273,6 +5273,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
gapi-script@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gapi-script/-/gapi-script-1.2.0.tgz#8106ad0abb36661ce4fab62ef6efada288d7169e"
integrity sha512-NKTVKiIwFdkO1j1EzcrWu/Pz7gsl1GmBmgh+qhuV2Ytls04W/Eg5aiBL91SCiBM9lU0PMu7p1hTVxhh1rPT5Lw==
gensync@^1.0.0-beta.1: gensync@^1.0.0-beta.1:
version "1.0.0-beta.2" version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"