Move Google calendar import to Nextjs

This commit is contained in:
Benji Grant 2023-06-09 23:02:55 +10:00
parent 19c5c51b42
commit 21fc28987b
15 changed files with 401 additions and 311 deletions

View file

@ -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=""

2
frontend/.gitignore vendored
View file

@ -4,3 +4,5 @@ dist
yarn-debug.log*
yarn-error.log*
.env

View file

@ -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",

View file

@ -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 <>
<Content isCentered>{t('you.info')}</Content>
{/* {isSpecificDates && (
<StyledMain>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<Suspense fallback={<Loader />}>
<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, '[)')
))
)}
/>
<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>
</StyledMain>
)} */}
{times[0].length === 13 && <Content>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<GoogleCalendar
timezone={timezone}
timeStart={parseSpecificDate(times[0])}
timeEnd={parseSpecificDate(times[times.length - 1]).add({ minutes: 15 })}
times={times}
onImport={onChange}
/>
</div>
</Content>}
<div className={styles.wrapper}>
<div>

View file

@ -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 <div
key={y}
@ -115,6 +115,7 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
columns,
highlight,
max,
min,
t,
palette,
tempFocus,

View file

@ -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

View file

@ -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;
}

View file

@ -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;
`

View 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

View file

@ -26,10 +26,10 @@ const Legend = ({ min, max, total, palette, onSegmentFocus }: LegendProps) => {
onClick={() => setHighlight?.(!highlight)}
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
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}
onMouseOver={() => onSegmentFocus(i)}
/>

View 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>
)
}

View file

@ -15,7 +15,7 @@ export const convertTimesToDates = (times: string[], timezone: string): Temporal
}
// 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) {
throw new Error('String must be in HHmm-DDMMYYYY format')
}

View file

@ -10,3 +10,4 @@ export * from './getWeekdayNames'
export * from './relativeTimeFormat'
export * from './expandTimes'
export * from './serializeTime'
export * from './allowUrlToWrap'

View file

@ -30,7 +30,8 @@
{
"name": "typescript-plugin-css-modules"
}
]
],
"types": ["google.accounts", "gapi", "gapi.calendar"]
},
"include": [
"next-env.d.ts",

View file

@ -207,6 +207,23 @@
dependencies:
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":
version "7.0.11"
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"
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:
version "1.2.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"