Move Google calendar import to Nextjs
This commit is contained in:
parent
19c5c51b42
commit
21fc28987b
|
|
@ -1 +1,5 @@
|
||||||
NEXT_PUBLIC_API_URL="http://127.0.0.1:3000"
|
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=""
|
||||||
|
|
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
|
|
@ -4,3 +4,5 @@ dist
|
||||||
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"@vercel/analytics": "^1.0.1",
|
"@vercel/analytics": "^1.0.1",
|
||||||
"accept-language": "^3.0.18",
|
"accept-language": "^3.0.18",
|
||||||
"chroma.ts": "^1.0.10",
|
"chroma.ts": "^1.0.10",
|
||||||
"gapi-script": "^1.2.0",
|
|
||||||
"hue-map": "^1.0.0",
|
"hue-map": "^1.0.0",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
"i18next-browser-languagedetector": "^7.0.2",
|
"i18next-browser-languagedetector": "^7.0.2",
|
||||||
|
|
@ -33,6 +32,9 @@
|
||||||
"zustand": "^4.3.8"
|
"zustand": "^4.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/gapi": "^0.0.44",
|
||||||
|
"@types/gapi.calendar": "^3.0.6",
|
||||||
|
"@types/google.accounts": "^0.0.7",
|
||||||
"@types/node": "^20.2.5",
|
"@types/node": "^20.2.5",
|
||||||
"@types/react": "^18.2.9",
|
"@types/react": "^18.2.9",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Fragment, useCallback, useMemo, useRef, useState } from 'react'
|
import { Fragment, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import Content from '/src/components/Content/Content'
|
import Content from '/src/components/Content/Content'
|
||||||
|
import GoogleCalendar from '/src/components/GoogleCalendar/GoogleCalendar'
|
||||||
import { usePalette } from '/src/hooks/usePalette'
|
import { usePalette } from '/src/hooks/usePalette'
|
||||||
import { useTranslation } from '/src/i18n/client'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
import { useStore } from '/src/stores'
|
import { useStore } from '/src/stores'
|
||||||
import useSettingsStore from '/src/stores/settingsStore'
|
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'
|
import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss'
|
||||||
|
|
||||||
|
|
@ -47,34 +48,17 @@ const AvailabilityEditor = ({
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Content isCentered>{t('you.info')}</Content>
|
<Content isCentered>{t('you.info')}</Content>
|
||||||
{/* {isSpecificDates && (
|
{times[0].length === 13 && <Content>
|
||||||
<StyledMain>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
<Suspense fallback={<Loader />}>
|
|
||||||
<GoogleCalendar
|
<GoogleCalendar
|
||||||
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
|
timezone={timezone}
|
||||||
timeMax={dayjs(times[times.length - 1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
|
timeStart={parseSpecificDate(times[0])}
|
||||||
timeZone={timezone}
|
timeEnd={parseSpecificDate(times[times.length - 1]).add({ minutes: 15 })}
|
||||||
onImport={busyArray => onChange(
|
times={times}
|
||||||
times.filter(time => !busyArray.some(busy =>
|
onImport={onChange}
|
||||||
dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)')
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<OutlookCalendar
|
|
||||||
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(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)')
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</StyledMain>
|
</Content>}
|
||||||
)} */}
|
|
||||||
|
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
|
||||||
if (tempFocus) {
|
if (tempFocus) {
|
||||||
peopleHere = peopleHere.filter(p => p === 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 <div
|
return <div
|
||||||
key={y}
|
key={y}
|
||||||
|
|
@ -115,6 +115,7 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
|
||||||
columns,
|
columns,
|
||||||
highlight,
|
highlight,
|
||||||
max,
|
max,
|
||||||
|
min,
|
||||||
t,
|
t,
|
||||||
palette,
|
palette,
|
||||||
tempFocus,
|
tempFocus,
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { loadGapiInsideDOM } from 'gapi-script'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import googleLogo from '/src/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 { 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 ? (
|
|
||||||
<Center>
|
|
||||||
<Button
|
|
||||||
onClick={() => signIn()}
|
|
||||||
isLoading={signedIn === undefined}
|
|
||||||
primaryColor="#4286F5"
|
|
||||||
secondaryColor="#3367BD"
|
|
||||||
icon={<img aria-hidden="true" focusable="false" src={googleLogo} alt="" />}
|
|
||||||
>
|
|
||||||
{t('event:you.google_cal.login')}
|
|
||||||
</Button>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<CalendarList>
|
|
||||||
<Title>
|
|
||||||
<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>)
|
|
||||||
</Title>
|
|
||||||
<Options>
|
|
||||||
{calendars !== undefined && !calendars.every(c => c.checked) && (
|
|
||||||
<LinkButton type="button" onClick={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
setCalendars(calendars.map(c => ({...c, checked: true})))
|
|
||||||
}}>{t('event:you.google_cal.select_all')}</LinkButton>
|
|
||||||
)}
|
|
||||||
{calendars !== undefined && calendars.every(c => c.checked) && (
|
|
||||||
<LinkButton type="button" onClick={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
setCalendars(calendars.map(c => ({...c, checked: false})))
|
|
||||||
}}>{t('event:you.google_cal.select_none')}</LinkButton>
|
|
||||||
)}
|
|
||||||
</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={() => 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>{t('event:you.google_cal.info')}</Info>
|
|
||||||
<Button
|
|
||||||
small
|
|
||||||
isLoading={freeBusyLoading}
|
|
||||||
disabled={freeBusyLoading}
|
|
||||||
onClick={() => importAvailability()}
|
|
||||||
>{t('event:you.google_cal.button')}</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CalendarList>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GoogleCalendar
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
`
|
|
||||||
194
frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
Normal file
194
frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
Normal file
|
|
@ -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<Calendar[]>()
|
||||||
|
|
||||||
|
// 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 && <Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!canLoad) {
|
||||||
|
setCanLoad(true)
|
||||||
|
if ('google' in window) {
|
||||||
|
login(fetchCalendars)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCanLoad(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={canLoad}
|
||||||
|
surfaceColor="#4286F5"
|
||||||
|
shadowColor="#3367BD"
|
||||||
|
icon={<img aria-hidden="true" src={googleLogo.src} alt="" />}
|
||||||
|
>
|
||||||
|
{t('you.google_cal.login')}
|
||||||
|
</Button>}
|
||||||
|
|
||||||
|
{calendars && <div className={styles.wrapper}>
|
||||||
|
<p className={styles.title}>
|
||||||
|
<img src={googleLogo.src} alt="" className={styles.icon} />
|
||||||
|
<strong>{t('you.google_cal.login')}</strong>
|
||||||
|
(<button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCanLoad(false)}
|
||||||
|
>{t('you.google_cal.logout')}</button>)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.options}>
|
||||||
|
{!calendars.every(c => c.isChecked) && <button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendars(calendars.map(c => ({ ...c, isChecked: true })))}
|
||||||
|
>{t('event:you.google_cal.select_all')}</button>}
|
||||||
|
{calendars.every(c => c.isChecked) && <button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendars(calendars.map(c => ({ ...c, isChecked: false })))}
|
||||||
|
>{t('event:you.google_cal.select_none')}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calendars.map(calendar => <div key={calendar.id}>
|
||||||
|
<input
|
||||||
|
className={styles.checkbox}
|
||||||
|
type="checkbox"
|
||||||
|
id={calendar.id}
|
||||||
|
color={calendar.color}
|
||||||
|
checked={calendar.isChecked}
|
||||||
|
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, isChecked: !c.isChecked} : c))}
|
||||||
|
/>
|
||||||
|
<label htmlFor={calendar.id} style={{ '--cal-color': calendar.color } as React.CSSProperties} />
|
||||||
|
<label className={styles.calendarName} htmlFor={calendar.id} title={calendar.description}>{allowUrlToWrap(calendar.name)}</label>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<div className={styles.info}>{t('you.google_cal.info')}</div>
|
||||||
|
<Button
|
||||||
|
isSmall
|
||||||
|
isLoading={isLoadingAvailability}
|
||||||
|
disabled={isLoadingAvailability}
|
||||||
|
onClick={() => importAvailability()}
|
||||||
|
>{t('you.google_cal.button')}</Button>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Load google api scripts */}
|
||||||
|
{canLoad && <>
|
||||||
|
<Script
|
||||||
|
src="https://accounts.google.com/gsi/client"
|
||||||
|
onError={() => setCanLoad(false)}
|
||||||
|
onLoad={() => login(fetchCalendars)}
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://apis.google.com/js/api.js"
|
||||||
|
onError={() => setCanLoad(false)}
|
||||||
|
onLoad={() => gapi.load('client', () => {
|
||||||
|
gapi.client.init({
|
||||||
|
apiKey,
|
||||||
|
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
|
||||||
|
}).catch(() => setCanLoad(false))
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoogleCalendar
|
||||||
|
|
@ -26,10 +26,10 @@ const Legend = ({ min, max, total, palette, onSegmentFocus }: LegendProps) => {
|
||||||
onClick={() => setHighlight?.(!highlight)}
|
onClick={() => setHighlight?.(!highlight)}
|
||||||
title={t<string>('group.legend_tooltip')}
|
title={t<string>('group.legend_tooltip')}
|
||||||
>
|
>
|
||||||
{[...Array(max + 1 - min).keys()].map(i => i + min).map(i =>
|
{[...Array(max + 1 - min).keys()].map(i => i + min).map((i, j) =>
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{ flex: 1, backgroundColor: palette[i].string, '--highlight-color': palette[i].highlight } as React.CSSProperties}
|
style={{ flex: 1, backgroundColor: palette[j].string, '--highlight-color': palette[j].highlight } as React.CSSProperties}
|
||||||
className={highlight && i === max && max > 0 ? styles.highlight : undefined}
|
className={highlight && i === max && max > 0 ? styles.highlight : undefined}
|
||||||
onMouseOver={() => onSegmentFocus(i)}
|
onMouseOver={() => onSegmentFocus(i)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
26
frontend/src/utils/allowUrlToWrap.tsx
Normal file
26
frontend/src/utils/allowUrlToWrap.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a url and insert `<wbr>` elements to indicate to a browser
|
||||||
|
* where to break the line. This allows urls to wrap which prevents
|
||||||
|
* them from breaking layouts and also wraps them in a way that's still
|
||||||
|
* readable.
|
||||||
|
*
|
||||||
|
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/wbr | WBR element reference}
|
||||||
|
*/
|
||||||
|
export const allowUrlToWrap = (url: string): React.ReactNode => {
|
||||||
|
const formatted = url
|
||||||
|
.split('//') // Split the URL into an array to distinguish double slashes from single slashes
|
||||||
|
.map(str => str
|
||||||
|
// Insert a word break opportunity after a colon
|
||||||
|
.replace(/(?<after>:)/giu, '$1<wbr>')
|
||||||
|
// Before a single slash, tilde, period, comma, hyphen, underline, question mark, number sign, or percent symbol
|
||||||
|
.replace(/(?<before>[/~.,\-_?#%])/giu, '<wbr>$1')
|
||||||
|
// Before and after an equals sign or ampersand
|
||||||
|
.replace(/(?<beforeAndAfter>[=&])/giu, '<wbr>$1<wbr>')
|
||||||
|
).join('//<wbr>')
|
||||||
|
|
||||||
|
return formatted.split('<wbr>').map((section, i) =>
|
||||||
|
<Fragment key={i}>{section}<wbr /></Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ export const convertTimesToDates = (times: string[], timezone: string): Temporal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse from UTC `HHmm-DDMMYYYY` format into a ZonedDateTime in UTC
|
// Parse from UTC `HHmm-DDMMYYYY` format into a ZonedDateTime in UTC
|
||||||
const parseSpecificDate = (str: string): Temporal.ZonedDateTime => {
|
export const parseSpecificDate = (str: string): Temporal.ZonedDateTime => {
|
||||||
if (str.length !== 13) {
|
if (str.length !== 13) {
|
||||||
throw new Error('String must be in HHmm-DDMMYYYY format')
|
throw new Error('String must be in HHmm-DDMMYYYY format')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ export * from './getWeekdayNames'
|
||||||
export * from './relativeTimeFormat'
|
export * from './relativeTimeFormat'
|
||||||
export * from './expandTimes'
|
export * from './expandTimes'
|
||||||
export * from './serializeTime'
|
export * from './serializeTime'
|
||||||
|
export * from './allowUrlToWrap'
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@
|
||||||
{
|
{
|
||||||
"name": "typescript-plugin-css-modules"
|
"name": "typescript-plugin-css-modules"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"types": ["google.accounts", "gapi", "gapi.calendar"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,23 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@types/gapi.calendar@^3.0.6":
|
||||||
|
version "3.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/gapi.calendar/-/gapi.calendar-3.0.6.tgz#8ff854204056f2b0396283155fef8c10654a7e26"
|
||||||
|
integrity sha512-5POp+QFcJh7yHHOaOc3pkvEZq99u8EzaHuAL+0XcrNKQpN6EM9T6guQ2Q2O39KWkxGvAKdshDv2ZwB7I+v+t3A==
|
||||||
|
dependencies:
|
||||||
|
"@types/gapi" "*"
|
||||||
|
|
||||||
|
"@types/gapi@*", "@types/gapi@^0.0.44":
|
||||||
|
version "0.0.44"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/gapi/-/gapi-0.0.44.tgz#f097f7a0f59d63a59098a08a62a560ca168426fb"
|
||||||
|
integrity sha512-hsgJMfZ/pMwI15UlAYHMNwj8DRoigo1odhbPwEXdp19ZQwQAXbcRrpzaDsfc+9XM6RtGpvl4Ja7uW8A+KPCa7w==
|
||||||
|
|
||||||
|
"@types/google.accounts@^0.0.7":
|
||||||
|
version "0.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/google.accounts/-/google.accounts-0.0.7.tgz#d9f20f29f3d14336967c905bddb2d89dc2bfc709"
|
||||||
|
integrity sha512-AAA2+/s/Oa9ETgaWY6JM2P3h8RiQ1FkdyI7QYKMY3hbIkwGbjEBLX0L6FpazVxHnq68ruDyl8Fi5v0tEwSyfeg==
|
||||||
|
|
||||||
"@types/json-schema@^7.0.9":
|
"@types/json-schema@^7.0.9":
|
||||||
version "7.0.11"
|
version "7.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
||||||
|
|
@ -1271,11 +1288,6 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
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==
|
|
||||||
|
|
||||||
get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0:
|
get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
|
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue