Migrate extension Create page to Nextjs
This commit is contained in:
parent
d2bee83db4
commit
2d9b1d7959
|
|
@ -16,7 +16,6 @@
|
|||
"@microsoft/microsoft-graph-client": "^3.0.5",
|
||||
"accept-language": "^3.0.18",
|
||||
"gapi-script": "^1.2.0",
|
||||
"goober": "^2.1.13",
|
||||
"hue-map": "^1.0.0",
|
||||
"i18next": "^22.5.0",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
|
|
|
|||
19
frontend/src/app/create/Redirect.tsx
Normal file
19
frontend/src/app/create/Redirect.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/** Check if the current page is running in an iframe, otherwise redirect home */
|
||||
const Redirect = () => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
router.replace('/')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default Redirect
|
||||
29
frontend/src/app/create/page.tsx
Normal file
29
frontend/src/app/create/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import Content from '/src/components/Content/Content'
|
||||
import CreateForm from '/src/components/CreateForm/CreateForm'
|
||||
import Header from '/src/components/Header/Header'
|
||||
|
||||
import Redirect from './Redirect'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create a Crab Fit',
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in the Crab Fit browser extension, to be rendered only in an iframe
|
||||
*/
|
||||
const Page = async () => <>
|
||||
<Content isSlim>
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<Header isFull isSmall />
|
||||
</Content>
|
||||
|
||||
<Content isSlim>
|
||||
<CreateForm noRedirect />
|
||||
</Content>
|
||||
|
||||
<Redirect />
|
||||
</>
|
||||
|
||||
export default Page
|
||||
|
|
@ -6,6 +6,7 @@ import { range, rotateArray } from '@giraugh/tools'
|
|||
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||
import Button from '/src/components/Button/Button'
|
||||
import Content from '/src/components/Content/Content'
|
||||
import Footer from '/src/components/Footer/Footer'
|
||||
import Header from '/src/components/Header/Header'
|
||||
import { P } from '/src/components/Paragraph/Text'
|
||||
import Section from '/src/components/Section/Section'
|
||||
|
|
@ -80,6 +81,9 @@ const Page = async () => {
|
|||
<Button href="/">{t('common:cta')}</Button>
|
||||
</Content>
|
||||
</Section>
|
||||
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<Footer />
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import Egg from '/src/components/Egg/Egg'
|
||||
import Footer from '/src/components/Footer/Footer'
|
||||
import Settings from '/src/components/Settings/Settings'
|
||||
import TranslateDialog from '/src/components/TranslateDialog/TranslateDialog'
|
||||
import { fallbackLng } from '/src/i18n/options'
|
||||
|
|
@ -40,9 +39,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||
<TranslateDialog />
|
||||
|
||||
{children}
|
||||
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import Content from '/src/components/Content/Content'
|
||||
import CreateForm from '/src/components/CreateForm/CreateForm'
|
||||
import DownloadButtons from '/src/components/DownloadButtons/DownloadButtons'
|
||||
import Footer from '/src/components/Footer/Footer'
|
||||
import Header from '/src/components/Header/Header'
|
||||
import { P } from '/src/components/Paragraph/Text'
|
||||
import Recents from '/src/components/Recents/Recents'
|
||||
|
|
@ -46,6 +47,9 @@ const Page = async () => {
|
|||
<P>{t('about.content.p5')}</P>
|
||||
</Content>
|
||||
</Section>
|
||||
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<Footer />
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Metadata } from 'next'
|
|||
import GoogleTranslate from '/src/app/privacy/GoogleTranslate'
|
||||
import Button from '/src/components/Button/Button'
|
||||
import Content from '/src/components/Content/Content'
|
||||
import Footer from '/src/components/Footer/Footer'
|
||||
import Header from '/src/components/Header/Header'
|
||||
import { P, Ul } from '/src/components/Paragraph/Text'
|
||||
import Section from '/src/components/Section/Section'
|
||||
|
|
@ -84,6 +85,9 @@ const Page = async () => {
|
|||
<Button href="/">{t('common:cta')}</Button>
|
||||
</Content>
|
||||
</Section>
|
||||
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<Footer />
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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[]>([])
|
||||
|
|
|
|||
|
|
@ -9,3 +9,8 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.slim {
|
||||
margin-block: 10px;
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
.form {
|
||||
margin: 0 0 60px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
|
||||
import {
|
||||
TextField,
|
||||
CalendarField,
|
||||
TimeRangeField,
|
||||
SelectField,
|
||||
Button,
|
||||
Error,
|
||||
Recents,
|
||||
Footer,
|
||||
} from '/src/components'
|
||||
|
||||
import {
|
||||
StyledMain,
|
||||
CreateForm,
|
||||
TitleSmall,
|
||||
TitleLarge,
|
||||
P,
|
||||
OfflineMessage,
|
||||
ShareInfo,
|
||||
} from './Create.styles'
|
||||
|
||||
import api from '/src/services'
|
||||
import { useRecentsStore } from '/src/stores'
|
||||
|
||||
import timezones from '/src/res/timezones.json'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const Create = ({ offline }) => {
|
||||
const { register, handleSubmit, setValue } = useForm({
|
||||
defaultValues: {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [createdEvent, setCreatedEvent] = useState(null)
|
||||
const [copied, setCopied] = useState(null)
|
||||
const [showFooter, setShowFooter] = useState(true)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation(['common', 'home', 'event'])
|
||||
|
||||
const addRecent = useRecentsStore(state => state.addRecent)
|
||||
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
navigate('/')
|
||||
}
|
||||
document.title = 'Create a Crab Fit'
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage('crabfit-create', '*')
|
||||
window.addEventListener('message', e => {
|
||||
if (e.data === 'safari-extension') {
|
||||
setShowFooter(false)
|
||||
}
|
||||
}, {
|
||||
once: true
|
||||
})
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const onSubmit = async data => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { start, end } = JSON.parse(data.times)
|
||||
const dates = JSON.parse(data.dates)
|
||||
|
||||
if (dates.length === 0) {
|
||||
return setError(t('home:form.errors.no_dates'))
|
||||
}
|
||||
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
|
||||
if (start === end) {
|
||||
return setError(t('home:form.errors.same_times'))
|
||||
}
|
||||
|
||||
const times = dates.reduce((times, date) => {
|
||||
const day = []
|
||||
for (let i = start; i < (start > end ? 24 : end); i++) {
|
||||
if (isSpecificDates) {
|
||||
day.push(
|
||||
dayjs.tz(date, 'DDMMYYYY', data.timezone)
|
||||
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||
)
|
||||
} else {
|
||||
day.push(
|
||||
dayjs().tz(data.timezone)
|
||||
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||
)
|
||||
}
|
||||
}
|
||||
if (start > end) {
|
||||
for (let i = 0; i < end; i++) {
|
||||
if (isSpecificDates) {
|
||||
day.push(
|
||||
dayjs.tz(date, 'DDMMYYYY', data.timezone)
|
||||
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||
)
|
||||
} else {
|
||||
day.push(
|
||||
dayjs().tz(data.timezone)
|
||||
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...times, ...day]
|
||||
}, [])
|
||||
|
||||
if (times.length === 0) {
|
||||
return setError(t('home:form.errors.no_time'))
|
||||
}
|
||||
|
||||
const event = await api.post('/event', {
|
||||
event: {
|
||||
name: data.name,
|
||||
times: times,
|
||||
timezone: data.timezone,
|
||||
},
|
||||
})
|
||||
setCreatedEvent(event)
|
||||
addRecent({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
name: event.name,
|
||||
})
|
||||
gtag('event', 'create_event', {
|
||||
'event_category': 'create',
|
||||
})
|
||||
} catch (e) {
|
||||
setError(t('home:form.errors.unknown'))
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledMain>
|
||||
<TitleSmall>{t('home:create')}</TitleSmall>
|
||||
<TitleLarge>CRAB FIT</TitleLarge>
|
||||
</StyledMain>
|
||||
|
||||
{createdEvent ? (
|
||||
<StyledMain>
|
||||
<OfflineMessage>
|
||||
<h2>{createdEvent?.name}</h2>
|
||||
<ShareInfo
|
||||
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${createdEvent.id}`)
|
||||
.then(() => {
|
||||
setCopied(t('event:nav.copied'))
|
||||
setTimeout(() => setCopied(null), 1000)
|
||||
gtag('event', 'copy_link', {
|
||||
'event_category': 'event',
|
||||
})
|
||||
})
|
||||
.catch(e => console.error('Failed to copy', e))
|
||||
}
|
||||
title={navigator.clipboard ? t('event:nav.title') : ''}
|
||||
>{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo>
|
||||
<ShareInfo>
|
||||
{/* eslint-disable-next-line */}
|
||||
<Trans i18nKey="event:nav.shareinfo_alt">Click the link above to copy it to your clipboard, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: createdEvent?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${createdEvent?.id}`)}`} target="_blank">email</a>.</Trans>
|
||||
</ShareInfo>
|
||||
{showFooter && <Footer small />}
|
||||
</OfflineMessage>
|
||||
</StyledMain>
|
||||
) : (
|
||||
<>
|
||||
<Recents target="_blank" />
|
||||
|
||||
<StyledMain>
|
||||
{offline ? (
|
||||
<OfflineMessage>
|
||||
<h1>🦀📵</h1>
|
||||
<P>{t('home:offline')}</P>
|
||||
</OfflineMessage>
|
||||
) : (
|
||||
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
|
||||
<TextField
|
||||
label={t('home:form.name.label')}
|
||||
subLabel={t('home:form.name.sublabel')}
|
||||
type="text"
|
||||
id="name"
|
||||
{...register('name')}
|
||||
/>
|
||||
|
||||
<CalendarField
|
||||
label={t('home:form.dates.label')}
|
||||
subLabel={t('home:form.dates.sublabel')}
|
||||
id="dates"
|
||||
required
|
||||
setValue={setValue}
|
||||
{...register('dates')}
|
||||
/>
|
||||
|
||||
<TimeRangeField
|
||||
label={t('home:form.times.label')}
|
||||
subLabel={t('home:form.times.sublabel')}
|
||||
id="times"
|
||||
required
|
||||
setValue={setValue}
|
||||
{...register('times')}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label={t('home:form.timezone.label')}
|
||||
id="timezone"
|
||||
options={timezones}
|
||||
required
|
||||
{...register('timezone')}
|
||||
defaultOption={t('home:form.timezone.defaultOption')}
|
||||
/>
|
||||
|
||||
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
|
||||
|
||||
<Button type="submit" isLoading={isLoading} disabled={isLoading} style={{ width: '100%' }}>{t('home:form.button')}</Button>
|
||||
</CreateForm>
|
||||
)}
|
||||
</StyledMain>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Create
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
export const StyledMain = styled('div')`
|
||||
width: 600px;
|
||||
margin: 10px auto;
|
||||
max-width: calc(100% - 30px);
|
||||
`
|
||||
|
||||
export const CreateForm = styled('form')`
|
||||
margin: 0 0 30px;
|
||||
`
|
||||
|
||||
export const TitleSmall = styled('span')`
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
font-family: 'Samurai Bob', sans-serif;
|
||||
font-weight: 400;
|
||||
color: var(--secondary);
|
||||
line-height: 1em;
|
||||
text-transform: uppercase;
|
||||
`
|
||||
|
||||
export const TitleLarge = styled('h1')`
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
font-family: 'Molot', sans-serif;
|
||||
font-weight: 400;
|
||||
text-shadow: 0 3px 0 var(--secondary);
|
||||
line-height: 1em;
|
||||
text-transform: uppercase;
|
||||
`
|
||||
|
||||
export const P = styled('p')`
|
||||
font-weight: 500;
|
||||
line-height: 1.6em;
|
||||
`
|
||||
|
||||
export const OfflineMessage = styled('div')`
|
||||
text-align: center;
|
||||
margin: 50px 0 20px;
|
||||
`
|
||||
|
||||
export const ShareInfo = styled('p')`
|
||||
margin: 6px 0;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
padding: 10px 0;
|
||||
|
||||
${props => props.onClick && `
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { lazy } from 'react'
|
||||
|
||||
export const Home = lazy(() => import('./Home/Home'))
|
||||
export const Event = lazy(() => import('./Event/Event'))
|
||||
export const Create = lazy(() => import('./Create/Create'))
|
||||
export const Help = lazy(() => import('./Help/Help'))
|
||||
export const Privacy = lazy(() => import('./Privacy/Privacy'))
|
||||
|
|
@ -6,7 +6,7 @@ type TimeFormat = '12h' | '24h'
|
|||
type Theme = 'System' | 'Light' | 'Dark'
|
||||
|
||||
interface SettingsStore {
|
||||
/** 0: Monday, 1: Sunday */
|
||||
/** 0: Sunday, 1: Monday */
|
||||
weekStart: 0 | 1
|
||||
timeFormat: TimeFormat
|
||||
theme: Theme
|
||||
|
|
@ -34,18 +34,7 @@ const useSettingsStore = create<SettingsStore>()(persist(
|
|||
setHighlight: highlight => set({ highlight }),
|
||||
setColormap: colormap => set({ colormap }),
|
||||
}),
|
||||
{
|
||||
name: 'crabfit-settings',
|
||||
version: 1,
|
||||
migrate: (persistedState, version) => {
|
||||
if (version === 0) {
|
||||
// Weekstart used to be 0 for Sunday, but now it's been swapped
|
||||
(persistedState as SettingsStore).weekStart = (persistedState as SettingsStore).weekStart === 1 ? 0 : 1
|
||||
return persistedState as SettingsStore
|
||||
}
|
||||
return persistedState as SettingsStore
|
||||
},
|
||||
},
|
||||
{ name: 'crabfit-settings' },
|
||||
))
|
||||
|
||||
export default useSettingsStore
|
||||
|
|
|
|||
|
|
@ -1317,11 +1317,6 @@ globby@^13.1.3:
|
|||
merge2 "^1.4.1"
|
||||
slash "^4.0.0"
|
||||
|
||||
goober@^2.1.13:
|
||||
version "2.1.13"
|
||||
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c"
|
||||
integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==
|
||||
|
||||
gopd@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
|
||||
|
|
|
|||
Loading…
Reference in a new issue