View event availabilities
This commit is contained in:
parent
cdea567bf3
commit
1a6d34ac59
17 changed files with 483 additions and 68 deletions
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 TextField from '/src/components/TextField/TextField'
|
||||
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 timezones from '/src/res/timezones.json'
|
||||
import useRecentsStore from '/src/stores/recentsStore'
|
||||
|
|
@ -94,22 +94,10 @@ const CreateForm = ({ noRedirect }: { noRedirect?: boolean }) => {
|
|||
return setError(t('form.errors.no_time'))
|
||||
}
|
||||
|
||||
const res = await fetch(new URL('/event', API_BASE), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
times,
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(res)
|
||||
const newEvent = await createEvent({ name, times, timezone }).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Failed to create event')
|
||||
}
|
||||
|
||||
const newEvent = EventResponse.parse(await res.json())
|
||||
})
|
||||
|
||||
if (noRedirect) {
|
||||
// Show event link
|
||||
|
|
|
|||
|
|
@ -9,11 +9,3 @@
|
|||
font-size: 15px;
|
||||
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 Copyable from '/src/components/Copyable/Copyable'
|
||||
import { EventResponse } from '/src/config/api'
|
||||
import { useTranslation } from '/src/i18n/client'
|
||||
import { makeClass } from '/src/utils'
|
||||
|
||||
import styles from './EventInfo.module.scss'
|
||||
|
||||
|
|
@ -14,21 +13,11 @@ interface EventInfoProps {
|
|||
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>
|
||||
<Copyable className={styles.info}>
|
||||
{`https://crab.fit/${event.id}`}
|
||||
</Copyable>
|
||||
<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>
|
||||
|
|
|
|||
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 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 = await getStats()
|
||||
const stats = await getStats().catch(() => undefined)
|
||||
const { t } = await useTranslation('home')
|
||||
|
||||
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')
|
||||
Loading…
Add table
Add a link
Reference in a new issue