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 ? (
-
-
-
- ) : (
-
-
-
- {t('event:you.google_cal.login')}
- ( {
- e.preventDefault()
- signOut()
- }}>{t('event:you.google_cal.logout')})
-
-
- {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 && <>
+