Wrap event page parts in suspense

This commit is contained in:
Benji Grant 2023-06-18 13:55:44 +10:00
parent 1855188ea1
commit 8d9a8cf868
19 changed files with 103 additions and 71 deletions

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Trans } from 'react-i18next/TransWithoutContext' import { Trans } from 'react-i18next/TransWithoutContext'
import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor' import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor'
@ -20,17 +20,16 @@ import { calculateTable, expandTimes, makeClass } from '/src/utils'
import styles from './page.module.scss' import styles from './page.module.scss'
interface EventAvailabilitiesProps { interface EventAvailabilitiesProps {
event: EventResponse event?: EventResponse
people: PersonResponse[]
} }
const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { const EventAvailabilities = ({ event }: EventAvailabilitiesProps) => {
const { t, i18n } = useTranslation('event') const { t, i18n } = useTranslation('event')
const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h' const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
const [people, setPeople] = useState(data.people) const [people, setPeople] = useState<PersonResponse[]>([])
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>()
const [password, setPassword] = useState<string>() const [password, setPassword] = useState<string>()
@ -39,40 +38,40 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone) const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
// Web worker for calculating the heatmap table // Web worker for calculating the heatmap table
const tableWorker = useMemo(() => (typeof window !== undefined && window.Worker) ? new Worker(new URL('/src/workers/calculateTable', import.meta.url)) : undefined, []) const tableWorker = useRef<Worker>()
// Calculate table (using a web worker if available) // Calculate table (using a web worker if available)
const [table, setTable] = useState<ReturnType<typeof calculateTable>>() const [table, setTable] = useState<ReturnType<typeof calculateTable>>()
useEffect(() => { useEffect(() => {
if (!tableWorker.current) {
tableWorker.current = window.Worker ? new Worker(new URL('/src/workers/calculateTable', import.meta.url)) : undefined
}
const args = { times: expandedTimes, locale: i18n.language, timeFormat, timezone } const args = { times: expandedTimes, locale: i18n.language, timeFormat, timezone }
if (tableWorker) { if (tableWorker.current) {
tableWorker.postMessage(args) tableWorker.current.onmessage = (e: MessageEvent<ReturnType<typeof calculateTable>>) => setTable(e.data)
tableWorker.current.postMessage(args)
setTable(undefined) setTable(undefined)
} else { } else {
setTable(calculateTable(args)) setTable(calculateTable(args))
} }
}, [tableWorker, expandedTimes, i18n.language, timeFormat, timezone]) }, [expandedTimes, i18n.language, timeFormat, timezone])
useEffect(() => {
if (tableWorker) {
tableWorker.onmessage = (e: MessageEvent<ReturnType<typeof calculateTable>>) => setTable(e.data)
}
}, [tableWorker])
// Add this event to recents // Add this event to recents
const addRecent = useRecentsStore(state => state.addRecent) const addRecent = useRecentsStore(state => state.addRecent)
useEffect(() => { useEffect(() => {
if (event) {
addRecent({ addRecent({
id: event.id, id: event.id,
name: event.name, name: event.name,
created_at: event.created_at, created_at: event.created_at,
}) })
}
}, [addRecent]) }, [addRecent])
// Refetch availabilities // Refetch availabilities
useEffect(() => { useEffect(() => {
if (tab === 'group') { if (tab === 'group' && event) {
getPeople(event.id) getPeople(event.id)
.then(setPeople) .then(setPeople)
.catch(console.warn) .catch(console.warn)
@ -82,7 +81,7 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
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') setTab(u ? 'you' : 'group')
@ -148,7 +147,7 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
document.dispatchEvent(new CustomEvent('focusName')) document.dispatchEvent(new CustomEvent('focusName'))
} }
}} }}
title={user ? '' : t<string>('tabs.you_tooltip')} title={user ? '' : t('tabs.you_tooltip')}
>{t('tabs.you')}</button> >{t('tabs.you')}</button>
<button <button
className={makeClass( className={makeClass(
@ -170,6 +169,7 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
timezone={timezone} timezone={timezone}
value={user.availability} value={user.availability}
onChange={availability => { onChange={availability => {
if (!event) return
const oldAvailability = [...user.availability] const oldAvailability = [...user.availability]
setUser({ ...user, availability }) setUser({ ...user, availability })
updatePerson(event.id, user.name, { availability }, password) updatePerson(event.id, user.name, { availability }, password)

View file

@ -69,3 +69,12 @@
opacity: .5; opacity: .5;
cursor: not-allowed; cursor: not-allowed;
} }
.bone {
width: 12em;
max-width: 100%;
background-color: var(--loading);
border-radius: 3px;
height: 1em;
display: inline-block;
}

View file

@ -1,3 +1,4 @@
import { Suspense } from 'react'
import { Trans } from 'react-i18next/TransWithoutContext' import { Trans } from 'react-i18next/TransWithoutContext'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
@ -5,7 +6,7 @@ import { Temporal } from '@js-temporal/polyfill'
import Content from '/src/components/Content/Content' import Content from '/src/components/Content/Content'
import Copyable from '/src/components/Copyable/Copyable' import Copyable from '/src/components/Copyable/Copyable'
import { getEvent, getPeople } from '/src/config/api' import { EventResponse, getEvent } from '/src/config/api'
import { useTranslation } from '/src/i18n/server' import { useTranslation } from '/src/i18n/server'
import { makeClass, relativeTimeFormat } from '/src/utils' import { makeClass, relativeTimeFormat } from '/src/utils'
@ -26,14 +27,31 @@ export const generateMetadata = async ({ params }: PageProps): Promise<Metadata>
} }
const Page = async ({ params }: PageProps) => { const Page = async ({ params }: PageProps) => {
const event = await getEvent(params.id).catch(() => undefined) const event = getEvent(params.id)
const people = await getPeople(params.id).catch(() => undefined)
if (!event || !people) notFound() return <>
<Suspense
fallback={<Content>
<h1 className={styles.name}><span className={styles.bone} /></h1>
<div className={styles.date}><span className={styles.bone} /></div>
<div className={styles.info}><span className={styles.bone} style={{ width: '20em' }} /></div>
<div className={styles.info}><span className={styles.bone} style={{ width: '20em' }} /></div>
</Content>}
>
<EventMeta event={event} />
</Suspense>
<EventAvailabilities event={await event.catch(() => undefined)} />
</>
}
const EventMeta = async (props: { event: Promise<EventResponse> }) => {
const event = await props.event.catch(() => undefined)
if (!event) notFound()
const { t, i18n } = await useTranslation(['common', 'event']) const { t, i18n } = await useTranslation(['common', 'event'])
return <> return <Content>
<Content>
<h1 className={styles.name}>{event.name}</h1> <h1 className={styles.name}>{event.name}</h1>
<span <span
className={styles.date} className={styles.date}
@ -44,12 +62,9 @@ const Page = async ({ params }: PageProps) => {
{`https://crab.fit/${event.id}`} {`https://crab.fit/${event.id}`}
</Copyable> </Copyable>
<p className={makeClass(styles.info, styles.noPrint)}> <p className={makeClass(styles.info, styles.noPrint)}>
<Trans i18nKey="event:nav.shareinfo" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t<string>('event:nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${event.id}`)}`}>_</a>_</Trans> <Trans i18nKey="event:nav.shareinfo" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${event.id}`)}`}>_</a>_</Trans>
</p> </p>
</Content> </Content>
<EventAvailabilities event={event} people={people} />
</>
} }
export default Page export default Page

View file

@ -65,7 +65,7 @@ const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: Av
<Button isSmall onClick={selectInvert} title="Ctrl + I (⌘ I)">{t('you.select_invert')}</Button> <Button isSmall onClick={selectInvert} title="Ctrl + I (⌘ I)">{t('you.select_invert')}</Button>
</div> </div>
</Content> </Content>
{times[0].length === 13 && <Content> {times[0]?.length === 13 && <Content>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<GoogleCalendar <GoogleCalendar
timezone={timezone} timezone={timezone}
@ -106,7 +106,7 @@ const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: Av
if (!cell) return <div if (!cell) return <div
className={makeClass(viewerStyles.timeSpace, viewerStyles.grey)} className={makeClass(viewerStyles.timeSpace, viewerStyles.grey)}
key={y} key={y}
title={t<string>('greyed_times')} title={t('greyed_times')}
/> />
const isSelected = ( const isSelected = (
@ -162,7 +162,7 @@ const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: Av
})} })}
</div> </div>
</div> : <div className={viewerStyles.columnSpacer} />} </div> : <div className={viewerStyles.columnSpacer} />}
</Fragment>) ?? <Skeleton isSpecificDates={times[0].length === 13} />} </Fragment>) ?? <Skeleton isSpecificDates={times[0]?.length === 13} />}
</div> </div>
</div> </div>
</div> </div>

View file

@ -70,7 +70,7 @@ const AvailabilityViewer = ({ times, people, table }: AvailabilityViewerProps) =
if (!cell) return <div if (!cell) return <div
className={makeClass(styles.timeSpace, styles.grey)} className={makeClass(styles.timeSpace, styles.grey)}
key={y} key={y}
title={t<string>('greyed_times')} title={t('greyed_times')}
/> />
let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? [] let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
@ -106,7 +106,7 @@ const AvailabilityViewer = ({ times, people, table }: AvailabilityViewerProps) =
})} })}
</div> </div>
</div> : <div className={styles.columnSpacer} />} </div> : <div className={styles.columnSpacer} />}
</Fragment>) ?? <Skeleton isSpecificDates={times[0].length === 13} />, [ </Fragment>) ?? <Skeleton isSpecificDates={times[0]?.length === 13} />, [
availabilities, availabilities,
table?.columns, table?.columns,
highlight, highlight,

View file

@ -55,13 +55,13 @@ const Month = ({ value, onChange }: MonthProps) => {
return <> return <>
{useMemo(() => <div className={styles.header}> {useMemo(() => <div className={styles.header}>
<Button <Button
title={t<string>('form.dates.tooltips.previous')} title={t('form.dates.tooltips.previous')}
onClick={() => setPage(page.subtract({ months: 1 }))} onClick={() => setPage(page.subtract({ months: 1 }))}
icon={<ChevronLeft />} icon={<ChevronLeft />}
/> />
<span>{page.toPlainDate({ day: 1 }).toLocaleString(i18n.language, { month: 'long', year: 'numeric' })}</span> <span>{page.toPlainDate({ day: 1 }).toLocaleString(i18n.language, { month: 'long', year: 'numeric' })}</span>
<Button <Button
title={t<string>('form.dates.tooltips.next')} title={t('form.dates.tooltips.next')}
onClick={() => setPage(page.add({ months: 1 }))} onClick={() => setPage(page.add({ months: 1 }))}
icon={<ChevronRight />} icon={<ChevronRight />}
/> />

View file

@ -56,7 +56,7 @@ const Weekdays = ({ value, onChange }: WeekdaysProps) => {
) && styles.selected, ) && styles.selected,
)} )}
key={day.toString()} key={day.toString()}
title={day.equals(Temporal.Now.plainDateISO()) ? t<string>('form.dates.tooltips.today') : undefined} title={day.equals(Temporal.Now.plainDateISO()) ? t('form.dates.tooltips.today') : undefined}
onKeyDown={e => { onKeyDown={e => {
if (e.key === ' ' || e.key === 'Enter') { if (e.key === ' ' || e.key === 'Enter') {
if (value.includes(day.dayOfWeek.toString())) { if (value.includes(day.dayOfWeek.toString())) {

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from '/src/i18n/client' import { useTranslation } from '/src/i18n/client'
import { makeClass } from '/src/utils' import { makeClass } from '/src/utils'
@ -16,6 +16,9 @@ const Copyable = ({ children, className, ...props }: CopyableProps) => {
const [copied, setCopied] = useState<React.ReactNode>() const [copied, setCopied] = useState<React.ReactNode>()
const [canCopy, setCanCopy] = useState(false)
useEffect(() => { setCanCopy('clipboard' in navigator) }, [])
return <p return <p
onClick={() => navigator.clipboard?.writeText(children) onClick={() => navigator.clipboard?.writeText(children)
.then(() => { .then(() => {
@ -24,8 +27,8 @@ const Copyable = ({ children, className, ...props }: CopyableProps) => {
}) })
.catch(e => console.error('Failed to copy', e)) .catch(e => console.error('Failed to copy', e))
} }
title={'clipboard' in navigator ? t<string>('nav.title') : undefined} title={canCopy ? t('nav.title') : undefined}
className={makeClass(className, 'clipboard' in navigator && styles.copyable)} className={makeClass(className, canCopy && styles.copyable)}
{...props} {...props}
>{copied ?? children}</p> >{copied ?? children}</p>
} }

View file

@ -19,7 +19,7 @@ const EventInfo = ({ event }: EventInfoProps) => {
{`https://crab.fit/${event.id}`} {`https://crab.fit/${event.id}`}
</Copyable> </Copyable>
<p className={styles.info}> <p className={styles.info}>
<Trans i18nKey="event:nav.shareinfo_alt" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t<string>('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} https://crab.fit/${event.id}`)}`} target="_blank">_</a>_</Trans> <Trans i18nKey="event:nav.shareinfo_alt" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} https://crab.fit/${event.id}`)}`} target="_blank">_</a>_</Trans>
</p> </p>
</div> </div>
} }

View file

@ -39,4 +39,10 @@
justify-content: center; justify-content: center;
margin-left: 16px; margin-left: 16px;
padding: 0; padding: 0;
border-radius: .2em;
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
} }

View file

@ -23,7 +23,7 @@ const Footer = async ({ isSmall }: FooterProps) => {
<span>{t('donate.info')}</span> <span>{t('donate.info')}</span>
<Button <Button
isSmall isSmall
title={t<string>('donate.title')} title={t('donate.title')}
href="https://ko-fi.com/A06841WZ" href="https://ko-fi.com/A06841WZ"
target="_blank" target="_blank"
rel="noreferrer noopener payment" rel="noreferrer noopener payment"

View file

@ -24,7 +24,7 @@ const Legend = ({ min, max, total, palette, onSegmentFocus }: LegendProps) => {
className={styles.bar} className={styles.bar}
onMouseOut={() => onSegmentFocus(undefined)} onMouseOut={() => onSegmentFocus(undefined)}
onClick={() => setHighlight?.(!highlight)} onClick={() => setHighlight?.(!highlight)}
title={t<string>('group.legend_tooltip')} title={t('group.legend_tooltip')}
> >
{[...Array(max + 1 - min).keys()].map(i => i + min).map((i, j) => {[...Array(max + 1 - min).keys()].map(i => i + min).map((i, j) =>
<div <div

View file

@ -5,13 +5,10 @@
grid-gap: 18px; grid-gap: 18px;
@media (max-width: 500px) { @media (max-width: 500px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 400px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
& div:last-child { & div:last-child {
--btn-width: 100%; width: 100%;
} }
} }
} }

View file

@ -15,7 +15,7 @@ const defaultValues = {
} }
interface LoginProps { interface LoginProps {
eventId: string eventId?: string
user: PersonResponse | undefined user: PersonResponse | undefined
onChange: (user: PersonResponse | undefined, password?: string) => void onChange: (user: PersonResponse | undefined, password?: string) => void
} }
@ -50,6 +50,8 @@ const Login = ({ eventId, user, onChange }: LoginProps) => {
setError(undefined) setError(undefined)
try { try {
if (!eventId) throw 'Event ID not set'
const resUser = await getPerson(eventId, username, password || undefined) const resUser = await getPerson(eventId, username, password || undefined)
onChange(resUser, password || undefined) onChange(resUser, password || undefined)
reset() reset()

View file

@ -56,7 +56,7 @@ const Settings = () => {
type="button" type="button"
className={makeClass(styles.openButton, isOpen && styles.open)} className={makeClass(styles.openButton, isOpen && styles.open)}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
title={t<string>('options.name')} title={t('options.name')}
><SettingsIcon /></button> ><SettingsIcon /></button>
<dialog <dialog

View file

@ -17,7 +17,7 @@ const Video = () => {
width="560" width="560"
height="315" height="315"
src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1"
title={t<string>('video.title')} title={t('video.title')}
frameBorder="0" frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
@ -34,7 +34,7 @@ const Video = () => {
setIsPlaying(true) setIsPlaying(true)
}} }}
> >
<img src={video_thumb.src} alt={t<string>('video.button')} /> <img src={video_thumb.src} alt={t('video.button')} />
<span>{t('video.button')}</span> <span>{t('video.button')}</span>
</a> </a>
) )

View file

@ -23,8 +23,8 @@ interface AvailabilityInfo {
* group availability for each date passed in. * group availability for each date passed in.
*/ */
export const calculateAvailability = (dates: string[], people: Person[]): AvailabilityInfo => { export const calculateAvailability = (dates: string[], people: Person[]): AvailabilityInfo => {
let min = Infinity let min = 0
let max = -Infinity let max = people.length
const availabilities: Availability[] = dates.map(date => { const availabilities: Availability[] = dates.map(date => {
const names = people.flatMap(p => p.availability.some(d => d === date) ? [p.name] : []) const names = people.flatMap(p => p.availability.some(d => d === date) ? [p.name] : [])

View file

@ -26,7 +26,7 @@ export const calculateTable = ({
const columns = calculateColumns(dates) const columns = calculateColumns(dates)
// Is specific dates or just days of the week // Is specific dates or just days of the week
const isSpecificDates = times[0].length === 13 const isSpecificDates = times[0]?.length === 13
return { return {
rows: rows.map(row => row && row.minute === 0 ? { rows: rows.map(row => row && row.minute === 0 ? {

View file

@ -6,7 +6,7 @@ import { Temporal } from '@js-temporal/polyfill'
* @param timezone The target timezone * @param timezone The target timezone
*/ */
export const convertTimesToDates = (times: string[], timezone: string): Temporal.ZonedDateTime[] => { export const convertTimesToDates = (times: string[], timezone: string): Temporal.ZonedDateTime[] => {
const isSpecificDates = times[0].length === 13 const isSpecificDates = times[0]?.length === 13
return times.map(time => isSpecificDates ? return times.map(time => isSpecificDates ?
parseSpecificDate(time).withTimeZone(timezone) parseSpecificDate(time).withTimeZone(timezone)