View event availabilities
This commit is contained in:
parent
cdea567bf3
commit
1a6d34ac59
118
frontend/src/app/[id]/EventAvailabilities.tsx
Normal file
118
frontend/src/app/[id]/EventAvailabilities.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
|
||||||
|
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Login from '/src/components/Login/Login'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import SelectField from '/src/components/SelectField/SelectField'
|
||||||
|
import { EventResponse, PersonResponse } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import timezones from '/src/res/timezones.json'
|
||||||
|
import { expandTimes, makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
const EventAvailabilities = ({ event, people }: { event: EventResponse, people: PersonResponse[] }) => {
|
||||||
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
const expandedTimes = useMemo(() => expandTimes(event.times), [event.times])
|
||||||
|
|
||||||
|
const [user, setUser] = useState<PersonResponse>()
|
||||||
|
const [password, setPassword] = useState<string>()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'group' | 'you'>('group')
|
||||||
|
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Section id="login">
|
||||||
|
<Content>
|
||||||
|
<Login eventId={event.id} user={user} onChange={(u, p) => {
|
||||||
|
setUser(u)
|
||||||
|
setPassword(p)
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label={t('form.timezone')}
|
||||||
|
name="timezone"
|
||||||
|
id="timezone"
|
||||||
|
isInline
|
||||||
|
value={timezone}
|
||||||
|
onChange={event => setTimezone(event.currentTarget.value)}
|
||||||
|
options={timezones}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{event?.timezone && event.timezone !== timezone && <p>
|
||||||
|
<Trans i18nKey="form.created_in_timezone" t={t} i18n={i18n}>
|
||||||
|
{/* eslint-disable-next-line */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
_<strong>{{timezone: event.timezone}}</strong>
|
||||||
|
_<a href="#" onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTimezone(event.timezone)
|
||||||
|
}}>_</a>_
|
||||||
|
</Trans>
|
||||||
|
</p>}
|
||||||
|
|
||||||
|
{((
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
|
||||||
|
&& (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
) || (
|
||||||
|
event?.timezone === undefined
|
||||||
|
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
|
||||||
|
)) && (
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="form.local_timezone" t={t} i18n={i18n}>
|
||||||
|
{/* eslint-disable-next-line */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
_<strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>
|
||||||
|
_<a href="#" onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
}}>_</a>_
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Content>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
<button
|
||||||
|
className={makeClass(
|
||||||
|
styles.tab,
|
||||||
|
tab === 'you' && styles.tabSelected,
|
||||||
|
!user && styles.tabDisabled,
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (user) {
|
||||||
|
setTab('you')
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent('focusName'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={user ? '' : t<string>('tabs.you_tooltip')}
|
||||||
|
>{t('tabs.you')}</button>
|
||||||
|
<button
|
||||||
|
className={makeClass(
|
||||||
|
styles.tab,
|
||||||
|
tab === 'group' && styles.tabSelected,
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('group')}
|
||||||
|
>{t('tabs.group')}</button>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{tab === 'group' && <AvailabilityViewer
|
||||||
|
times={expandedTimes}
|
||||||
|
people={people}
|
||||||
|
timezone={timezone}
|
||||||
|
/>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventAvailabilities
|
||||||
17
frontend/src/app/[id]/layout.tsx
Normal file
17
frontend/src/app/[id]/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Footer from '/src/components/Footer/Footer'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
|
||||||
|
const Layout = async ({ children }: { children: React.ReactNode }) => <>
|
||||||
|
<Content>
|
||||||
|
{/* @ts-expect-error Async Server Component */}
|
||||||
|
<Header />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* @ts-expect-error Async Server Component */}
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
|
||||||
|
export default Layout
|
||||||
17
frontend/src/app/[id]/not-found.tsx
Normal file
17
frontend/src/app/[id]/not-found.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
const NotFound = async () => {
|
||||||
|
const { t } = await useTranslation('event')
|
||||||
|
|
||||||
|
return <Content>
|
||||||
|
<div style={{ marginBlock: 100 }}>
|
||||||
|
<h1 className={styles.name}>{t('error.title')}</h1>
|
||||||
|
<p className={styles.info}>{t('error.body')}</p>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFound
|
||||||
66
frontend/src/app/[id]/page.module.scss
Normal file
66
frontend/src/app/[id]/page.module.scss
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
.name {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 20px 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noPrint {
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: .8;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
&::after {
|
||||||
|
content: ' - ' attr(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 30px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
user-select: none;
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 18px;
|
||||||
|
background-color: var(--surface);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-bottom: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
font: inherit;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabSelected {
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabDisabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
41
frontend/src/app/[id]/page.tsx
Normal file
41
frontend/src/app/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Copyable from '/src/components/Copyable/Copyable'
|
||||||
|
import { getEvent, getPeople } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
import { makeClass, relativeTimeFormat } from '/src/utils'
|
||||||
|
|
||||||
|
import EventAvailabilities from './EventAvailabilities'
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
const Page = async ({ params }: { params: { id: string } }) => {
|
||||||
|
const event = await getEvent(params.id).catch(() => undefined)
|
||||||
|
const people = await getPeople(params.id).catch(() => undefined)
|
||||||
|
if (!event || !people) notFound()
|
||||||
|
|
||||||
|
const { t, i18n } = await useTranslation(['common', 'event'])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
<h1 className={styles.name}>{event.name}</h1>
|
||||||
|
<span
|
||||||
|
className={styles.date}
|
||||||
|
title={Temporal.Instant.fromEpochSeconds(event.created_at).toLocaleString(i18n.language, { dateStyle: 'long' })}
|
||||||
|
>{t('common:created', { date: relativeTimeFormat(Temporal.Instant.fromEpochSeconds(event.created_at), i18n.language) })}</span>
|
||||||
|
|
||||||
|
<Copyable className={styles.info}>
|
||||||
|
{`https://crab.fit/${event.id}`}
|
||||||
|
</Copyable>
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<EventAvailabilities event={event} people={people} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
||||||
7
frontend/src/components/Copyable/Copyable.module.scss
Normal file
7
frontend/src/components/Copyable/Copyable.module.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.copyable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/components/Copyable/Copyable.tsx
Normal file
33
frontend/src/components/Copyable/Copyable.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Copyable.module.scss'
|
||||||
|
|
||||||
|
interface CopyableProps extends Omit<React.ComponentProps<'p'>, 'children'> {
|
||||||
|
children: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Copyable = ({ children, className, ...props }: CopyableProps) => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState<React.ReactNode>()
|
||||||
|
|
||||||
|
return <p
|
||||||
|
onClick={() => navigator.clipboard?.writeText(children)
|
||||||
|
.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}
|
||||||
|
className={makeClass(className, 'clipboard' in navigator && styles.copyable)}
|
||||||
|
{...props}
|
||||||
|
>{copied ?? children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Copyable
|
||||||
|
|
@ -12,7 +12,7 @@ import { default as ErrorAlert } from '/src/components/Error/Error'
|
||||||
import SelectField from '/src/components/SelectField/SelectField'
|
import SelectField from '/src/components/SelectField/SelectField'
|
||||||
import TextField from '/src/components/TextField/TextField'
|
import TextField from '/src/components/TextField/TextField'
|
||||||
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
|
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
|
||||||
import { API_BASE, EventResponse } from '/src/config/api'
|
import { createEvent, EventResponse } 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 useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
|
@ -94,22 +94,10 @@ const CreateForm = ({ noRedirect }: { noRedirect?: boolean }) => {
|
||||||
return setError(t('form.errors.no_time'))
|
return setError(t('form.errors.no_time'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(new URL('/event', API_BASE), {
|
const newEvent = await createEvent({ name, times, timezone }).catch(e => {
|
||||||
method: 'POST',
|
console.error(e)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
times,
|
|
||||||
timezone,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error(res)
|
|
||||||
throw new Error('Failed to create event')
|
throw new Error('Failed to create event')
|
||||||
}
|
})
|
||||||
|
|
||||||
const newEvent = EventResponse.parse(await res.json())
|
|
||||||
|
|
||||||
if (noRedirect) {
|
if (noRedirect) {
|
||||||
// Show event link
|
// Show event link
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,3 @@
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copyable {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { Trans } from 'react-i18next/TransWithoutContext'
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
|
||||||
|
import Copyable from '/src/components/Copyable/Copyable'
|
||||||
import { EventResponse } from '/src/config/api'
|
import { EventResponse } from '/src/config/api'
|
||||||
import { useTranslation } from '/src/i18n/client'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
import { makeClass } from '/src/utils'
|
|
||||||
|
|
||||||
import styles from './EventInfo.module.scss'
|
import styles from './EventInfo.module.scss'
|
||||||
|
|
||||||
|
|
@ -14,21 +13,11 @@ interface EventInfoProps {
|
||||||
const EventInfo = ({ event }: EventInfoProps) => {
|
const EventInfo = ({ event }: EventInfoProps) => {
|
||||||
const { t, i18n } = useTranslation('event')
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
const [copied, setCopied] = useState<React.ReactNode>()
|
|
||||||
|
|
||||||
return <div className={styles.wrapper}>
|
return <div className={styles.wrapper}>
|
||||||
<h2>{event.name}</h2>
|
<h2>{event.name}</h2>
|
||||||
<p
|
<Copyable className={styles.info}>
|
||||||
className={makeClass(styles.info, styles.copyable)}
|
{`https://crab.fit/${event.id}`}
|
||||||
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${event.id}`)
|
</Copyable>
|
||||||
.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}>
|
<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<string>('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} https://crab.fit/${event.id}`)}`} target="_blank">_</a>_</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
23
frontend/src/components/Login/Login.module.scss
Normal file
23
frontend/src/components/Login/Login.module.scss
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: flex-end;
|
||||||
|
grid-gap: 18px;
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
|
& div:last-child {
|
||||||
|
--btn-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 18px 0;
|
||||||
|
opacity: .75;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
100
frontend/src/components/Login/Login.tsx
Normal file
100
frontend/src/components/Login/Login.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import Error from '/src/components/Error/Error'
|
||||||
|
import TextField from '/src/components/TextField/TextField'
|
||||||
|
import { getPerson, PersonResponse } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
|
||||||
|
import styles from './Login.module.scss'
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
eventId: string
|
||||||
|
user: PersonResponse | undefined
|
||||||
|
onChange: (user: PersonResponse | undefined, password?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = ({ eventId, user, onChange }: LoginProps) => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setFocus,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
} = useForm({ defaultValues })
|
||||||
|
|
||||||
|
const [error, setError] = useState<React.ReactNode>()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const focusName = useCallback(() => setFocus('username'), [setFocus])
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('focusName', focusName)
|
||||||
|
return () => document.removeEventListener('focusName', focusName)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<typeof defaultValues> = async ({ username, password }) => {
|
||||||
|
if (username.length === 0) {
|
||||||
|
focusName()
|
||||||
|
return setError(t('form.errors.name_required'))
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(undefined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resUser = await getPerson(eventId, username, password || undefined)
|
||||||
|
onChange(resUser, password || undefined)
|
||||||
|
reset()
|
||||||
|
} catch (e) {
|
||||||
|
if (e && typeof e === 'object' && 'status' in e && e.status === 401) {
|
||||||
|
setError(t('form.errors.password_incorrect'))
|
||||||
|
setValue('password', '')
|
||||||
|
} else {
|
||||||
|
setError(t('form.errors.unknown'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>{t('form.signed_in', { name: user.name })}</h2>
|
||||||
|
<Button isSmall onClick={() => onChange(undefined)}>{t('form.logout_button')}</Button>
|
||||||
|
</div> : <>
|
||||||
|
<h2>{t('form.signed_out')}</h2>
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<TextField
|
||||||
|
label={t('form.name')}
|
||||||
|
type="text"
|
||||||
|
isInline
|
||||||
|
required
|
||||||
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('form.password')}
|
||||||
|
type="password"
|
||||||
|
isInline
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>{t('form.button')}</Button>
|
||||||
|
</form>
|
||||||
|
<Error onClose={() => setError(undefined)}>{error}</Error>
|
||||||
|
<p className={styles.info}>{t('form.info')}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
import { API_BASE, StatsResponse } from '/src/config/api'
|
import { getStats } from '/src/config/api'
|
||||||
import { useTranslation } from '/src/i18n/server'
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
import styles from './Stats.module.scss'
|
import styles from './Stats.module.scss'
|
||||||
|
|
||||||
const getStats = async () => {
|
|
||||||
const res = await fetch(new URL('/stats', API_BASE))
|
|
||||||
.catch(console.warn)
|
|
||||||
if (!res?.ok) return
|
|
||||||
return StatsResponse.parse(await res.json())
|
|
||||||
}
|
|
||||||
|
|
||||||
const Stats = async () => {
|
const Stats = async () => {
|
||||||
const stats = await getStats()
|
const stats = await getStats().catch(() => undefined)
|
||||||
const { t } = await useTranslation('home')
|
const { t } = await useTranslation('home')
|
||||||
|
|
||||||
return stats ? <div className={styles.wrapper}>
|
return stats ? <div className={styles.wrapper}>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
// export { default as TextField } from './TextField/TextField'
|
|
||||||
// export { default as SelectField } from './SelectField/SelectField'
|
|
||||||
// export { default as CalendarField } from './CalendarField/CalendarField'
|
|
||||||
// export { default as TimeRangeField } from './TimeRangeField/TimeRangeField'
|
|
||||||
// export { default as ToggleField } from './ToggleField/ToggleField'
|
|
||||||
|
|
||||||
// export { default as Legend } from './Legend/Legend'
|
|
||||||
// export { default as AvailabilityViewer } from './AvailabilityViewer/AvailabilityViewer'
|
|
||||||
// export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'
|
|
||||||
// export { default as Loading } from './Loading/Loading'
|
|
||||||
|
|
||||||
// export { default as Center } from './Center/Center'
|
|
||||||
// export { default as Settings } from './Settings/Settings'
|
|
||||||
// export { default as Egg } from './Egg/Egg'
|
|
||||||
// export { default as TranslateDialog } from './TranslateDialog/TranslateDialog'
|
|
||||||
|
|
||||||
// export const _GoogleCalendar = () => import('./GoogleCalendar/GoogleCalendar')
|
|
||||||
// export const _OutlookCalendar = () => import('./OutlookCalendar/OutlookCalendar')
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// TODO: Write a simple rust crate that generates these from the OpenAPI spec
|
// TODO: Potentially write a simple rust crate that generates these from the OpenAPI spec
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ if (process.env.NEXT_PUBLIC_API_URL === undefined) {
|
||||||
throw new Error('Expected API url environment variable')
|
throw new Error('Expected API url environment variable')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_BASE = new URL(process.env.NEXT_PUBLIC_API_URL)
|
const API_BASE = new URL(process.env.NEXT_PUBLIC_API_URL)
|
||||||
|
|
||||||
export const EventInput = z.object({
|
export const EventInput = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|
@ -42,3 +42,38 @@ export const StatsResponse = z.object({
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
})
|
})
|
||||||
export type StatsResponse = z.infer<typeof StatsResponse>
|
export type StatsResponse = z.infer<typeof StatsResponse>
|
||||||
|
|
||||||
|
const get = async <S extends z.Schema>(url: string, schema: S, auth?: string): Promise<ReturnType<S['parse']>> => {
|
||||||
|
const res = await fetch(new URL(url, API_BASE), {
|
||||||
|
headers: {
|
||||||
|
...auth && { Authorization: `Bearer ${auth}` },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.warn)
|
||||||
|
if (!res?.ok) throw res
|
||||||
|
return schema.parse(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = async <S extends z.Schema>(url: string, schema: S, input: unknown, auth?: string, method = 'POST'): Promise<ReturnType<S['parse']>> => {
|
||||||
|
const res = await fetch(new URL(url, API_BASE), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...auth && { Authorization: `Bearer ${auth}` },
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
.catch(console.warn)
|
||||||
|
if (!res?.ok) throw res
|
||||||
|
return schema.parse(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get
|
||||||
|
export const getStats = () => get('/stats', StatsResponse)
|
||||||
|
export const getEvent = (eventId: string) => get(`/event/${eventId}`, EventResponse)
|
||||||
|
export const getPeople = (eventId: string) => get(`/event/${eventId}/people`, PersonResponse.array())
|
||||||
|
export const getPerson = (eventId: string, personName: string, password?: string) => get(`/event/${eventId}/people/${personName}`, PersonResponse, password && btoa(password))
|
||||||
|
|
||||||
|
// Post
|
||||||
|
export const createEvent = (input: EventInput) => post('/event', EventResponse, EventInput.parse(input))
|
||||||
|
export const updatePerson = (eventId: string, personName: string, input: PersonInput, password?: string) => post(`/event/${eventId}/people/${personName}`, PersonResponse, PersonInput.parse(input), password && btoa(password), 'PATCH')
|
||||||
|
|
|
||||||
13
frontend/src/utils/expandTimes.ts
Normal file
13
frontend/src/utils/expandTimes.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Takes times as strings that start with `HH00-`, and adds 15, 30 and 45 minute variants
|
||||||
|
*/
|
||||||
|
export const expandTimes = (times: string[]) =>
|
||||||
|
times.flatMap(time => {
|
||||||
|
const [hour, rest] = [time.substring(0, 2), time.substring(4)]
|
||||||
|
return [
|
||||||
|
`${hour}00${rest}`,
|
||||||
|
`${hour}15${rest}`,
|
||||||
|
`${hour}30${rest}`,
|
||||||
|
`${hour}45${rest}`,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
@ -7,3 +7,4 @@ export * from './calculateRows'
|
||||||
export * from './calculateColumns'
|
export * from './calculateColumns'
|
||||||
export * from './getWeekdayNames'
|
export * from './getWeekdayNames'
|
||||||
export * from './relativeTimeFormat'
|
export * from './relativeTimeFormat'
|
||||||
|
export * from './expandTimes'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue