Migrate extension Create page to Nextjs

This commit is contained in:
Ben Grant 2023-05-28 19:24:35 +10:00
parent d2bee83db4
commit 2d9b1d7959
24 changed files with 214 additions and 391 deletions

View file

@ -20,7 +20,7 @@ interface MonthProps {
const Month = ({ value, onChange }: MonthProps) => {
const { t, i18n } = useTranslation('home')
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 1
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
const [page, setPage] = useState<Temporal.PlainYearMonth>(Temporal.Now.plainDateISO().toPlainYearMonth())
const dates = useMemo(() => calculateMonth(page, weekStart), [page, weekStart])
@ -61,7 +61,7 @@ const Month = ({ value, onChange }: MonthProps) => {
</div>
<div className={styles.dayLabels}>
{(rotateArray(getWeekdayNames(i18n.language, 'short'), weekStart)).map(name =>
{(rotateArray(getWeekdayNames(i18n.language, 'short'), weekStart ? 0 : 1)).map(name =>
<label key={name}>{name}</label>
)}
</div>
@ -119,8 +119,8 @@ export default Month
/** Calculate the dates to show for the month in a 2d array */
const calculateMonth = (month: Temporal.PlainYearMonth, weekStart: 0 | 1) => {
const daysBefore = month.toPlainDate({ day: 1 }).dayOfWeek - (weekStart ? 0 : 1)
const daysAfter = 6 - month.toPlainDate({ day: month.daysInMonth }).dayOfWeek + (weekStart ? 0 : 1)
const daysBefore = month.toPlainDate({ day: 1 }).dayOfWeek - weekStart
const daysAfter = 6 - month.toPlainDate({ day: month.daysInMonth }).dayOfWeek + weekStart
const dates: Temporal.PlainDate[][] = []
let curDate = month.toPlainDate({ day: 1 }).subtract({ days: daysBefore })

View file

@ -19,9 +19,9 @@ interface WeekdaysProps {
const Weekdays = ({ value, onChange }: WeekdaysProps) => {
const { t, i18n } = useTranslation('home')
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 1
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
const weekdays = useMemo(() => rotateArray(range(1, 7).map(i => Temporal.Now.plainDateISO().add({ days: i - Temporal.Now.plainDateISO().dayOfWeek })), weekStart), [weekStart])
const weekdays = useMemo(() => rotateArray(range(1, 7).map(i => Temporal.Now.plainDateISO().add({ days: i - Temporal.Now.plainDateISO().dayOfWeek })), weekStart ? 0 : 1), [weekStart])
// Ref and state required to rerender but also access static version in callbacks
const selectingRef = useRef<string[]>([])

View file

@ -9,3 +9,8 @@
align-items: center;
justify-content: center;
}
.slim {
margin-block: 10px;
max-width: calc(100% - 30px);
}

View file

@ -5,9 +5,17 @@ import styles from './Content.module.scss'
interface ContentProps {
children: React.ReactNode
isCentered?: boolean
isSlim?: boolean
}
const Content = ({ isCentered, ...props }: ContentProps) =>
<div className={makeClass(styles.content, isCentered && styles.centered)} {...props} />
const Content = ({ isCentered, isSlim, ...props }: ContentProps) =>
<div
className={makeClass(
styles.content,
isCentered && styles.centered,
isSlim && styles.slim,
)}
{...props}
/>
export default Content

View file

@ -1,7 +1,3 @@
.form {
margin: 0 0 60px;
}
.buttonWrapper {
display: flex;
justify-content: center;

View file

@ -3,6 +3,7 @@
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import { range } from '@giraugh/tools'
import { Temporal } from '@js-temporal/polyfill'
import Button from '/src/components/Button/Button'
@ -11,14 +12,17 @@ import { default as ErrorAlert } from '/src/components/Error/Error'
import SelectField from '/src/components/SelectField/SelectField'
import TextField from '/src/components/TextField/TextField'
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
import { API_BASE } from '/src/config/api'
import { API_BASE, EventResponse } from '/src/config/api'
import { useTranslation } from '/src/i18n/client'
import timezones from '/src/res/timezones.json'
import useRecentsStore from '/src/stores/recentsStore'
import EventInfo from './components/EventInfo/EventInfo'
import styles from './CreateForm.module.scss'
interface Fields {
name: string
/** As `YYYY-MM-DD` or `d` */
dates: string[]
time: {
start: number
@ -34,10 +38,12 @@ const defaultValues: Fields = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
const CreateForm = () => {
const CreateForm = ({ noRedirect }: { noRedirect?: boolean }) => {
const { t } = useTranslation('home')
const { push } = useRouter()
const addRecent = useRecentsStore(state => state.addRecent)
const {
register,
handleSubmit,
@ -45,6 +51,7 @@ const CreateForm = () => {
} = useForm({ defaultValues })
const [isLoading, setIsLoading] = useState(false)
const [createdEvent, setCreatedEvent] = useState<EventResponse>()
const [error, setError] = useState<React.ReactNode>()
const onSubmit: SubmitHandler<Fields> = async values => {
@ -64,36 +71,24 @@ const CreateForm = () => {
// If format is `YYYY-MM-DD` or `d`
const isSpecificDates = dates[0].length !== 1
const times = dates.reduce((times, dateStr) => {
const day = []
const times = dates.flatMap(dateStr => {
const date = isSpecificDates
? Temporal.PlainDate.from(dateStr)
: Temporal.Now.plainDateISO().add({ days: Number(dateStr) - Temporal.Now.plainDateISO().dayOfWeek })
for (let i = time.start; i < (time.start > time.end ? 24 : time.end); i++) {
const dateTime = date.toZonedDateTime({ timeZone: timezone, plainTime: Temporal.PlainTime.from({ hour: i }) }).withTimeZone('UTC')
const hours = time.start > time.end ? [...range(0, time.end - 1), ...range(time.start, 23)] : range(time.start, time.end - 1)
return hours.map(hour => {
const dateTime = date.toZonedDateTime({ timeZone: timezone, plainTime: Temporal.PlainTime.from({ hour }) }).withTimeZone('UTC')
if (isSpecificDates) {
// Format as `HHmm-DDMMYYYY`
day.push(`${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${dateTime.day.toString().padStart(2, '0')}${dateTime.month.toString().padStart(2, '0')}${dateTime.year.toString().padStart(4, '0')}`)
return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${dateTime.day.toString().padStart(2, '0')}${dateTime.month.toString().padStart(2, '0')}${dateTime.year.toString().padStart(4, '0')}`
} else {
// Format as `HHmm-d`
day.push(`${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${String(dateTime.dayOfWeek === 7 ? 0 : dateTime.dayOfWeek)}`)
return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${String(dateTime.dayOfWeek === 7 ? 0 : dateTime.dayOfWeek)}`
}
}
if (time.start > time.end) {
for (let i = 0; i < time.end; i++) {
const dateTime = date.toZonedDateTime({ timeZone: timezone, plainTime: Temporal.PlainTime.from({ hour: i }) }).withTimeZone('UTC')
if (isSpecificDates) {
// Format as `HHmm-DDMMYYYY`
day.push(`${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${dateTime.day.toString().padStart(2, '0')}${dateTime.month.toString().padStart(2, '0')}${dateTime.year.toString().padStart(4, '0')}`)
} else {
// Format as `HHmm-d`
day.push(`${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${String(dateTime.dayOfWeek === 7 ? 0 : dateTime.dayOfWeek)}`)
}
}
}
return [...times, ...day]
}, [] as string[])
})
})
if (times.length === 0) {
return setError(t('form.errors.no_time'))
@ -114,10 +109,20 @@ const CreateForm = () => {
throw new Error('Failed to create event')
}
const { id } = await res.json()
const newEvent = EventResponse.parse(await res.json())
// Navigate to the new event
push(`/${id}`)
if (noRedirect) {
// Show event link
setCreatedEvent(newEvent)
addRecent({
id: newEvent.id,
name: newEvent.name,
created_at: newEvent.created_at,
})
} else {
// Navigate to the new event
push(`/${newEvent.id}`)
}
} catch (e) {
setError(t('form.errors.unknown'))
console.error(e)
@ -126,7 +131,11 @@ const CreateForm = () => {
}
}
return <form className={styles.form} onSubmit={handleSubmit(onSubmit)} id="create">
return createdEvent ? <EventInfo event={createdEvent} /> : <form
style={{ marginBlockEnd: noRedirect ? 30 : 60 }}
onSubmit={handleSubmit(onSubmit)}
id="create"
>
<TextField
label={t('form.name.label')}
description={t('form.name.sublabel')}
@ -163,6 +172,7 @@ const CreateForm = () => {
type="submit"
isLoading={isLoading}
disabled={isLoading}
style={noRedirect ? { width: '100%' } : undefined}
>{t('form.button')}</Button>
</div>
</form>

View file

@ -0,0 +1,19 @@
.wrapper {
text-align: center;
margin: 50px 0 20px;
}
.info {
margin: 6px 0;
text-align: center;
font-size: 15px;
padding: 10px 0;
}
.copyable {
cursor: pointer;
&:hover {
color: var(--secondary);
}
}

View file

@ -0,0 +1,38 @@
import { useState } from 'react'
import { Trans } from 'react-i18next/TransWithoutContext'
import { EventResponse } from '/src/config/api'
import { useTranslation } from '/src/i18n/client'
import { makeClass } from '/src/utils'
import styles from './EventInfo.module.scss'
interface EventInfoProps {
event: EventResponse
}
const EventInfo = ({ event }: EventInfoProps) => {
const { t, i18n } = useTranslation('event')
const [copied, setCopied] = useState<React.ReactNode>()
return <div className={styles.wrapper}>
<h2>{event.name}</h2>
<p
className={makeClass(styles.info, styles.copyable)}
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${event.id}`)
.then(() => {
setCopied(t('nav.copied'))
setTimeout(() => setCopied(undefined), 1000)
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t<string>('nav.title') : undefined}
>{copied ?? `https://crab.fit/${event.id}`}</p>
<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>
</p>
</div>
}
export default EventInfo

View file

@ -77,6 +77,10 @@
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
[data-small=true] & {
font-size: 2rem;
}
}
.hasAltChars {
@ -101,6 +105,13 @@
@media (max-width: 350px) {
font-size: 3.5rem;
}
[data-small=true] & {
font-size: 2rem;
@media (max-width: 350px) {
font-size: 2rem;
}
}
}
.bigLogo {

View file

@ -9,14 +9,15 @@ import styles from './Header.module.scss'
interface HeaderProps {
/** Show the full header */
isFull?: boolean
isSmall?: boolean
}
const Header = async ({ isFull }: HeaderProps) => {
const Header = async ({ isFull, isSmall }: HeaderProps) => {
const { t } = await useTranslation(['common', 'home'])
return <header className={styles.header}>
return <header className={styles.header} data-small={isSmall}>
{isFull ? <>
<img className={styles.bigLogo} src={logo.src} alt="" />
{!isSmall && <img className={styles.bigLogo} src={logo.src} alt="" />}
<span className={makeClass(styles.subtitle, !/^[A-Za-z ]+$/.test(t('home:create')) && styles.hasAltChars)}>{t('home:create')}</span>
<h1 className={styles.bigTitle}>CRAB FIT</h1>
</> : <Link href="/" className={styles.link}>

View file

@ -12,11 +12,7 @@ import { relativeTimeFormat } from '/src/utils'
import styles from './Recents.module.scss'
interface RecentsProps {
target?: React.ComponentProps<'a'>['target']
}
const Recents = ({ target }: RecentsProps) => {
const Recents = () => {
const recents = useStore(useRecentsStore, state => state.recents)
const { t, i18n } = useTranslation(['home', 'common'])
@ -24,7 +20,7 @@ const Recents = ({ target }: RecentsProps) => {
<Content>
<h2>{t('home:recently_visited')}</h2>
{recents.map(event => (
<Link className={styles.recent} href={`/${event.id}`} target={target} key={event.id}>
<Link className={styles.recent} href={`/${event.id}`} key={event.id}>
<span className={styles.name}>{event.name}</span>
<span
className={styles.date}

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { isKeyOfObject } from '@giraugh/tools'
import { maps } from 'hue-map'
import { MapKey } from 'hue-map/dist/maps'
import { Settings as SettingsIcon } from 'lucide-react'
@ -65,8 +66,8 @@ const Settings = () => {
'Sunday': t('options.weekStart.options.Sunday'),
'Monday': t('options.weekStart.options.Monday'),
}}
value={store?.weekStart === 1 ? 'Sunday' : 'Monday'}
onChange={value => store?.setWeekStart(value === 'Sunday' ? 1 : 0)}
value={store.weekStart === 0 ? 'Sunday' : 'Monday'}
onChange={value => store.setWeekStart(value === 'Sunday' ? 0 : 1)}
/>
<ToggleField
@ -76,8 +77,8 @@ const Settings = () => {
'12h': t('options.timeFormat.options.12h'),
'24h': t('options.timeFormat.options.24h'),
}}
value={store?.timeFormat ?? '12h'}
onChange={value => store?.setTimeFormat(value)}
value={store.timeFormat ?? '12h'}
onChange={value => store.setTimeFormat(value)}
/>
<ToggleField
@ -88,8 +89,8 @@ const Settings = () => {
'Light': t('options.theme.options.Light'),
'Dark': t('options.theme.options.Dark'),
}}
value={store?.theme ?? 'System'}
onChange={value => store?.setTheme(value)}
value={store.theme ?? 'System'}
onChange={value => store.setTheme(value)}
/>
<SelectField
@ -103,8 +104,8 @@ const Settings = () => {
])),
}}
isSmall
value={store?.colormap}
onChange={event => store?.setColormap(event.target.value as MapKey)}
value={store.colormap}
onChange={event => store.setColormap(event.target.value as MapKey)}
/>
<ToggleField
@ -115,8 +116,8 @@ const Settings = () => {
'Off': t('options.highlight.options.Off'),
'On': t('options.highlight.options.On'),
}}
value={store?.highlight ? 'On' : 'Off'}
onChange={value => store?.setHighlight(value === 'On')}
value={store.highlight ? 'On' : 'Off'}
onChange={value => store.setHighlight(value === 'On')}
/>
<SelectField
@ -129,7 +130,15 @@ const Settings = () => {
}}
isSmall
value={i18n.language}
onChange={e => i18n.changeLanguage(e.target.value).then(() => router.refresh())}
onChange={e => {
// Set language defaults
if (isKeyOfObject(e.target.value, languageDetails)) {
store.setTimeFormat(languageDetails[e.target.value].timeFormat)
store.setWeekStart(languageDetails[e.target.value].weekStart)
}
i18n.changeLanguage(e.target.value).then(() => router.refresh())
}}
/>
</>}
</div>