Add component to allow importing from other events

This commit is contained in:
Benji Grant 2023-06-18 23:20:01 +10:00
parent f39db5f343
commit 7afcb4e8d9
15 changed files with 241 additions and 210 deletions

View file

@ -167,6 +167,7 @@ const EventAvailabilities = ({ event }: EventAvailabilitiesProps) => {
people={people}
table={table}
/> : user && <AvailabilityEditor
eventId={event?.id}
times={expandedTimes}
timezone={timezone}
value={user.availability}
@ -174,6 +175,15 @@ const EventAvailabilities = ({ event }: EventAvailabilitiesProps) => {
if (!event) return
const oldAvailability = [...user.availability]
setUser({ ...user, availability })
addRecent({
id: event.id,
name: event.name,
created_at: event.created_at,
user: availability.length > 0 ? {
name: user.name,
availability,
} : undefined,
})
updatePerson(event.id, user.name, { availability }, password)
.catch(e => {
console.warn(e)

View file

@ -135,3 +135,11 @@ a:focus-visible {
*::-webkit-scrollbar-thumb:active {
background: var(--secondary);
}
input[type=checkbox], input[type=radio] {
accent-color: var(--primary);
}
input[type=checkbox]:focus-visible, input[type=radio]:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}

View file

@ -2,16 +2,18 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import Button from '/src/components/Button/Button'
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 { calculateTable, makeClass, parseSpecificDate } from '/src/utils'
import styles from './AvailabilityEditor.module.scss'
import GoogleCalendar from './components/GoogleCalendar/GoogleCalendar'
import RecentEvents from './components/RecentEvents/RecentEvents'
import viewerStyles from '../AvailabilityViewer/AvailabilityViewer.module.scss'
import Skeleton from '../AvailabilityViewer/components/Skeleton/Skeleton'
interface AvailabilityEditorProps {
eventId?: string
times: string[]
timezone: string
value: string[]
@ -19,7 +21,7 @@ interface AvailabilityEditorProps {
table?: ReturnType<typeof calculateTable>
}
const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: AvailabilityEditorProps) => {
const AvailabilityEditor = ({ eventId, times, timezone, value = [], onChange, table }: AvailabilityEditorProps) => {
const { t } = useTranslation('event')
// Ref and state required to rerender but also access static version in callbacks
@ -74,6 +76,11 @@ const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: Av
times={times}
onImport={onChange}
/>
<RecentEvents
eventId={eventId}
times={times}
onImport={onChange}
/>
</div>
</Content>}

View file

@ -0,0 +1,63 @@
.wrapper {
width: 100%;
}
.title {
display: flex;
align-items: center;
& strong {
margin-right: 1ex;
}
}
.icon {
height: 24px;
width: 24px;
margin-right: 12px;
}
.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;
}
.item {
display: flex;
margin-block: .5em;
}
.name {
margin-left: .6em;
font-size: 15px;
font-weight: 500;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.info {
font-size: 14px;
opacity: .6;
font-weight: 500;
padding: 14px 0 10px;
}

View file

@ -4,7 +4,7 @@ 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 GoogleIcon from '/src/res/GoogleIcon'
import { allowUrlToWrap, parseSpecificDate } from '/src/utils'
import styles from './GoogleCalendar.module.scss'
@ -119,14 +119,14 @@ const GoogleCalendar = ({ timezone, timeStart, timeEnd, times, onImport }: Googl
isLoading={canLoad}
surfaceColor="#4286F5"
shadowColor="#3367BD"
icon={<img aria-hidden="true" src={googleLogo.src} alt="" />}
icon={<GoogleIcon aria-hidden="true" />}
>
{t('you.google_cal')}
</Button>}
{calendars && <div className={styles.wrapper}>
<p className={styles.title}>
<img src={googleLogo.src} alt="" className={styles.icon} />
<GoogleIcon className={styles.icon} />
<strong>{t('you.google_cal')}</strong>
(<button
className={styles.linkButton}
@ -148,17 +148,15 @@ const GoogleCalendar = ({ timezone, timeStart, timeEnd, times, onImport }: Googl
>{t('you.select_none')}</button>}
</div>
{calendars.map(calendar => <div key={calendar.id}>
{calendars.map(calendar => <div key={calendar.id} className={styles.item}>
<input
className={styles.checkbox}
type="checkbox"
id={calendar.id}
color={calendar.color}
style={{ accentColor: 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>
<label className={styles.name} htmlFor={calendar.id} title={calendar.description}>{allowUrlToWrap(calendar.name)}</label>
</div>)}
<div className={styles.info}>{t('you.integration.info')}</div>

View file

@ -1,3 +1,4 @@
/* TODO:
import { PublicClientApplication } from '@azure/msal-browser'
import { Client } from '@microsoft/microsoft-graph-client'
import { useEffect, useState } from 'react'
@ -226,3 +227,4 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
}
export default OutlookCalendar
*/

View file

@ -0,0 +1,91 @@
import { useCallback, useMemo, useState } from 'react'
import { Temporal } from '@js-temporal/polyfill'
import Button from '/src/components/Button/Button'
import { useTranslation } from '/src/i18n/client'
import CrabIcon from '/src/res/CrabIcon'
import { useStore } from '/src/stores'
import useRecentsStore, { RecentEvent } from '/src/stores/recentsStore'
import { relativeTimeFormat } from '/src/utils'
import styles from '../GoogleCalendar/GoogleCalendar.module.scss'
interface RecentEventsProps {
eventId?: string
times: string[]
onImport: (availability: string[]) => void
}
const hasAvailability = (event: RecentEvent): event is Required<RecentEvent> => event.user !== undefined
const RecentEvents = ({ eventId, times, onImport }: RecentEventsProps) => {
const { t, i18n } = useTranslation('event')
const allRecents = useStore(useRecentsStore, state => state.recents)
const recents = useMemo(() =>
allRecents
?.filter(hasAvailability)
.filter(e => e.id !== eventId && e.user.availability.some(a => times.includes(a))) ?? [],
[allRecents]
)
const [isOpen, setIsOpen] = useState(false)
const [selected, setSelected] = useState<string>()
const importAvailability = useCallback(() => {
if (selected === undefined || recents.length === 0) return
const selectedRecent = recents.find(r => r.id === selected)
if (!selectedRecent) return
onImport(selectedRecent.user.availability.filter(a => times.includes(a)))
}, [selected, recents])
// No recents
if (recents.length === 0) return null
return <>
{!isOpen && <Button
onClick={() => setIsOpen(true)}
icon={<CrabIcon aria-hidden="true" />}
>
{t('you.recent_event')}
</Button>}
{isOpen && <div className={styles.wrapper}>
<p className={styles.title}>
<CrabIcon className={styles.icon} />
<strong>{t('you.recent_event')}</strong>
(<button
className={styles.linkButton}
type="button"
onClick={() => setIsOpen(false)}
>{t('you.integration.close')}</button>)
</p>
{recents.map(recent => <div
key={recent.id}
title={Temporal.Instant.fromEpochSeconds(recent.created_at).toLocaleString(i18n.language, { dateStyle: 'long' })}
className={styles.item}
>
<input type="radio" name="recents" value={recent.id} id={recent.id} onChange={() => setSelected(recent.id)} checked={selected === recent.id} />
<label
htmlFor={recent.id}
className={styles.name}
>
<span>{recent.name} ({recent.user.name})</span>
<span style={{ opacity: .7, fontSize: '.7em' }}>{relativeTimeFormat(Temporal.Instant.fromEpochSeconds(recent.created_at), i18n.language)}</span>
</label>
</div>)}
<div className={styles.info}>{t('you.integration.info')}</div>
<Button
isSmall
disabled={selected === undefined}
onClick={importAvailability}
>{t('you.integration.button')}</Button>
</div>}
</>
}
export default RecentEvents

View file

@ -1,134 +0,0 @@
.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

@ -60,8 +60,10 @@
"google_cal": "Sync with Google Calendar",
"outlook_cal": "Sync with Outlook Calendar",
"recent_event": "Sync with a recent event",
"integration": {
"logout": "log out",
"close": "close",
"info": "Importing will overwrite your current availability",
"button": "Import availability"
}

View file

@ -0,0 +1,16 @@
const CrabIcon = (props: React.ComponentProps<'svg'>) =>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
<path fill="currentColor" d="M181.11,311a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L211.66,98.73h32.46c0,79.47-108.36,32.47-136.43,50.75C73.65,171.64,181.11,311,181.11,311Z"/>
<path fill="currentColor" d="M404,149.48C375.94,131.2,267.58,178.2,267.58,98.73H300L266.27,65h64.32a123,123,0,0,1,123,123h0a123,123,0,0,1-123,123S438.05,171.64,404,149.48Z"/>
<rect fill="currentColor" x="266.27" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<rect fill="currentColor" x="224.49" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<path fill="currentColor" d="M190.55,229.11H321.11A108.35,108.35,0,0,1,429.47,337.47h0A108.36,108.36,0,0,1,321.11,445.83H190.55A108.37,108.37,0,0,1,82.19,337.47h0A108.36,108.36,0,0,1,190.55,229.11Z"/>
<path fill="currentColor" d="M402.77,386.23c36,12.31,66,40.07,59.44,60.75C440,431.17,413.11,416.91,389,409.83Z"/>
<path fill="currentColor" d="M420.05,345.89c37.37,7.15,71,30.45,67.35,51.85-24.24-12.55-52.82-22.92-77.67-26.57Z"/>
<path fill="currentColor" d="M417.26,303.44c38.05.39,75.26,17.34,75.5,39-26.08-8-56.05-13.16-81.15-12.32Z"/>
<path fill="currentColor" d="M109.23,386.23c-36,12.31-66,40.07-59.44,60.75C72,431.17,98.89,416.91,123,409.83Z"/>
<path fill="currentColor" d="M92,345.89C54.58,353,21,376.34,24.6,397.74c24.24-12.55,52.82-22.92,77.67-26.57Z"/>
<path fill="currentColor" d="M94.74,303.44c-38,.39-75.26,17.34-75.5,39,26.08-8,56.05-13.16,81.15-12.32Z"/>
</svg>
export default CrabIcon

View file

@ -0,0 +1,11 @@
const GoogleIcon = (props: React.ComponentProps<'svg'>) =>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2443 2500" {...props}>
<path fill="currentColor" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z" />
</svg>
export default GoogleIcon

View file

@ -0,0 +1,20 @@
const OutlookIcon = (props: React.ComponentProps<'svg'>) =>
<svg version="1.1" width="103.17322" height="104.31332" viewBox="0 0 103.17322 104.31332" {...props}>
<path
d="m 64.566509,22.116383 v 20.404273 l 7.130526,4.489881 c 0.188058,0.05485 0.595516,0.05877 0.783574,0 L 103.16929,26.320259 c 0,-2.44867 -2.28412,-4.203876 -3.573094,-4.203876 H 64.566509 z"
fill="currentColor" />
<path
d="m 64.566509,50.13308 6.507584,4.470291 c 0.916782,0.673874 2.021622,0 2.021622,0 -1.100922,0.673874 30.077495,-20.035993 30.077495,-20.035993 v 37.501863 c 0,4.082422 -2.61322,5.794531 -5.551621,5.794531 H 64.562591 V 50.13308 z"
fill="currentColor" />
<g transform="matrix(3.9178712,0,0,3.9178712,-13.481403,-41.384473)">
<path
d="m 11.321,20.958 c -0.566,0 -1.017,0.266 -1.35,0.797 -0.333,0.531 -0.5,1.234 -0.5,2.109 0,0.888 0.167,1.59 0.5,2.106 0.333,0.517 0.77,0.774 1.31,0.774 0.557,0 0.999,-0.251 1.325,-0.753 0.326,-0.502 0.49,-1.199 0.49,-2.09 0,-0.929 -0.158,-1.652 -0.475,-2.169 -0.317,-0.516 -0.75,-0.774 -1.3,-0.774 z"
fill="currentColor" />
<path
d="m 3.441,13.563 v 20.375 l 15.5,3.25 V 10.563 l -15.5,3 z m 10.372,13.632 c -0.655,0.862 -1.509,1.294 -2.563,1.294 -1.027,0 -1.863,-0.418 -2.51,-1.253 C 8.094,26.4 7.77,25.312 7.77,23.97 c 0,-1.417 0.328,-2.563 0.985,-3.438 0.657,-0.875 1.527,-1.313 2.61,-1.313 1.023,0 1.851,0.418 2.482,1.256 0.632,0.838 0.948,1.942 0.948,3.313 10e-4,1.409 -0.327,2.545 -0.982,3.407 z"
fill="currentColor" />
</g>
</svg>
export default OutlookIcon

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve">
<path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 859 B

View file

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 15.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="103.17322"
height="104.31332"
viewBox="0 0 103.17322 104.31332"
enable-background="new 0 0 190 50"
xml:space="preserve"
inkscape:version="0.48.2 r9819"
sodipodi:docname="Outlook_logo.svg"><metadata
id="metadata45"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs43" />
<path
d="m 64.566509,22.116383 v 20.404273 l 7.130526,4.489881 c 0.188058,0.05485 0.595516,0.05877 0.783574,0 L 103.16929,26.320259 c 0,-2.44867 -2.28412,-4.203876 -3.573094,-4.203876 H 64.566509 z"
id="path3"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<path
d="m 64.566509,50.13308 6.507584,4.470291 c 0.916782,0.673874 2.021622,0 2.021622,0 -1.100922,0.673874 30.077495,-20.035993 30.077495,-20.035993 v 37.501863 c 0,4.082422 -2.61322,5.794531 -5.551621,5.794531 H 64.562591 V 50.13308 z"
id="path5"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<g
id="g23"
transform="matrix(3.9178712,0,0,3.9178712,-13.481403,-41.384473)">
<path
d="m 11.321,20.958 c -0.566,0 -1.017,0.266 -1.35,0.797 -0.333,0.531 -0.5,1.234 -0.5,2.109 0,0.888 0.167,1.59 0.5,2.106 0.333,0.517 0.77,0.774 1.31,0.774 0.557,0 0.999,-0.251 1.325,-0.753 0.326,-0.502 0.49,-1.199 0.49,-2.09 0,-0.929 -0.158,-1.652 -0.475,-2.169 -0.317,-0.516 -0.75,-0.774 -1.3,-0.774 z"
id="path25"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<path
d="m 3.441,13.563 v 20.375 l 15.5,3.25 V 10.563 l -15.5,3 z m 10.372,13.632 c -0.655,0.862 -1.509,1.294 -2.563,1.294 -1.027,0 -1.863,-0.418 -2.51,-1.253 C 8.094,26.4 7.77,25.312 7.77,23.97 c 0,-1.417 0.328,-2.563 0.985,-3.438 0.657,-0.875 1.527,-1.313 2.61,-1.313 1.023,0 1.851,0.418 2.482,1.256 0.632,0.838 0.948,1.942 0.948,3.313 10e-4,1.409 -0.327,2.545 -0.982,3.407 z"
id="path27"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface RecentEvent {
export interface RecentEvent {
id: string
name: string
created_at: number
@ -24,7 +24,7 @@ const useRecentsStore = create<RecentsStore>()(persist(
recents: [],
addRecent: event => set(state => ({
recents: [event, ...state.recents.filter(e => e.id !== event.id)],
recents: [{ ...state.recents.find(e => e.id === event.id), ...event }, ...state.recents.filter(e => e.id !== event.id)],
})),
removeRecent: id => set(state => ({
recents: state.recents.filter(e => e.id !== id),