From 8b24b2e27afd87122393985e3083c1283e34cd22 Mon Sep 17 00:00:00 2001 From: Ben Grant Date: Sun, 16 May 2021 18:17:39 +1000 Subject: [PATCH] Sync with Google calendar --- crabfit-frontend/package.json | 1 + .../AvailabilityEditor/AvailabilityEditor.tsx | 172 ++++++++++-------- .../src/components/Button/Button.tsx | 6 +- .../src/components/Button/buttonStyle.ts | 10 +- .../GoogleCalendar/GoogleCalendar.tsx | 163 +++++++++++++++++ .../GoogleCalendar/googleCalendarStyle.ts | 112 ++++++++++++ crabfit-frontend/src/components/index.ts | 1 + crabfit-frontend/src/pages/Event/Event.tsx | 3 + crabfit-frontend/src/res/google.svg | 9 + crabfit-frontend/yarn.lock | 5 + 10 files changed, 404 insertions(+), 78 deletions(-) create mode 100644 crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx create mode 100644 crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts create mode 100644 crabfit-frontend/src/res/google.svg diff --git a/crabfit-frontend/package.json b/crabfit-frontend/package.json index 31de9ef..785e94c 100644 --- a/crabfit-frontend/package.json +++ b/crabfit-frontend/package.json @@ -14,6 +14,7 @@ "@types/react-dom": "^17.0.1", "axios": "^0.21.1", "dayjs": "^1.10.4", + "gapi-script": "^1.2.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-hook-form": "^6.15.4", diff --git a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx index 927799f..f6b1085 100644 --- a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx +++ b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -2,9 +2,11 @@ import { useState, useRef, Fragment } from 'react'; import dayjs from 'dayjs'; import localeData from 'dayjs/plugin/localeData'; import customParseFormat from 'dayjs/plugin/customParseFormat'; +import isBetween from 'dayjs/plugin/isBetween'; import { Wrapper, + ScrollWrapper, Container, Date, Times, @@ -14,16 +16,21 @@ import { TimeLabels, TimeLabel, TimeSpace, + StyledMain, } from 'components/AvailabilityViewer/availabilityViewerStyle'; import { Time } from './availabilityEditorStyle'; +import { GoogleCalendar } from 'components'; + dayjs.extend(localeData); dayjs.extend(customParseFormat); +dayjs.extend(isBetween); const AvailabilityEditor = ({ times, timeLabels, dates, + timezone, isSpecificDates, value = [], onChange, @@ -45,82 +52,101 @@ const AvailabilityEditor = ({ }; return ( - - - - {!!timeLabels.length && timeLabels.map((label, i) => - - {label.label?.length !== '' && {label.label}} - - )} - - {dates.map((date, x) => { - const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date); - const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1; - return ( - - - {isSpecificDates && {parsedDate.format('MMM D')}} - {parsedDate.format('ddd')} + <> + {isSpecificDates && ( + + onChange( + times.filter(time => !busyArray.some(busy => + dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') + )) + )} + /> + + )} - - {timeLabels.map((timeLabel, y) => { - if (!timeLabel.time) return null; - if (!times.includes(`${timeLabel.time}-${date}`)) { - return ( - - ); - } - const time = `${timeLabel.time}-${date}`; + + + + + {!!timeLabels.length && timeLabels.map((label, i) => + + {label.label?.length !== '' && {label.label}} + + )} + + {dates.map((date, x) => { + const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date); + const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1; + return ( + + + {isSpecificDates && {parsedDate.format('MMM D')}} + {parsedDate.format('ddd')} - return ( - - {last && dates.length !== x+1 && ( - - )} - - ); - })} - - + return ( + + + {last && dates.length !== x+1 && ( + + )} + + ); + })} + + + + ); }; diff --git a/crabfit-frontend/src/components/Button/Button.tsx b/crabfit-frontend/src/components/Button/Button.tsx index 5e90449..896d574 100644 --- a/crabfit-frontend/src/components/Button/Button.tsx +++ b/crabfit-frontend/src/components/Button/Button.tsx @@ -3,11 +3,13 @@ import { Wrapper, Top, Bottom } from './buttonStyle'; const Button = ({ buttonHeight, buttonWidth, + primaryColor, + secondaryColor, ...props }) => ( - - + + ); diff --git a/crabfit-frontend/src/components/Button/buttonStyle.ts b/crabfit-frontend/src/components/Button/buttonStyle.ts index 46115c4..6bac31d 100644 --- a/crabfit-frontend/src/components/Button/buttonStyle.ts +++ b/crabfit-frontend/src/components/Button/buttonStyle.ts @@ -16,10 +16,10 @@ export const Top = styled.button` cursor: pointer; font: inherit; box-sizing: border-box; - background: ${props => props.theme.primary}; + background: ${props => props.primaryColor || props.theme.primary}; color: #FFF; 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; border-radius: 3px; height: var(--btn-height); @@ -43,6 +43,10 @@ export const Top = styled.button` color: transparent; cursor: wait; + & img { + opacity: 0; + } + @keyframes load { from { transform: rotate(0deg); @@ -69,7 +73,7 @@ export const Top = styled.button` export const Bottom = styled.div` box-sizing: border-box; - background: ${props => props.theme.primaryDark}; + background: ${props => props.secondaryColor || props.theme.primaryDark}; border-radius: 3px; height: var(--btn-height); width: var(--btn-width); diff --git a/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx b/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx new file mode 100644 index 0000000..7031326 --- /dev/null +++ b/crabfit-frontend/src/components/GoogleCalendar/GoogleCalendar.tsx @@ -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 ? ( +
+ +
+ ) : ( + +

+ {/* eslint-disable-next-line */} + Sync with Google Calendar ( { + e.preventDefault(); + signOut(); + }}>log out) +

+ + {calendars !== undefined && !calendars.every(c => c.checked) && ( + /* eslint-disable-next-line */ + { + e.preventDefault(); + setCalendars(calendars.map(c => ({...c, checked: true}))); + }}>Select all + )} + {calendars !== undefined && calendars.every(c => c.checked) && ( + /* eslint-disable-next-line */ + { + e.preventDefault(); + setCalendars(calendars.map(c => ({...c, checked: false}))); + }}>Select none + )} + + {calendars !== undefined ? calendars.map(calendar => ( +
+ setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))} + /> + + {calendar.name} +
+ )) : ( + + )} + {calendars !== undefined && ( + <> + Importing will overwrite your currently inputted availability + + + )} +
+ )} + + ); +}; + +export default GoogleCalendar; diff --git a/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts b/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts new file mode 100644 index 0000000..dcb1cae --- /dev/null +++ b/crabfit-frontend/src/components/GoogleCalendar/googleCalendarStyle.ts @@ -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; +`; diff --git a/crabfit-frontend/src/components/index.ts b/crabfit-frontend/src/components/index.ts index 0d60028..a3fcc60 100644 --- a/crabfit-frontend/src/components/index.ts +++ b/crabfit-frontend/src/components/index.ts @@ -10,6 +10,7 @@ export { default as AvailabilityViewer } from './AvailabilityViewer/Availability export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'; export { default as Error } from './Error/Error'; export { default as Loading } from './Loading/Loading'; +export { default as GoogleCalendar } from './GoogleCalendar/GoogleCalendar'; export { default as Center } from './Center/Center'; export { default as Donate } from './Donate/Donate'; diff --git a/crabfit-frontend/src/pages/Event/Event.tsx b/crabfit-frontend/src/pages/Event/Event.tsx index 3184ab8..7b86e85 100644 --- a/crabfit-frontend/src/pages/Event/Event.tsx +++ b/crabfit-frontend/src/pages/Event/Event.tsx @@ -370,6 +370,7 @@ const Event = (props) => { onChange={event => setTimezone(event.currentTarget.value)} options={timezones} /> + {/* eslint-disable-next-line */} {event?.timezone && event.timezone !== timezone &&

This event was created in the timezone {event.timezone}. { e.preventDefault(); setTimezone(event.timezone); @@ -381,6 +382,7 @@ const Event = (props) => { event?.timezone === undefined && Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone )) && ( + /* eslint-disable-next-line */

Your local timezone is detected to be {Intl.DateTimeFormat().resolvedOptions().timeZone}. { e.preventDefault(); setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); @@ -435,6 +437,7 @@ const Event = (props) => { times={times} timeLabels={timeLabels} dates={dates} + timezone={timezone} isSpecificDates={!!dates.length && dates[0].length === 8} value={user.availability} onChange={async availability => { diff --git a/crabfit-frontend/src/res/google.svg b/crabfit-frontend/src/res/google.svg new file mode 100644 index 0000000..3b71e33 --- /dev/null +++ b/crabfit-frontend/src/res/google.svg @@ -0,0 +1,9 @@ + + + + diff --git a/crabfit-frontend/yarn.lock b/crabfit-frontend/yarn.lock index 8967169..2916191 100644 --- a/crabfit-frontend/yarn.lock +++ b/crabfit-frontend/yarn.lock @@ -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" 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: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"