Migrate AvailabilityEditor

This commit is contained in:
Ben Grant 2023-05-29 01:06:57 +10:00
parent 1a6d34ac59
commit 5abba62c66
11 changed files with 277 additions and 219 deletions

View file

@ -1,186 +0,0 @@
import { useState, useRef, Fragment, Suspense, lazy } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import localeData from 'dayjs/plugin/localeData'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import isBetween from 'dayjs/plugin/isBetween'
import dayjs_timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
ScrollWrapper,
Container,
Date,
Times,
DateLabel,
DayLabel,
Spacer,
TimeLabels,
TimeLabel,
TimeSpace,
StyledMain,
} from '/src/components/AvailabilityViewer/AvailabilityViewer.styles'
import { Time } from './AvailabilityEditor.styles'
import { _GoogleCalendar, _OutlookCalendar, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
const GoogleCalendar = lazy(() => _GoogleCalendar())
const OutlookCalendar = lazy(() => _OutlookCalendar())
dayjs.extend(localeData)
dayjs.extend(customParseFormat)
dayjs.extend(isBetween)
dayjs.extend(utc)
dayjs.extend(dayjs_timezone)
const AvailabilityEditor = ({
times,
timeLabels,
dates,
timezone,
isSpecificDates,
value = [],
onChange,
}) => {
const { t } = useTranslation('event')
const locale = useLocaleUpdateStore(state => state.locale)
const [selectingTimes, _setSelectingTimes] = useState([])
const staticSelectingTimes = useRef([])
const setSelectingTimes = newTimes => {
staticSelectingTimes.current = newTimes
_setSelectingTimes(newTimes)
}
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode
_setMode(newMode)
}
return (
<>
<StyledMain>
<Center style={{textAlign: 'center'}}>{t('event:you.info')}</Center>
</StyledMain>
{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>
)}
<Wrapper locale={locale}>
<ScrollWrapper>
<Container>
<TimeLabels>
{!!timeLabels.length && timeLabels.map((label, i) =>
<TimeSpace key={i}>
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
</TimeSpace>
)}
</TimeLabels>
{dates.map((date, x) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1
return (
<Fragment key={x}>
<Date>
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times
$borderRight={last}
$borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1}
>
{timeLabels.map((timeLabel, y) => {
if (!timeLabel.time) return null
if (!times.includes(`${timeLabel.time}-${date}`)) {
return (
<TimeSpace key={x+y} className="timespace" title={t('event:greyed_times')} />
)
}
const time = `${timeLabel.time}-${date}`
return (
<Time
key={x+y}
$time={time}
className="time"
$selected={value.includes(time)}
$selecting={selectingTimes.includes(time)}
$mode={mode}
onPointerDown={e => {
e.preventDefault()
startPos.current = {x, y}
setMode(value.includes(time) ? 'remove' : 'add')
setSelectingTimes([time])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
onChange([...value, ...staticSelectingTimes.current])
} else if (staticMode.current === 'remove') {
onChange(value.filter(t => !staticSelectingTimes.current.includes(t)))
}
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx})
}
}
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`))
}
}}
/>
)
})}
</Times>
</Date>
{last && dates.length !== x+1 && (
<Spacer />
)}
</Fragment>
)
})}
</Container>
</ScrollWrapper>
</Wrapper>
</>
)
}
export default AvailabilityEditor

View file

@ -1,24 +0,0 @@
import { styled } from 'goober'
export const Time = styled('div')`
height: 10px;
touch-action: none;
transition: background-color .1s;
${props => props.$time.slice(2, 4) === '00' && `
border-top: 2px solid var(--text);
`}
${props => props.$time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent;
`}
${props => props.$time.slice(2, 4) === '30' && `
border-top: 2px dotted var(--text);
`}
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
background-color: var(--primary);
`};
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--background);
`};
`

View file

@ -0,0 +1,181 @@
import { Fragment, useCallback, useMemo, useRef, useState } from 'react'
import { createPalette } from 'hue-map'
import Content from '/src/components/Content/Content'
import { useTranslation } from '/src/i18n/client'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import { calculateColumns, calculateRows, convertTimesToDates, makeClass, serializeTime } from '/src/utils'
import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss'
interface AvailabilityEditorProps {
times: string[]
timezone: string
value: string[]
onChange: (value: string[]) => void
}
const AvailabilityEditor = ({
times,
timezone,
value = [],
onChange,
}: AvailabilityEditorProps) => {
const { t, i18n } = useTranslation('event')
const timeFormat = useStore(useSettingsStore, state => state.timeFormat)
const colormap = useStore(useSettingsStore, state => state.colormap)
// Calculate rows and columns
const [dates, rows, columns] = useMemo(() => {
const dates = convertTimesToDates(times, timezone)
return [dates, calculateRows(dates), calculateColumns(dates)]
}, [times, timezone])
// Ref and state required to rerender but also access static version in callbacks
const selectingRef = useRef<string[]>([])
const [selecting, _setSelecting] = useState<string[]>([])
const setSelecting = useCallback((v: string[]) => {
selectingRef.current = v
_setSelecting(v)
}, [])
const startPos = useRef({ x: 0, y: 0 })
const mode = useRef<'add' | 'remove'>()
// Is specific dates or just days of the week
const isSpecificDates = useMemo(() => times[0].length === 13, [times])
const palette = useMemo(() => createPalette({
map: colormap !== 'crabfit' ? colormap : [[0, [247, 158, 0, 0]], [1, [247, 158, 0, 255]]],
steps: 2,
}).format(), [colormap])
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>
)} */}
<div className={styles.wrapper}>
<div>
<div className={styles.heatmap}>
<div className={styles.timeLabels}>
{rows.map((row, i) =>
<div className={styles.timeSpace} key={i}>
{row && row.minute === 0 && <label className={styles.timeLabel}>
{row.toLocaleString(i18n.language, { hour: 'numeric', hour12: timeFormat === '12h' })}
</label>}
</div>
)}
</div>
{columns.map((column, x) => <Fragment key={x}>
{column ? <div className={styles.dateColumn}>
{isSpecificDates && <label className={styles.dateLabel}>{column.toLocaleString(i18n.language, { month: 'short', day: 'numeric' })}</label>}
<label className={styles.dayLabel}>{column.toLocaleString(i18n.language, { weekday: 'short' })}</label>
<div
className={styles.times}
data-border-left={x === 0 || columns.at(x - 1) === null}
data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
>
{rows.map((row, y) => {
if (y === rows.length - 1) return null
if (!row || rows.at(y + 1) === null || dates.every(d => !d.equals(column.toZonedDateTime({ timeZone: timezone, plainTime: row })))) {
return <div
className={makeClass(styles.timeSpace, styles.grey)}
key={y}
title={t<string>('greyed_times')}
/>
}
const date = column.toZonedDateTime({ timeZone: timezone, plainTime: row })
return <div
key={y}
className={styles.time}
style={{
backgroundColor: (
(!(mode.current === 'remove' && selecting.includes(serializeTime(date, isSpecificDates))) && value.includes(serializeTime(date, isSpecificDates)))
|| (mode.current === 'add' && selecting.includes(serializeTime(date, isSpecificDates)))
) ? palette[1] : palette[0],
...date.minute !== 0 && date.minute !== 30 && { borderTopColor: 'transparent' },
...date.minute === 30 && { borderTopStyle: 'dotted' },
}}
onPointerDown={e => {
e.preventDefault()
startPos.current = { x, y }
mode.current = value.includes(serializeTime(date, isSpecificDates)) ? 'remove' : 'add'
setSelecting([serializeTime(date, isSpecificDates)])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (mode.current === 'add') {
onChange([...value, ...selectingRef.current])
} else if (mode.current === 'remove') {
onChange(value.filter(t => !selectingRef.current.includes(t)))
}
mode.current = undefined
}, { once: true })
}}
onPointerEnter={() => {
if (mode.current) {
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y) + 1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x) + 1; cx++) {
found.push({ y: cy, x: cx })
}
}
setSelecting(found.flatMap(d => {
const [time, date] = [rows[d.y], columns[d.x]]
if (time !== null && date !== null) {
const str = serializeTime(date.toZonedDateTime({ timeZone: timezone, plainTime: time }), isSpecificDates)
if (times.includes(str)) {
return [str]
}
return []
}
return []
}))
}
}}
/>
})}
</div>
</div> : <div className={styles.columnSpacer} />}
</Fragment>)}
</div>
</div>
</div>
</>
}
export default AvailabilityEditor

View file

@ -97,6 +97,7 @@
height: 10px;
background-origin: border-box;
transition: background-color .1s;
touch-action: none;
border-top-width: 2px;
border-top-style: solid;

View file

@ -8,6 +8,7 @@
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.slim {

View file

@ -24,7 +24,7 @@ const Copyable = ({ children, className, ...props }: CopyableProps) => {
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t<string>('nav.title') : undefined}
title={'clipboard' in navigator ? t<string>('nav.title') : undefined}
className={makeClass(className, 'clipboard' in navigator && styles.copyable)}
{...props}
>{copied ?? children}</p>