Migrate AvailabilityEditor
This commit is contained in:
parent
1a6d34ac59
commit
5abba62c66
|
|
@ -1,23 +1,31 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Trans } from 'react-i18next/TransWithoutContext'
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
|
||||||
|
import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor'
|
||||||
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||||
import Content from '/src/components/Content/Content'
|
import Content from '/src/components/Content/Content'
|
||||||
import Login from '/src/components/Login/Login'
|
import Login from '/src/components/Login/Login'
|
||||||
import Section from '/src/components/Section/Section'
|
import Section from '/src/components/Section/Section'
|
||||||
import SelectField from '/src/components/SelectField/SelectField'
|
import SelectField from '/src/components/SelectField/SelectField'
|
||||||
import { EventResponse, PersonResponse } from '/src/config/api'
|
import { EventResponse, getPeople, PersonResponse, updatePerson } from '/src/config/api'
|
||||||
import { useTranslation } from '/src/i18n/client'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
import timezones from '/src/res/timezones.json'
|
import timezones from '/src/res/timezones.json'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
import { expandTimes, makeClass } from '/src/utils'
|
import { expandTimes, makeClass } from '/src/utils'
|
||||||
|
|
||||||
import styles from './page.module.scss'
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
const EventAvailabilities = ({ event, people }: { event: EventResponse, people: PersonResponse[] }) => {
|
interface EventAvailabilitiesProps {
|
||||||
|
event: EventResponse
|
||||||
|
people: PersonResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
|
||||||
const { t, i18n } = useTranslation('event')
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
const [people, setPeople] = useState(data.people)
|
||||||
const expandedTimes = useMemo(() => expandTimes(event.times), [event.times])
|
const expandedTimes = useMemo(() => expandTimes(event.times), [event.times])
|
||||||
|
|
||||||
const [user, setUser] = useState<PersonResponse>()
|
const [user, setUser] = useState<PersonResponse>()
|
||||||
|
|
@ -26,12 +34,32 @@ const EventAvailabilities = ({ event, people }: { event: EventResponse, people:
|
||||||
const [tab, setTab] = useState<'group' | 'you'>('group')
|
const [tab, setTab] = useState<'group' | 'you'>('group')
|
||||||
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
|
||||||
|
// Add this event to recents
|
||||||
|
const addRecent = useRecentsStore(state => state.addRecent)
|
||||||
|
useEffect(() => {
|
||||||
|
addRecent({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
created_at: event.created_at,
|
||||||
|
})
|
||||||
|
}, [addRecent])
|
||||||
|
|
||||||
|
// Refetch availabilities
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'group') {
|
||||||
|
getPeople(event.id)
|
||||||
|
.then(setPeople)
|
||||||
|
.catch(console.warn)
|
||||||
|
}
|
||||||
|
}, [tab])
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Section id="login">
|
<Section id="login">
|
||||||
<Content>
|
<Content>
|
||||||
<Login eventId={event.id} user={user} onChange={(u, p) => {
|
<Login eventId={event.id} user={user} onChange={(u, p) => {
|
||||||
setUser(u)
|
setUser(u)
|
||||||
setPassword(p)
|
setPassword(p)
|
||||||
|
setTab(u ? 'you' : 'group')
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
|
|
@ -107,10 +135,23 @@ const EventAvailabilities = ({ event, people }: { event: EventResponse, people:
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
{tab === 'group' && <AvailabilityViewer
|
{tab === 'group' ? <AvailabilityViewer
|
||||||
times={expandedTimes}
|
times={expandedTimes}
|
||||||
people={people}
|
people={people}
|
||||||
timezone={timezone}
|
timezone={timezone}
|
||||||
|
/> : user && <AvailabilityEditor
|
||||||
|
times={expandedTimes}
|
||||||
|
timezone={timezone}
|
||||||
|
value={user.availability}
|
||||||
|
onChange={availability => {
|
||||||
|
const oldAvailability = [...user.availability]
|
||||||
|
setUser({ ...user, availability })
|
||||||
|
updatePerson(event.id, user.name, { availability }, password)
|
||||||
|
.catch(e => {
|
||||||
|
console.warn(e)
|
||||||
|
setUser({ ...user, availability: oldAvailability })
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>}
|
/>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import Content from '/src/components/Content/Content'
|
import Content from '/src/components/Content/Content'
|
||||||
import { useTranslation } from '/src/i18n/server'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
|
||||||
import styles from './page.module.scss'
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
const NotFound = async () => {
|
const NotFound = () => {
|
||||||
const { t } = await useTranslation('event')
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
// Remove this event from recents if it was in there
|
||||||
|
const removeRecent = useRecentsStore(state => state.removeRecent)
|
||||||
|
useEffect(() => {
|
||||||
|
// Note: Next.js doesn't expose path params to the 404 page
|
||||||
|
removeRecent(window.location.pathname.replace('/', ''))
|
||||||
|
}, [removeRecent])
|
||||||
|
|
||||||
return <Content>
|
return <Content>
|
||||||
<div style={{ marginBlock: 100 }}>
|
<div style={{ marginBlock: 100 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Trans } from 'react-i18next/TransWithoutContext'
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import { Metadata } from 'next'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
|
@ -11,7 +12,21 @@ import { makeClass, relativeTimeFormat } from '/src/utils'
|
||||||
import EventAvailabilities from './EventAvailabilities'
|
import EventAvailabilities from './EventAvailabilities'
|
||||||
import styles from './page.module.scss'
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
const Page = async ({ params }: { params: { id: string } }) => {
|
interface PageProps {
|
||||||
|
params: { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params }: PageProps): Promise<Metadata> => {
|
||||||
|
const event = await getEvent(params.id).catch(() => undefined)
|
||||||
|
const { t } = await useTranslation('event')
|
||||||
|
|
||||||
|
// TODO: More metadata
|
||||||
|
return {
|
||||||
|
title: event?.name ?? t('error.title'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async ({ params }: PageProps) => {
|
||||||
const event = await getEvent(params.id).catch(() => undefined)
|
const event = await getEvent(params.id).catch(() => undefined)
|
||||||
const people = await getPeople(params.id).catch(() => undefined)
|
const people = await getPeople(params.id).catch(() => undefined)
|
||||||
if (!event || !people) notFound()
|
if (!event || !people) notFound()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
`};
|
|
||||||
`
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -97,6 +97,7 @@
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background-origin: border-box;
|
background-origin: border-box;
|
||||||
transition: background-color .1s;
|
transition: background-color .1s;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
border-top-width: 2px;
|
border-top-width: 2px;
|
||||||
border-top-style: solid;
|
border-top-style: solid;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slim {
|
.slim {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const Copyable = ({ children, className, ...props }: CopyableProps) => {
|
||||||
})
|
})
|
||||||
.catch(e => console.error('Failed to copy', e))
|
.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)}
|
className={makeClass(className, 'clipboard' in navigator && styles.copyable)}
|
||||||
{...props}
|
{...props}
|
||||||
>{copied ?? children}</p>
|
>{copied ?? children}</p>
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ export * from './calculateColumns'
|
||||||
export * from './getWeekdayNames'
|
export * from './getWeekdayNames'
|
||||||
export * from './relativeTimeFormat'
|
export * from './relativeTimeFormat'
|
||||||
export * from './expandTimes'
|
export * from './expandTimes'
|
||||||
|
export * from './serializeTime'
|
||||||
|
|
|
||||||
16
frontend/src/utils/serializeTime.ts
Normal file
16
frontend/src/utils/serializeTime.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a ZonedDateTime in any timezone, and serializes it in UTC
|
||||||
|
* @param isSpecificDates Whether to format at `HHmm-DDMMYYYY` or `HHmm-d`
|
||||||
|
* @returns Time serialized to UTC
|
||||||
|
*/
|
||||||
|
export const serializeTime = (time: Temporal.ZonedDateTime, isSpecificDates: boolean) => {
|
||||||
|
const t = time.withTimeZone('UTC')
|
||||||
|
const [hour, minute, day, month] = [t.hour, t.minute, t.day, t.month].map(x => x.toString().padStart(2, '0'))
|
||||||
|
const [year, dayOfWeek] = [t.year.toString().padStart(4, '0'), (t.dayOfWeek === 7 ? 0 : t.dayOfWeek).toString()]
|
||||||
|
|
||||||
|
return isSpecificDates
|
||||||
|
? `${hour}${minute}-${day}${month}${year}`
|
||||||
|
: `${hour}${minute}-${dayOfWeek}`
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue