diff --git a/frontend/.env.local b/frontend/.env.local index 5484c2c..ee43468 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -1 +1,5 @@ NEXT_PUBLIC_API_URL="http://127.0.0.1:3000" + +# Google auth for calendar syncing, feature will be disabled if these aren't set +# NEXT_PUBLIC_GOOGLE_CLIENT_ID="" +# NEXT_PUBLIC_GOOGLE_API_KEY="" diff --git a/frontend/.gitignore b/frontend/.gitignore index dec23b6..00d0689 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -4,3 +4,5 @@ dist yarn-debug.log* yarn-error.log* + +.env diff --git a/frontend/package.json b/frontend/package.json index 9402fd4..8a155e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,6 @@ "@vercel/analytics": "^1.0.1", "accept-language": "^3.0.18", "chroma.ts": "^1.0.10", - "gapi-script": "^1.2.0", "hue-map": "^1.0.0", "i18next": "^22.5.1", "i18next-browser-languagedetector": "^7.0.2", @@ -33,6 +32,9 @@ "zustand": "^4.3.8" }, "devDependencies": { + "@types/gapi": "^0.0.44", + "@types/gapi.calendar": "^3.0.6", + "@types/google.accounts": "^0.0.7", "@types/node": "^20.2.5", "@types/react": "^18.2.9", "@types/react-dom": "^18.2.4", diff --git a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx index e650488..e559761 100644 --- a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx +++ b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -1,11 +1,12 @@ import { Fragment, useCallback, useMemo, useRef, useState } from 'react' import Content from '/src/components/Content/Content' +import GoogleCalendar from '/src/components/GoogleCalendar/GoogleCalendar' import { usePalette } from '/src/hooks/usePalette' import { useTranslation } from '/src/i18n/client' import { useStore } from '/src/stores' import useSettingsStore from '/src/stores/settingsStore' -import { calculateTable, makeClass } from '/src/utils' +import { calculateTable, makeClass, parseSpecificDate } from '/src/utils' import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss' @@ -47,34 +48,17 @@ const AvailabilityEditor = ({ return <> {t('you.info')} - {/* {isSpecificDates && ( - -
- }> - onChange( - times.filter(time => !busyArray.some(busy => - dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') - )) - )} - /> - onChange( - times.filter(time => !busyArray.some(busy => - dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)') - )) - )} - /> - -
-
- )} */} + {times[0].length === 13 && +
+ +
+
}
diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx index 5b2bfc0..7c06cec 100644 --- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx +++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx @@ -79,7 +79,7 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps if (tempFocus) { peopleHere = peopleHere.filter(p => p === tempFocus) } - const color = palette[tempFocus && peopleHere.length ? max : peopleHere.length] + const color = palette[tempFocus && peopleHere.length ? max : peopleHere.length - min] return
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 { t } = useTranslation('event') - - 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(() => void 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.primary === true, - }))) - }) - .catch(e => { - console.error(e) - signOut() - }) - } - }, [signedIn]) - - return ( - <> - {!signedIn ? ( -
- -
- ) : ( - - - <Icon src={googleLogo} alt="" /> - <strong>{t('event:you.google_cal.login')}</strong> - (<LinkButton type="button" onClick={e => { - e.preventDefault() - signOut() - }}>{t('event:you.google_cal.logout')}</LinkButton>) - - - {calendars !== undefined && !calendars.every(c => c.checked) && ( - { - e.preventDefault() - setCalendars(calendars.map(c => ({...c, checked: true}))) - }}>{t('event:you.google_cal.select_all')} - )} - {calendars !== undefined && calendars.every(c => c.checked) && ( - { - e.preventDefault() - setCalendars(calendars.map(c => ({...c, checked: false}))) - }}>{t('event:you.google_cal.select_none')} - )} - - {calendars !== undefined ? calendars.map(calendar => ( -
- setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))} - /> - - {calendar.name} -
- )) : ( - - )} - {calendars !== undefined && ( - <> - {t('event:you.google_cal.info')} - - - )} -
- )} - - ) -} - -export default GoogleCalendar diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss b/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss new file mode 100644 index 0000000..89d75da --- /dev/null +++ b/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss @@ -0,0 +1,134 @@ +.wrapper { + width: 100%; + + & > div { + display: flex; + margin-block: 2px; + } +} + +.title { + display: flex; + align-items: center; + + & strong { + margin-right: 1ex; + } +} + +.icon { + height: 24px; + width: 24px; + margin-right: 12px; + + @media (prefers-color-scheme: light) { + filter: invert(1); + } + :global(.light) & { + filter: invert(1); + } +} + +.linkButton { + font: inherit; + color: var(--primary); + border: 0; + background: none; + text-decoration: underline; + padding: 0; + margin: 0; + display: inline; + cursor: pointer; + appearance: none; + border-radius: .2em; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 2px; + } +} + +.options { + font-size: 14px; + padding: 0 0 5px; +} + +.checkbox { + 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 var(--text); + background-color: var(--text); + } + + & + 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; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + height: 14px; + width: 14px; + border: 2px solid var(--text); + border-radius: 2px; + position: absolute; + top: 3px; + left: 3px; + } + &::after { + content: ''; + display: inline-block; + height: 14px; + width: 14px; + border: 2px solid var(--cal-color, var(--primary)); + background-color: var(--cal-color, var(--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; + } + } +} + +.calendarName { + margin-left: .6em; + font-size: 15px; + font-weight: 500; + line-height: 24px; +} + +.info { + font-size: 14px; + opacity: .6; + font-weight: 500; + padding: 14px 0 10px; +} diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js b/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js deleted file mode 100644 index b30b622..0000000 --- a/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js +++ /dev/null @@ -1,122 +0,0 @@ -import { styled } from 'goober' - -export const CalendarList = styled('div')` - width: 100%; - & > 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 var(--text); - background-color: var(--text); - } -` - -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 var(--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 || 'var(--primary)'}; - background-color: ${props => props.color || 'var(--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; -` - -export const Title = styled('p')` - display: flex; - align-items: center; - - & strong { - margin-right: 1ex; - } -` - -export const Icon = styled('img')` - height: 24px; - width: 24px; - margin-right: 12px; - filter: invert(1); -` - -export const LinkButton = styled('button')` - font: inherit; - color: var(--primary); - border: 0; - background: none; - text-decoration: underline; - padding: 0; - margin: 0; - display: inline; - cursor: pointer; - appearance: none; -` diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx b/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx new file mode 100644 index 0000000..7b567a1 --- /dev/null +++ b/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import Script from 'next/script' +import { Temporal } from '@js-temporal/polyfill' + +import Button from '/src/components/Button/Button' +import { useTranslation } from '/src/i18n/client' +import googleLogo from '/src/res/google.svg' +import { allowUrlToWrap, parseSpecificDate } from '/src/utils' + +import styles from './GoogleCalendar.module.scss' + +const [clientId, apiKey] = [process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, process.env.NEXT_PUBLIC_GOOGLE_API_KEY] + +interface Calendar { + id: string + name: string + description?: string + color?: string + isChecked: boolean +} + +const login = (callback: (tokenResponse: google.accounts.oauth2.TokenResponse) => void) => { + if (!clientId) return + + const client = google.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: 'https://www.googleapis.com/auth/calendar.readonly', + callback, + }) + if (gapi?.client?.getToken()) { + // Skip dialog for existing session + client.requestAccessToken({ prompt: '' }) + } else { + client.requestAccessToken() + } +} + +interface GoogleCalendarProps { + timezone: string + timeStart: Temporal.ZonedDateTime + timeEnd: Temporal.ZonedDateTime + times: string[] + onImport: (availability: string[]) => void +} + +const GoogleCalendar = ({ timezone, timeStart, timeEnd, times, onImport }: GoogleCalendarProps) => { + if (!clientId || !apiKey) return null + + const { t } = useTranslation('event') + + // Prevent Google scripts from loading until button pressed + const [canLoad, setCanLoad] = useState(false) + const [calendars, setCalendars] = useState() + + // Clear calendars if logged out + useEffect(() => { + if (!canLoad) setCalendars(undefined) + }, [canLoad]) + + const fetchCalendars = useCallback((res: google.accounts.oauth2.TokenResponse) => { + if (res.error !== undefined) return setCanLoad(false) + if ('gapi' in window) { + gapi.client.calendar.calendarList.list({ + 'minAccessRole': 'freeBusyReader' + }) + .then(res => setCalendars(res.result.items.map(item => ({ + id: item.id, + name: item.summary, + description: item.description, + color: item.backgroundColor, + isChecked: item.primary === true, + })))) + .catch(console.warn) + } else { + setCanLoad(false) + } + }, []) + + // Process times so they can be checked quickly + const epochTimes = useMemo(() => times.map(t => parseSpecificDate(t).epochMilliseconds), [times]) + + const [isLoadingAvailability, setIsLoadingAvailability] = useState(false) + const importAvailability = useCallback(() => { + if (!calendars) return + + setIsLoadingAvailability(true) + gapi.client.calendar.freebusy.query({ + timeMin: timeStart.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z', + timeMax: timeEnd.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z', + timeZone: timezone, + items: calendars.filter(c => c.isChecked).map(c => ({ id: c.id })), + }) + .then(response => { + const availabilities = response.result.calendars ? Object.values(response.result.calendars).flatMap(cal => cal.busy.map(a => ({ + start: new Date(a.start).valueOf(), + end: new Date(a.end).valueOf(), + }))) : [] + + onImport(times.filter((_, i) => !availabilities.some(a => epochTimes[i] >= a.start && epochTimes[i] < a.end))) + setIsLoadingAvailability(false) + }, e => { + console.error(e) + setIsLoadingAvailability(false) + }) + }, [calendars]) + + return <> + {!calendars && } + + {calendars &&
+

+ + {t('you.google_cal.login')} + () +

+ +
+ {!calendars.every(c => c.isChecked) && } + {calendars.every(c => c.isChecked) && } +
+ + {calendars.map(calendar =>
+ setCalendars(calendars.map(c => c.id === calendar.id ? {...c, isChecked: !c.isChecked} : c))} + /> +
)} + +
{t('you.google_cal.info')}
+ +
} + + {/* Load google api scripts */} + {canLoad && <> +