Rebuild TextField and CalendarField
This commit is contained in:
parent
1e77205518
commit
12004b8584
|
|
@ -5,7 +5,10 @@
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"simple-import-sort/imports": "warn",
|
"simple-import-sort/imports": "warn",
|
||||||
"@next/next/no-img-element": "off"
|
"@next/next/no-img-element": "off",
|
||||||
|
"react/display-name": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"space-infix-ops": "warn"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
.nav {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Trans } from 'react-i18next/TransWithoutContext'
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import Button from '/src/components/Button/Button'
|
|
||||||
import Content from '/src/components/Content/Content'
|
import Content from '/src/components/Content/Content'
|
||||||
|
import CreateForm from '/src/components/CreateForm/CreateForm'
|
||||||
import DownloadButtons from '/src/components/DownloadButtons/DownloadButtons'
|
import DownloadButtons from '/src/components/DownloadButtons/DownloadButtons'
|
||||||
import Footer from '/src/components/Footer/Footer'
|
import Footer from '/src/components/Footer/Footer'
|
||||||
import Header from '/src/components/Header/Header'
|
import Header from '/src/components/Header/Header'
|
||||||
|
|
@ -13,8 +13,6 @@ import Stats from '/src/components/Stats/Stats'
|
||||||
import Video from '/src/components/Video/Video'
|
import Video from '/src/components/Video/Video'
|
||||||
import { useTranslation } from '/src/i18n/server'
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
import styles from './home.module.scss'
|
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const { t } = await useTranslation('home')
|
const { t } = await useTranslation('home')
|
||||||
|
|
||||||
|
|
@ -22,19 +20,12 @@ const Page = async () => {
|
||||||
<Content>
|
<Content>
|
||||||
{/* @ts-expect-error Async Server Component */}
|
{/* @ts-expect-error Async Server Component */}
|
||||||
<Header isFull />
|
<Header isFull />
|
||||||
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<a href="#about">{t('nav.about')}</a>
|
|
||||||
{' / '}
|
|
||||||
<a href="#donate">{t('nav.donate')}</a>
|
|
||||||
</nav>
|
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
<Recents />
|
<Recents />
|
||||||
|
|
||||||
<Content>
|
<Content>
|
||||||
<span>Form here</span>
|
<CreateForm />
|
||||||
<Button>Create</Button>
|
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
<Section id="about">
|
<Section id="about">
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
& svg, & img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
padding: .4em 1.3em;
|
padding: .4em 1.3em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ type ButtonProps = {
|
||||||
surfaceColor?: string
|
surfaceColor?: string
|
||||||
/** Override the shadow color of the button */
|
/** Override the shadow color of the button */
|
||||||
shadowColor?: string
|
shadowColor?: string
|
||||||
// TODO: evaluate
|
|
||||||
size?: string
|
|
||||||
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
|
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
|
@ -30,7 +28,6 @@ const Button: React.FC<ButtonProps> = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
surfaceColor,
|
surfaceColor,
|
||||||
shadowColor,
|
shadowColor,
|
||||||
size,
|
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -40,14 +37,14 @@ const Button: React.FC<ButtonProps> = ({
|
||||||
isSecondary && styles.secondary,
|
isSecondary && styles.secondary,
|
||||||
isSmall && styles.small,
|
isSmall && styles.small,
|
||||||
isLoading && styles.loading,
|
isLoading && styles.loading,
|
||||||
|
!children && icon && styles.iconButton,
|
||||||
),
|
),
|
||||||
style: {
|
style: {
|
||||||
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
|
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
|
||||||
...shadowColor && { '--override-shadow-color': shadowColor },
|
...shadowColor && { '--override-shadow-color': shadowColor },
|
||||||
...size && { padding: 0, height: size, width: size },
|
|
||||||
...style,
|
...style,
|
||||||
},
|
},
|
||||||
children: [icon, children],
|
children: <>{icon}{children}</>,
|
||||||
...props,
|
...props,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import isToday from 'dayjs/plugin/isToday'
|
|
||||||
import localeData from 'dayjs/plugin/localeData'
|
|
||||||
import updateLocale from 'dayjs/plugin/updateLocale'
|
|
||||||
|
|
||||||
import { Button, ToggleField } from '/src/components'
|
|
||||||
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
StyledLabel,
|
|
||||||
StyledSubLabel,
|
|
||||||
CalendarHeader,
|
|
||||||
CalendarDays,
|
|
||||||
CalendarBody,
|
|
||||||
Date,
|
|
||||||
Day,
|
|
||||||
} from './CalendarField.styles'
|
|
||||||
|
|
||||||
dayjs.extend(isToday)
|
|
||||||
dayjs.extend(localeData)
|
|
||||||
dayjs.extend(updateLocale)
|
|
||||||
|
|
||||||
const calculateMonth = (month, year, weekStart) => {
|
|
||||||
const date = dayjs().month(month).year(year)
|
|
||||||
const daysInMonth = date.daysInMonth()
|
|
||||||
const daysBefore = date.date(1).day() - weekStart
|
|
||||||
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
|
|
||||||
|
|
||||||
const dates = []
|
|
||||||
let curDate = date.date(1).subtract(daysBefore, 'day')
|
|
||||||
let y = 0
|
|
||||||
let x = 0
|
|
||||||
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
|
|
||||||
if (x === 0) dates[y] = []
|
|
||||||
dates[y][x] = curDate.clone()
|
|
||||||
curDate = curDate.add(1, 'day')
|
|
||||||
x++
|
|
||||||
if (x > 6) {
|
|
||||||
x = 0
|
|
||||||
y++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dates
|
|
||||||
}
|
|
||||||
|
|
||||||
const CalendarField = forwardRef(({
|
|
||||||
label,
|
|
||||||
subLabel,
|
|
||||||
id,
|
|
||||||
setValue,
|
|
||||||
...props
|
|
||||||
}, ref) => {
|
|
||||||
const weekStart = useSettingsStore(state => state.weekStart)
|
|
||||||
const locale = useLocaleUpdateStore(state => state.locale)
|
|
||||||
const { t } = useTranslation('home')
|
|
||||||
|
|
||||||
const [type, setType] = useState(0)
|
|
||||||
|
|
||||||
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart))
|
|
||||||
const [month, setMonth] = useState(dayjs().month())
|
|
||||||
const [year, setYear] = useState(dayjs().year())
|
|
||||||
|
|
||||||
const [selectedDates, setSelectedDates] = useState([])
|
|
||||||
const [selectingDates, _setSelectingDates] = useState([])
|
|
||||||
const staticSelectingDates = useRef([])
|
|
||||||
const setSelectingDates = newDates => {
|
|
||||||
staticSelectingDates.current = newDates
|
|
||||||
_setSelectingDates(newDates)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selectedDays, setSelectedDays] = useState([])
|
|
||||||
const [selectingDays, _setSelectingDays] = useState([])
|
|
||||||
const staticSelectingDays = useRef([])
|
|
||||||
const setSelectingDays = newDays => {
|
|
||||||
staticSelectingDays.current = newDays
|
|
||||||
_setSelectingDays(newDays)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPos = useRef({})
|
|
||||||
const staticMode = useRef(null)
|
|
||||||
const [mode, _setMode] = useState(staticMode.current)
|
|
||||||
const setMode = newMode => {
|
|
||||||
staticMode.current = newMode
|
|
||||||
_setMode(newMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
|
|
||||||
dayjs.updateLocale(locale, { weekStart })
|
|
||||||
}
|
|
||||||
setDates(calculateMonth(month, year, weekStart))
|
|
||||||
}, [weekStart, month, year, locale])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper locale={locale}>
|
|
||||||
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
|
|
||||||
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="hidden"
|
|
||||||
ref={ref}
|
|
||||||
value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleField
|
|
||||||
id="calendarMode"
|
|
||||||
name="calendarMode"
|
|
||||||
options={{
|
|
||||||
'specific': t('form.dates.options.specific'),
|
|
||||||
'week': t('form.dates.options.week'),
|
|
||||||
}}
|
|
||||||
value={type === 0 ? 'specific' : 'week'}
|
|
||||||
onChange={value => setType(value === 'specific' ? 0 : 1)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{type === 0 ? (
|
|
||||||
<>
|
|
||||||
<CalendarHeader>
|
|
||||||
<Button
|
|
||||||
size="30px"
|
|
||||||
title={t('form.dates.tooltips.previous')}
|
|
||||||
onClick={() => {
|
|
||||||
if (month-1 < 0) {
|
|
||||||
setYear(year-1)
|
|
||||||
setMonth(11)
|
|
||||||
} else {
|
|
||||||
setMonth(month-1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
><</Button>
|
|
||||||
<span>{dayjs.months()[month]} {year}</span>
|
|
||||||
<Button
|
|
||||||
size="30px"
|
|
||||||
title={t('form.dates.tooltips.next')}
|
|
||||||
onClick={() => {
|
|
||||||
if (month+1 > 11) {
|
|
||||||
setYear(year+1)
|
|
||||||
setMonth(0)
|
|
||||||
} else {
|
|
||||||
setMonth(month+1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>></Button>
|
|
||||||
</CalendarHeader>
|
|
||||||
|
|
||||||
<CalendarDays>
|
|
||||||
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name =>
|
|
||||||
<Day key={name}>{name}</Day>
|
|
||||||
)}
|
|
||||||
</CalendarDays>
|
|
||||||
<CalendarBody>
|
|
||||||
{dates.length > 0 && dates.map((dateRow, y) =>
|
|
||||||
dateRow.map((date, x) =>
|
|
||||||
<Date
|
|
||||||
key={y+x}
|
|
||||||
$otherMonth={date.month() !== month}
|
|
||||||
$isToday={date.isToday()}
|
|
||||||
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
|
|
||||||
$selected={selectedDates.includes(date.format('DDMMYYYY'))}
|
|
||||||
$selecting={selectingDates.includes(date)}
|
|
||||||
$mode={mode}
|
|
||||||
type="button"
|
|
||||||
onKeyPress={e => {
|
|
||||||
if (e.key === ' ' || e.key === 'Enter') {
|
|
||||||
if (selectedDates.includes(date.format('DDMMYYYY'))) {
|
|
||||||
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')))
|
|
||||||
} else {
|
|
||||||
setSelectedDates([...selectedDates, date.format('DDMMYYYY')])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPointerDown={e => {
|
|
||||||
startPos.current = {x, y}
|
|
||||||
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add')
|
|
||||||
setSelectingDates([date])
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
||||||
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
if (staticMode.current === 'add') {
|
|
||||||
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))])
|
|
||||||
} else if (staticMode.current === 'remove') {
|
|
||||||
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'))
|
|
||||||
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)))
|
|
||||||
}
|
|
||||||
setMode(null)
|
|
||||||
}, { once: true })
|
|
||||||
}}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (staticMode.current) {
|
|
||||||
const found = []
|
|
||||||
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
|
|
||||||
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
|
|
||||||
found.push({y: cy, x: cx})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSelectingDates(found.map(d => dates[d.y][d.x]))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{date.date()}</Date>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CalendarBody>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<CalendarBody>
|
|
||||||
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map((name, i) =>
|
|
||||||
<Date
|
|
||||||
key={name}
|
|
||||||
$isToday={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name}
|
|
||||||
title={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name ? t('form.dates.tooltips.today') : ''}
|
|
||||||
$selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
|
|
||||||
$selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
|
|
||||||
$mode={mode}
|
|
||||||
type="button"
|
|
||||||
onKeyPress={e => {
|
|
||||||
if (e.key === ' ' || e.key === 'Enter') {
|
|
||||||
if (selectedDays.includes(((i + weekStart) % 7 + 7) % 7)) {
|
|
||||||
setSelectedDays(selectedDays.filter(d => d !== ((i + weekStart) % 7 + 7) % 7))
|
|
||||||
} else {
|
|
||||||
setSelectedDays([...selectedDays, ((i + weekStart) % 7 + 7) % 7])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPointerDown={e => {
|
|
||||||
startPos.current = i
|
|
||||||
setMode(selectedDays.includes(((i + weekStart) % 7 + 7) % 7) ? 'remove' : 'add')
|
|
||||||
setSelectingDays([((i + weekStart) % 7 + 7) % 7])
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
||||||
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
if (staticMode.current === 'add') {
|
|
||||||
setSelectedDays([...selectedDays, ...staticSelectingDays.current])
|
|
||||||
} else if (staticMode.current === 'remove') {
|
|
||||||
const toRemove = staticSelectingDays.current
|
|
||||||
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)))
|
|
||||||
}
|
|
||||||
setMode(null)
|
|
||||||
}, { once: true })
|
|
||||||
}}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (staticMode.current) {
|
|
||||||
const found = []
|
|
||||||
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i)+1; ci++) {
|
|
||||||
found.push(((ci + weekStart) % 7 + 7) % 7)
|
|
||||||
}
|
|
||||||
setSelectingDays(found)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{name}</Date>
|
|
||||||
)}
|
|
||||||
</CalendarBody>
|
|
||||||
)}
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default CalendarField
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 30px 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledSubLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: .6;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarHeader = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
user-select: none;
|
|
||||||
padding: 6px 0;
|
|
||||||
font-size: 1.2em;
|
|
||||||
font-weight: bold;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarDays = styled('div')`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
grid-gap: 2px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Day = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 3px 0;
|
|
||||||
font-weight: bold;
|
|
||||||
user-select: none;
|
|
||||||
opacity: .7;
|
|
||||||
|
|
||||||
@media (max-width: 350px) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarBody = styled('div')`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
grid-gap: 2px;
|
|
||||||
|
|
||||||
& button:first-of-type {
|
|
||||||
border-top-left-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:nth-of-type(7) {
|
|
||||||
border-top-right-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:nth-last-of-type(7) {
|
|
||||||
border-bottom-left-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:last-of-type {
|
|
||||||
border-bottom-right-radius: 3px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Date = styled('button')`
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
appearance: none;
|
|
||||||
transition: background-color .1s;
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: var(--surface);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 10px 0;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
${props => props.$otherMonth && `
|
|
||||||
color: var(--tertiary);
|
|
||||||
`}
|
|
||||||
${props => props.$isToday && `
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--secondary);
|
|
||||||
`}
|
|
||||||
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
|
|
||||||
color: ${props.$otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
|
|
||||||
background-color: var(--primary);
|
|
||||||
`}
|
|
||||||
${props => props.$mode === 'remove' && props.$selecting && `
|
|
||||||
background-color: var(--surface);
|
|
||||||
color: ${props.$isToday ? 'var(--secondary)' : (props.$otherMonth ? 'var(--tertiary)' : 'inherit')};
|
|
||||||
`}
|
|
||||||
`
|
|
||||||
61
frontend/src/components/CalendarField/CalendarField.tsx
Normal file
61
frontend/src/components/CalendarField/CalendarField.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FieldValues,useController, UseControllerProps } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { Description, Label, Wrapper } from '/src/components/Field/Field'
|
||||||
|
import ToggleField from '/src/components/ToggleField/ToggleField'
|
||||||
|
|
||||||
|
import Month from './components/Month/Month'
|
||||||
|
import Weekdays from './components/Weekdays/Weekdays'
|
||||||
|
|
||||||
|
interface CalendarFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
|
||||||
|
label?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarField = <TValues extends FieldValues>({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
...props
|
||||||
|
}: CalendarFieldProps<TValues>) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const { field } = useController(props)
|
||||||
|
|
||||||
|
const [type, setType] = useState<'specific' | 'week'>('specific')
|
||||||
|
|
||||||
|
const [innerValue, setInnerValue] = useState({
|
||||||
|
specific: [],
|
||||||
|
week: [],
|
||||||
|
} satisfies Record<typeof type, string[]>)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInnerValue({ ...innerValue, [type]: field.value })
|
||||||
|
}, [type, field.value])
|
||||||
|
|
||||||
|
return <Wrapper>
|
||||||
|
{label && <Label htmlFor={props.name}>{label}</Label>}
|
||||||
|
{description && <Description htmlFor={props.name}>{description}</Description>}
|
||||||
|
|
||||||
|
<ToggleField
|
||||||
|
name="calendarMode"
|
||||||
|
options={{
|
||||||
|
specific: t('form.dates.options.specific'),
|
||||||
|
week: t('form.dates.options.week'),
|
||||||
|
}}
|
||||||
|
value={type}
|
||||||
|
onChange={t => {
|
||||||
|
setType(t)
|
||||||
|
field.onChange(innerValue[t])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{type === 'specific' ? (
|
||||||
|
<Month value={innerValue.specific} onChange={field.onChange} />
|
||||||
|
) : (
|
||||||
|
<Weekdays value={innerValue.week} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarField
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
|
||||||
|
& label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
opacity: .7;
|
||||||
|
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
|
||||||
|
& button:first-of-type {
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:nth-of-type(7) {
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:nth-last-of-type(7) {
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:last-of-type {
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
transition: background-color .1s;
|
||||||
|
background-color: var(--surface);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherMonth {
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
|
.today {
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
.selected {
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
|
||||||
|
.otherMonth {
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal file
182
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import dayjs from '/src/config/dayjs'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import useLocaleUpdateStore from '/src/stores/localeUpdateStore'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Month.module.scss'
|
||||||
|
|
||||||
|
// TODO: use from giraugh tools
|
||||||
|
export const rotateArray = <T,>(arr: T[], amount = 1): T[] =>
|
||||||
|
arr.map((_, i) => arr[((( -amount + i ) % arr.length) + arr.length) % arr.length])
|
||||||
|
|
||||||
|
interface MonthProps {
|
||||||
|
/** Array of dates in `DDMMYYYY` format */
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Month = ({ value, onChange }: MonthProps) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const weekStart = useSettingsStore(state => state.weekStart)
|
||||||
|
const locale = useLocaleUpdateStore(state => state.locale)
|
||||||
|
|
||||||
|
const [page, setPage] = useState({
|
||||||
|
month: dayjs().month(),
|
||||||
|
year: dayjs().year(),
|
||||||
|
})
|
||||||
|
const [dates, setDates] = useState(calculateMonth(page, weekStart))
|
||||||
|
|
||||||
|
// Ref and state required to rerender but also access static version in callbacks
|
||||||
|
const selectingRef = useRef<string[]>([])
|
||||||
|
const [selecting, _setSelecting] = useState<string[]>([])
|
||||||
|
const setSelecting = useCallback((v: string[]) => {
|
||||||
|
selectingRef.current = v
|
||||||
|
_setSelecting(v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPos = useRef({ x: 0, y: 0 })
|
||||||
|
const mode = useRef<'add' | 'remove'>()
|
||||||
|
|
||||||
|
// Update month view
|
||||||
|
useEffect(() => {
|
||||||
|
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
|
||||||
|
dayjs.updateLocale(locale, { weekStart })
|
||||||
|
}
|
||||||
|
setDates(calculateMonth(page, weekStart))
|
||||||
|
}, [weekStart, page, locale])
|
||||||
|
|
||||||
|
const handleFinishSelection = useCallback(() => {
|
||||||
|
if (mode.current === 'add') {
|
||||||
|
onChange([...value, ...selectingRef.current])
|
||||||
|
} else {
|
||||||
|
onChange(value.filter(d => !selectingRef.current.includes(d)))
|
||||||
|
}
|
||||||
|
mode.current = undefined
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
title={t<string>('form.dates.tooltips.previous')}
|
||||||
|
onClick={() => {
|
||||||
|
if (page.month - 1 < 0) {
|
||||||
|
setPage({ month: 11, year: page.year - 1 })
|
||||||
|
} else {
|
||||||
|
setPage({ ...page, month: page.month - 1 })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<ChevronLeft />}
|
||||||
|
/>
|
||||||
|
<span>{dayjs.months()[page.month]} {page.year}</span>
|
||||||
|
<Button
|
||||||
|
title={t<string>('form.dates.tooltips.next')}
|
||||||
|
onClick={() => {
|
||||||
|
if (page.month + 1 > 11) {
|
||||||
|
setPage({ month: 0, year: page.year + 1 })
|
||||||
|
} else {
|
||||||
|
setPage({ ...page, month: page.month + 1 })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<ChevronRight />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.dayLabels}>
|
||||||
|
{(rotateArray(dayjs.weekdaysShort(), -weekStart)).map(name =>
|
||||||
|
<label key={name}>{name}</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{dates.length > 0 && dates.map((dateRow, y) =>
|
||||||
|
dateRow.map((date, x) => <button
|
||||||
|
type="button"
|
||||||
|
className={makeClass(
|
||||||
|
styles.date,
|
||||||
|
date.month !== page.month && styles.otherMonth,
|
||||||
|
date.isToday && styles.today,
|
||||||
|
(
|
||||||
|
(!(mode.current === 'remove' && selecting.includes(date.str)) && value.includes(date.str))
|
||||||
|
|| (mode.current === 'add' && selecting.includes(date.str))
|
||||||
|
) && styles.selected,
|
||||||
|
)}
|
||||||
|
key={date.str}
|
||||||
|
title={`${date.day} ${dayjs.months()[date.month]}${date.isToday ? ` (${t('form.dates.tooltips.today')})` : ''}`}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
if (value.includes(date.str)) {
|
||||||
|
onChange(value.filter(d => d !== date.str))
|
||||||
|
} else {
|
||||||
|
onChange([...value, date.str])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
startPos.current = { x, y }
|
||||||
|
mode.current = value.includes(date.str) ? 'remove' : 'add'
|
||||||
|
setSelecting([date.str])
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', handleFinishSelection, { once: true })
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (mode) {
|
||||||
|
const found = []
|
||||||
|
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y) + 1; cy++) {
|
||||||
|
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x) + 1; cx++) {
|
||||||
|
found.push({ y: cy, x: cx })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelecting(found.map(d => dates[d.y][d.x].str))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{date.day}</button>)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Month
|
||||||
|
|
||||||
|
interface Date {
|
||||||
|
str: string
|
||||||
|
day: number
|
||||||
|
month: number
|
||||||
|
isToday: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the dates to show for the month in a 2d array */
|
||||||
|
const calculateMonth = ({ month, year }: { month: number, year: number }, weekStart: 0 | 1) => {
|
||||||
|
const date = dayjs().month(month).year(year)
|
||||||
|
const daysInMonth = date.daysInMonth()
|
||||||
|
const daysBefore = date.date(1).day() - weekStart
|
||||||
|
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
|
||||||
|
|
||||||
|
const dates: Date[][] = []
|
||||||
|
let curDate = date.date(1).subtract(daysBefore, 'day')
|
||||||
|
let y = 0
|
||||||
|
let x = 0
|
||||||
|
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
|
||||||
|
if (x === 0) dates[y] = []
|
||||||
|
dates[y][x] = {
|
||||||
|
str: curDate.format('DDMMYYYY'),
|
||||||
|
day: curDate.date(),
|
||||||
|
month: curDate.month(),
|
||||||
|
isToday: curDate.isToday(),
|
||||||
|
}
|
||||||
|
curDate = curDate.add(1, 'day')
|
||||||
|
x++
|
||||||
|
if (x > 6) {
|
||||||
|
x = 0
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import dayjs from '/src/config/dayjs'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
// Use styles from Month picker
|
||||||
|
import styles from '../Month/Month.module.scss'
|
||||||
|
|
||||||
|
// TODO: use from giraugh tools
|
||||||
|
export const rotateArray = <T,>(arr: T[], amount = 1): T[] =>
|
||||||
|
arr.map((_, i) => arr[((( -amount + i ) % arr.length) + arr.length) % arr.length])
|
||||||
|
|
||||||
|
interface WeekdaysProps {
|
||||||
|
/** Array of weekdays as numbers from 0-6 (as strings) */
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Weekdays = ({ value, onChange }: WeekdaysProps) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const weekStart = useSettingsStore(state => state.weekStart)
|
||||||
|
|
||||||
|
const weekdays = useMemo(() => rotateArray(dayjs.weekdaysShort().map((name, i) => ({
|
||||||
|
name,
|
||||||
|
isToday: dayjs().day() === i,
|
||||||
|
str: String(i),
|
||||||
|
})), -weekStart), [weekStart])
|
||||||
|
|
||||||
|
// Ref and state required to rerender but also access static version in callbacks
|
||||||
|
const selectingRef = useRef<string[]>([])
|
||||||
|
const [selecting, _setSelecting] = useState<string[]>([])
|
||||||
|
const setSelecting = useCallback((v: string[]) => {
|
||||||
|
selectingRef.current = v
|
||||||
|
_setSelecting(v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPos = useRef(0)
|
||||||
|
const mode = useRef<'add' | 'remove'>()
|
||||||
|
|
||||||
|
const handleFinishSelection = useCallback(() => {
|
||||||
|
if (mode.current === 'add') {
|
||||||
|
onChange([...value, ...selectingRef.current])
|
||||||
|
} else {
|
||||||
|
onChange(value.filter(d => !selectingRef.current.includes(d)))
|
||||||
|
}
|
||||||
|
mode.current = undefined
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return <div className={styles.grid}>
|
||||||
|
{weekdays.map((day, i) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={makeClass(
|
||||||
|
styles.date,
|
||||||
|
day.isToday && styles.today,
|
||||||
|
(
|
||||||
|
(!(mode.current === 'remove' && selecting.includes(day.str)) && value.includes(day.str))
|
||||||
|
|| (mode.current === 'add' && selecting.includes(day.str))
|
||||||
|
) && styles.selected,
|
||||||
|
)}
|
||||||
|
key={day.name}
|
||||||
|
title={day.isToday ? t<string>('form.dates.tooltips.today') : undefined}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
if (value.includes(day.str)) {
|
||||||
|
onChange(value.filter(d => d !== day.str))
|
||||||
|
} else {
|
||||||
|
onChange([...value, day.str])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
startPos.current = i
|
||||||
|
mode.current = value.includes(day.str) ? 'remove' : 'add'
|
||||||
|
setSelecting([day.str])
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', handleFinishSelection, { once: true })
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (mode.current) {
|
||||||
|
const found = []
|
||||||
|
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i) + 1; ci++) {
|
||||||
|
found.push(weekdays[ci].str)
|
||||||
|
}
|
||||||
|
setSelecting(found)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{day.name}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Weekdays
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
.form {
|
||||||
|
margin: 0 0 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
172
frontend/src/components/CreateForm/CreateForm.tsx
Normal file
172
frontend/src/components/CreateForm/CreateForm.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import CalendarField from '/src/components/CalendarField/CalendarField'
|
||||||
|
import { default as ErrorAlert } from '/src/components/Error/Error'
|
||||||
|
import TextField from '/src/components/TextField/TextField'
|
||||||
|
import ToggleField from '/src/components/ToggleField/ToggleField'
|
||||||
|
import { API_BASE } from '/src/config/api'
|
||||||
|
import dayjs from '/src/config/dayjs'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import timezones from '/src/res/timezones.json'
|
||||||
|
|
||||||
|
import styles from './CreateForm.module.scss'
|
||||||
|
|
||||||
|
interface Fields {
|
||||||
|
name: string
|
||||||
|
dates: string[]
|
||||||
|
time: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
timezone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues: Fields = {
|
||||||
|
name: '',
|
||||||
|
dates: [],
|
||||||
|
time: { start: 9, end: 17 },
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateForm = () => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
const { push } = useRouter()
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
} = useForm({ defaultValues })
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<React.ReactNode>()
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<Fields> = async values => {
|
||||||
|
console.log({values})
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(undefined)
|
||||||
|
|
||||||
|
const { name, dates, time, timezone } = values
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dates.length === 0) {
|
||||||
|
return setError(t('form.errors.no_dates'))
|
||||||
|
}
|
||||||
|
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
|
||||||
|
if (time.start === time.end) {
|
||||||
|
return setError(t('form.errors.same_times'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = dates.reduce((times, date) => {
|
||||||
|
const day = []
|
||||||
|
for (let i = time.start; i < (time.start > time.end ? 24 : time.end); i++) {
|
||||||
|
if (isSpecificDates) {
|
||||||
|
day.push(
|
||||||
|
dayjs.tz(date, 'DDMMYYYY', timezone)
|
||||||
|
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
day.push(
|
||||||
|
dayjs().tz(timezone)
|
||||||
|
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (time.start > time.end) {
|
||||||
|
for (let i = 0; i < time.end; i++) {
|
||||||
|
if (isSpecificDates) {
|
||||||
|
day.push(
|
||||||
|
dayjs.tz(date, 'DDMMYYYY', timezone)
|
||||||
|
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
day.push(
|
||||||
|
dayjs().tz(timezone)
|
||||||
|
.day(date).hour(i).minute(0).utc().format('HHmm-d')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...times, ...day]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (times.length === 0) {
|
||||||
|
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)
|
||||||
|
throw new Error('Failed to create event')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await res.json()
|
||||||
|
|
||||||
|
// Navigate to the new event
|
||||||
|
push(`/${id}`)
|
||||||
|
} catch (e) {
|
||||||
|
setError(t('form.errors.unknown'))
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <form className={styles.form} onSubmit={handleSubmit(onSubmit)} id="create">
|
||||||
|
<TextField
|
||||||
|
label={t('form.name.label')}
|
||||||
|
description={t('form.name.sublabel')}
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarField
|
||||||
|
label={t('form.dates.label')}
|
||||||
|
description={t('form.dates.sublabel')}
|
||||||
|
control={control}
|
||||||
|
name="dates"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <TimeRangeField
|
||||||
|
label={t('form.times.label')}
|
||||||
|
subLabel={t('form.times.sublabel')}
|
||||||
|
required
|
||||||
|
setValue={setValue}
|
||||||
|
{...register('time')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label={t('form.timezone.label')}
|
||||||
|
options={timezones}
|
||||||
|
required
|
||||||
|
{...register('timezone')}
|
||||||
|
defaultOption={t('form.timezone.defaultOption')}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<ErrorAlert onClose={() => setError(undefined)}>{error}</ErrorAlert>
|
||||||
|
|
||||||
|
<div className={styles.buttonWrapper}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>{t('form.button')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateForm
|
||||||
16
frontend/src/components/Field/Field.module.scss
Normal file
16
frontend/src/components/Field/Field.module.scss
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.wrapper {
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
22
frontend/src/components/Field/Field.tsx
Normal file
22
frontend/src/components/Field/Field.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import styles from './Field.module.scss'
|
||||||
|
|
||||||
|
interface WrapperProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Wrapper = (props: WrapperProps) =>
|
||||||
|
<div className={styles.wrapper} {...props} />
|
||||||
|
|
||||||
|
interface LabelProps {
|
||||||
|
htmlFor?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Label = (props: LabelProps) =>
|
||||||
|
<label className={styles.label} {...props} />
|
||||||
|
|
||||||
|
export const Description = (props: LabelProps) =>
|
||||||
|
<label className={styles.description} {...props} />
|
||||||
|
|
@ -6,7 +6,8 @@ import Content from '/src/components/Content/Content'
|
||||||
import Section from '/src/components/Section/Section'
|
import Section from '/src/components/Section/Section'
|
||||||
import dayjs from '/src/config/dayjs'
|
import dayjs from '/src/config/dayjs'
|
||||||
import { useTranslation } from '/src/i18n/client'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
import { useRecentsStore, useStore } from '/src/stores'
|
import { useStore } from '/src/stores'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
|
||||||
import styles from './Recents.module.scss'
|
import styles from './Recents.module.scss'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
StyledLabel,
|
|
||||||
StyledSubLabel,
|
|
||||||
StyledInput,
|
|
||||||
} from './TextField.styles'
|
|
||||||
|
|
||||||
const TextField = forwardRef(({
|
|
||||||
label,
|
|
||||||
subLabel,
|
|
||||||
id,
|
|
||||||
inline = false,
|
|
||||||
...props
|
|
||||||
}, ref) => (
|
|
||||||
<Wrapper $inline={inline}>
|
|
||||||
{label && <StyledLabel htmlFor={id} $inline={inline}>{label}</StyledLabel>}
|
|
||||||
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
|
|
||||||
<StyledInput id={id} ref={ref} {...props} />
|
|
||||||
</Wrapper>
|
|
||||||
))
|
|
||||||
|
|
||||||
export default TextField
|
|
||||||
19
frontend/src/components/TextField/TextField.module.scss
Normal file
19
frontend/src/components/TextField/TextField.module.scss
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface);
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
box-shadow: inset 0 0 0 0 var(--primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 18px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
box-shadow: inset 0 -3px 0 0 var(--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 30px 0;
|
|
||||||
|
|
||||||
${props => props.$inline && `
|
|
||||||
margin: 0;
|
|
||||||
`}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
|
|
||||||
${props => props.$inline && `
|
|
||||||
font-size: 16px;
|
|
||||||
`}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledSubLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: .6;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledInput = styled('input', forwardRef)`
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font: inherit;
|
|
||||||
background: var(--surface);
|
|
||||||
color: inherit;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
box-shadow: inset 0 0 0 0 var(--primary);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 18px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color .15s, box-shadow .15s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: 1px solid var(--secondary);
|
|
||||||
box-shadow: inset 0 -3px 0 0 var(--secondary);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
31
frontend/src/components/TextField/TextField.tsx
Normal file
31
frontend/src/components/TextField/TextField.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { Description, Label, Wrapper } from '/src/components/Field/Field'
|
||||||
|
|
||||||
|
import styles from './TextField.module.scss'
|
||||||
|
|
||||||
|
interface TextFieldProps extends React.ComponentProps<'input'> {
|
||||||
|
label?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
isInline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
isInline,
|
||||||
|
...props
|
||||||
|
}, ref) => (
|
||||||
|
<Wrapper style={isInline ? { margin: 0 } : undefined}>
|
||||||
|
{label && <Label
|
||||||
|
htmlFor={props.name}
|
||||||
|
style={isInline ? { fontSize: '16px' } : undefined}
|
||||||
|
>{label}</Label>}
|
||||||
|
|
||||||
|
{description && <Description htmlFor={props.name}>{description}</Description>}
|
||||||
|
|
||||||
|
<input className={styles.input} id={props.name} ref={ref} {...props} />
|
||||||
|
</Wrapper>
|
||||||
|
))
|
||||||
|
|
||||||
|
export default TextField
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Info } from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
ToggleContainer,
|
|
||||||
StyledLabel,
|
|
||||||
Option,
|
|
||||||
HiddenInput,
|
|
||||||
LabelButton,
|
|
||||||
} from './ToggleField.styles'
|
|
||||||
|
|
||||||
const ToggleField = ({
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
title = '',
|
|
||||||
options = [],
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
inputRef,
|
|
||||||
}) => (
|
|
||||||
<Wrapper>
|
|
||||||
{label && <StyledLabel title={title}>{label} {title !== '' && <Info />}</StyledLabel>}
|
|
||||||
|
|
||||||
<ToggleContainer>
|
|
||||||
{Object.entries(options).map(([key, label]) =>
|
|
||||||
<Option key={label}>
|
|
||||||
<HiddenInput
|
|
||||||
type="radio"
|
|
||||||
name={name}
|
|
||||||
value={label}
|
|
||||||
id={`${name}-${label}`}
|
|
||||||
checked={value === key}
|
|
||||||
onChange={() => onChange(key)}
|
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
<LabelButton htmlFor={`${name}-${label}`}>{label}</LabelButton>
|
|
||||||
</Option>
|
|
||||||
)}
|
|
||||||
</ToggleContainer>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default ToggleField
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.toggleContainer {
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 10px 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const ToggleContainer = styled('div')`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
@ -27,26 +20,14 @@ export const ToggleContainer = styled('div')`
|
||||||
& > div:last-of-type label {
|
& > div:last-of-type label {
|
||||||
border-end-end-radius: 2px;
|
border-end-end-radius: 2px;
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const StyledLabel = styled('label')`
|
.option {
|
||||||
display: block;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
font-size: .9rem;
|
|
||||||
|
|
||||||
& svg {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Option = styled('div')`
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const HiddenInput = styled('input', forwardRef)`
|
.hiddenInput {
|
||||||
height: 0;
|
height: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -59,9 +40,9 @@ export const HiddenInput = styled('input', forwardRef)`
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
background-color: var(--focus-color);
|
background-color: var(--focus-color);
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const LabelButton = styled('label')`
|
.button {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -72,4 +53,4 @@ export const LabelButton = styled('label')`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: box-shadow .15s, background-color .15s;
|
transition: box-shadow .15s, background-color .15s;
|
||||||
`
|
}
|
||||||
47
frontend/src/components/ToggleField/ToggleField.tsx
Normal file
47
frontend/src/components/ToggleField/ToggleField.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Label, Wrapper } from '/src/components/Field/Field'
|
||||||
|
|
||||||
|
import styles from './ToggleField.module.scss'
|
||||||
|
|
||||||
|
interface ToggleFieldProps<TValue extends string> {
|
||||||
|
label?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
name: string
|
||||||
|
value: TValue
|
||||||
|
onChange: (value: TValue) => void
|
||||||
|
options: Record<TValue, React.ReactNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleField = <TValue extends string>({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: ToggleFieldProps<TValue>) => <Wrapper style={{ marginBlock: '10px' }}>
|
||||||
|
{/* TODO: Better description viewer */}
|
||||||
|
{label && <Label style={{ fontSize: '.9em' }} title={description as string}>
|
||||||
|
{label} {description && <Info size="1em" style={{ verticalAlign: 'middle' }} />}
|
||||||
|
</Label>}
|
||||||
|
|
||||||
|
<div className={styles.toggleContainer}>
|
||||||
|
{Object.entries(options).map(([key, label]) =>
|
||||||
|
<div className={styles.option} key={key}>
|
||||||
|
<input
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={key}
|
||||||
|
id={`${name}-${key}`}
|
||||||
|
checked={value === key}
|
||||||
|
onChange={() => onChange(key as TValue)}
|
||||||
|
/>
|
||||||
|
<label className={styles.button} htmlFor={`${name}-${key}`}>{label as React.ReactNode}</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
|
||||||
|
export default ToggleField
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||||
|
import isToday from 'dayjs/plugin/isToday'
|
||||||
|
import localeData from 'dayjs/plugin/localeData'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
import updateLocale from 'dayjs/plugin/updateLocale'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat)
|
||||||
|
dayjs.extend(isToday)
|
||||||
|
dayjs.extend(localeData)
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
dayjs.extend(updateLocale)
|
||||||
|
dayjs.extend(utc)
|
||||||
|
|
||||||
export default dayjs
|
export default dayjs
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
{
|
{
|
||||||
"create": "CREATE A",
|
"create": "CREATE A",
|
||||||
"recently_visited": "Recently visited",
|
"recently_visited": "Recently visited",
|
||||||
"nav": {
|
|
||||||
"about": "About",
|
|
||||||
"donate": "Donate"
|
|
||||||
},
|
|
||||||
"form": {
|
"form": {
|
||||||
"name": {
|
"name": {
|
||||||
"label": "Give your event a name!",
|
"label": "Give your event a name!",
|
||||||
|
|
@ -40,7 +36,6 @@
|
||||||
"unknown": "Something went wrong. Please try again later."
|
"unknown": "Something went wrong. Please try again later."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"offline": "You can't create a Crab Fit when you don't have an internet connection. Please make sure you're connected.",
|
|
||||||
|
|
||||||
"about": {
|
"about": {
|
||||||
"name": "About Crab Fit",
|
"name": "About Crab Fit",
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
|
||||||
import utc from 'dayjs/plugin/utc'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { Trans,useTranslation } from 'react-i18next'
|
|
||||||
import { Link,useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
CalendarField,
|
|
||||||
Center,
|
|
||||||
Error,
|
|
||||||
Footer,
|
|
||||||
Recents,
|
|
||||||
SelectField,
|
|
||||||
TextField,
|
|
||||||
TimeRangeField,
|
|
||||||
} from '/src/components'
|
|
||||||
import logo from '/src/res/logo.svg'
|
|
||||||
import timezones from '/src/res/timezones.json'
|
|
||||||
import video_thumb from '/src/res/video_thumb.jpg'
|
|
||||||
import api from '/src/services'
|
|
||||||
import { useTWAStore } from '/src/stores'
|
|
||||||
import { detect_browser } from '/src/utils'
|
|
||||||
|
|
||||||
import {
|
|
||||||
AboutSection,
|
|
||||||
ButtonArea,
|
|
||||||
CreateForm,
|
|
||||||
Links,
|
|
||||||
Logo,
|
|
||||||
OfflineMessage,
|
|
||||||
P,
|
|
||||||
Stat,
|
|
||||||
StatLabel,
|
|
||||||
StatNumber,
|
|
||||||
Stats,
|
|
||||||
StyledMain,
|
|
||||||
TitleLarge,
|
|
||||||
TitleSmall,
|
|
||||||
VideoLink,
|
|
||||||
VideoWrapper,
|
|
||||||
} from './Home.styles'
|
|
||||||
|
|
||||||
dayjs.extend(utc)
|
|
||||||
dayjs.extend(timezone)
|
|
||||||
dayjs.extend(customParseFormat)
|
|
||||||
|
|
||||||
const Home = ({ offline }) => {
|
|
||||||
const { register, handleSubmit, setValue } = useForm({
|
|
||||||
defaultValues: {
|
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [stats, setStats] = useState({
|
|
||||||
eventCount: null,
|
|
||||||
personCount: null,
|
|
||||||
version: 'loading...',
|
|
||||||
})
|
|
||||||
const [browser, setBrowser] = useState(undefined)
|
|
||||||
const [videoPlay, setVideoPlay] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { t } = useTranslation(['common', 'home'])
|
|
||||||
const isTWA = useTWAStore(state => state.TWA)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetch = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/stats')
|
|
||||||
setStats(response)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch()
|
|
||||||
document.title = 'Crab Fit'
|
|
||||||
setBrowser(detect_browser())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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 response = await api.post('/event', {
|
|
||||||
event: {
|
|
||||||
name: data.name,
|
|
||||||
times: times,
|
|
||||||
timezone: data.timezone,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
navigate(`/${response.id}`)
|
|
||||||
gtag('event', 'create_event', {
|
|
||||||
'event_category': 'home',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
setError(t('home:form.errors.unknown'))
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StyledMain>
|
|
||||||
<Center>
|
|
||||||
<Logo src={logo} alt="" />
|
|
||||||
</Center>
|
|
||||||
<TitleSmall $altChars={/^[A-Za-z ]+$/.test(t('home:create'))}>{t('home:create')}</TitleSmall>
|
|
||||||
<TitleLarge>CRAB FIT</TitleLarge>
|
|
||||||
<Links>
|
|
||||||
<a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
|
|
||||||
</Links>
|
|
||||||
</StyledMain>
|
|
||||||
|
|
||||||
<Recents />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button>
|
|
||||||
</Center>
|
|
||||||
</CreateForm>
|
|
||||||
)}
|
|
||||||
</StyledMain>
|
|
||||||
|
|
||||||
<AboutSection id="about">
|
|
||||||
<StyledMain>
|
|
||||||
<h2>{t('home:about.name')}</h2>
|
|
||||||
<Stats>
|
|
||||||
<Stat>
|
|
||||||
<StatNumber>{new Intl.NumberFormat().format(stats.eventCount ?? 7000)}{!stats.eventCount && '+'}</StatNumber>
|
|
||||||
<StatLabel>{t('home:about.events')}</StatLabel>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatNumber>{new Intl.NumberFormat().format(stats.personCount ?? 25000)}{!stats.personCount && '+'}</StatNumber>
|
|
||||||
<StatLabel>{t('home:about.availabilities')}</StatLabel>
|
|
||||||
</Stat>
|
|
||||||
</Stats>
|
|
||||||
<P><Trans i18nKey="home:about.content.p1">Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to" rel="help">Learn more about how to Crab Fit</Link>.</Trans></P>
|
|
||||||
|
|
||||||
{videoPlay ? (
|
|
||||||
<VideoWrapper>
|
|
||||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" title={t('common:video.title')} frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
|
|
||||||
</VideoWrapper>
|
|
||||||
) : (
|
|
||||||
<VideoLink
|
|
||||||
href="https://www.youtube.com/watch?v=yXGd4VXZzcY"
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
setVideoPlay(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={video_thumb} alt={t('common:video.button')} />
|
|
||||||
<span>{t('common:video.button')}</span>
|
|
||||||
</VideoLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!document.referrer.includes('android-app://fit.crab') && (
|
|
||||||
<ButtonArea>
|
|
||||||
{['chrome', 'firefox', 'safari'].includes(browser) && (
|
|
||||||
<Button
|
|
||||||
href={{
|
|
||||||
chrome: 'https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj',
|
|
||||||
firefox: 'https://addons.mozilla.org/en-US/firefox/addon/crab-fit/',
|
|
||||||
safari: 'https://apps.apple.com/us/app/crab-fit/id1570803259',
|
|
||||||
}[browser]}
|
|
||||||
icon={{
|
|
||||||
chrome: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>,
|
|
||||||
firefox: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M9.27 7.94C9.27 7.94 9.27 7.94 9.27 7.94M6.85 6.74C6.86 6.74 6.86 6.74 6.85 6.74M21.28 8.6C20.85 7.55 19.96 6.42 19.27 6.06C19.83 7.17 20.16 8.28 20.29 9.1L20.29 9.12C19.16 6.3 17.24 5.16 15.67 2.68C15.59 2.56 15.5 2.43 15.43 2.3C15.39 2.23 15.36 2.16 15.32 2.09C15.26 1.96 15.2 1.83 15.17 1.69C15.17 1.68 15.16 1.67 15.15 1.67H15.13L15.12 1.67L15.12 1.67L15.12 1.67C12.9 2.97 11.97 5.26 11.74 6.71C11.05 6.75 10.37 6.92 9.75 7.22C9.63 7.27 9.58 7.41 9.62 7.53C9.67 7.67 9.83 7.74 9.96 7.68C10.5 7.42 11.1 7.27 11.7 7.23L11.75 7.23C11.83 7.22 11.92 7.22 12 7.22C12.5 7.21 12.97 7.28 13.44 7.42L13.5 7.44C13.6 7.46 13.67 7.5 13.75 7.5C13.8 7.54 13.86 7.56 13.91 7.58L14.05 7.64C14.12 7.67 14.19 7.7 14.25 7.73C14.28 7.75 14.31 7.76 14.34 7.78C14.41 7.82 14.5 7.85 14.54 7.89C14.58 7.91 14.62 7.94 14.66 7.96C15.39 8.41 16 9.03 16.41 9.77C15.88 9.4 14.92 9.03 14 9.19C17.6 11 16.63 17.19 11.64 16.95C11.2 16.94 10.76 16.85 10.34 16.7C10.24 16.67 10.14 16.63 10.05 16.58C10 16.56 9.93 16.53 9.88 16.5C8.65 15.87 7.64 14.68 7.5 13.23C7.5 13.23 8 11.5 10.83 11.5C11.14 11.5 12 10.64 12.03 10.4C12.03 10.31 10.29 9.62 9.61 8.95C9.24 8.59 9.07 8.42 8.92 8.29C8.84 8.22 8.75 8.16 8.66 8.1C8.43 7.3 8.42 6.45 8.63 5.65C7.6 6.12 6.8 6.86 6.22 7.5H6.22C5.82 7 5.85 5.35 5.87 5C5.86 5 5.57 5.16 5.54 5.18C5.19 5.43 4.86 5.71 4.56 6C4.21 6.37 3.9 6.74 3.62 7.14C3 8.05 2.5 9.09 2.28 10.18C2.28 10.19 2.18 10.59 2.11 11.1L2.08 11.33C2.06 11.5 2.04 11.65 2 11.91L2 11.94L2 12.27L2 12.32C2 17.85 6.5 22.33 12 22.33C16.97 22.33 21.08 18.74 21.88 14C21.9 13.89 21.91 13.76 21.93 13.63C22.13 11.91 21.91 10.11 21.28 8.6Z" /></svg>,
|
|
||||||
safari: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,14.09 4.8,16 6.11,17.41L9.88,9.88L17.41,6.11C16,4.8 14.09,4 12,4M12,20A8,8 0 0,0 20,12C20,9.91 19.2,8 17.89,6.59L14.12,14.12L6.59,17.89C8,19.2 9.91,20 12,20M12,12L11.23,11.23L9.7,14.3L12.77,12.77L12,12M12,17.5H13V19H12V17.5M15.88,15.89L16.59,15.18L17.65,16.24L16.94,16.95L15.88,15.89M17.5,12V11H19V12H17.5M12,6.5H11V5H12V6.5M8.12,8.11L7.41,8.82L6.35,7.76L7.06,7.05L8.12,8.11M6.5,12V13H5V12H6.5Z" /></svg>,
|
|
||||||
}[browser]}
|
|
||||||
onClick={() => gtag('event', `download_extension_${browser}`, { 'event_category': 'home'})}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
secondary
|
|
||||||
>{{
|
|
||||||
chrome: t('home:about.chrome_extension'),
|
|
||||||
firefox: t('home:about.firefox_extension'),
|
|
||||||
safari: t('home:about.safari_extension'),
|
|
||||||
}[browser]}</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
href="https://play.google.com/store/apps/details?id=fit.crab"
|
|
||||||
icon={<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z" /></svg>}
|
|
||||||
onClick={() => gtag('event', 'download_android_app', { 'event_category': 'home' })}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
secondary
|
|
||||||
>{t('home:about.android_app')}</Button>
|
|
||||||
</ButtonArea>
|
|
||||||
)}
|
|
||||||
<P><Trans i18nKey="home:about.content.p3">Created by <a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</Trans></P>
|
|
||||||
<P><Trans i18nKey="home:about.content.p4">The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">repository</a>. By using Crab Fit you agree to the <Link to="/privacy" rel="license">privacy policy</Link>.</Trans></P>
|
|
||||||
<P>{t('home:about.content.p6')}</P>
|
|
||||||
<P>{t('home:about.content.p5')}</P>
|
|
||||||
</StyledMain>
|
|
||||||
</AboutSection>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
// export { default as useSettingsStore } from './settingsStore'
|
|
||||||
export { default as useRecentsStore } from './recentsStore'
|
|
||||||
// export { default as useTWAStore } from './twaStore'
|
|
||||||
// export { default as useLocaleUpdateStore } from './localeUpdateStore'
|
|
||||||
// export { default as useTranslateStore } from './translateStore'
|
|
||||||
|
|
||||||
/** Helper to use a persisted store in zustand with Next js without causing a hydration error */
|
/** Helper to use a persisted store in zustand with Next js without causing a hydration error */
|
||||||
export const useStore = <T, F>(
|
export const useStore = <T, F>(
|
||||||
store: (callback?: (state: T) => unknown) => unknown,
|
store: (callback?: (state: T) => unknown) => unknown,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ type TimeFormat = '12h' | '24h'
|
||||||
type Theme = 'System' | 'Light' | 'Dark'
|
type Theme = 'System' | 'Light' | 'Dark'
|
||||||
|
|
||||||
interface SettingsStore {
|
interface SettingsStore {
|
||||||
weekStart: number
|
weekStart: 0 | 1
|
||||||
timeFormat: TimeFormat
|
timeFormat: TimeFormat
|
||||||
theme: Theme
|
theme: Theme
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
colormap: string
|
colormap: string
|
||||||
|
|
||||||
setWeekStart: (weekStart: number) => void
|
setWeekStart: (weekStart: 0 | 1) => void
|
||||||
setTimeFormat: (timeFormat: TimeFormat) => void
|
setTimeFormat: (timeFormat: TimeFormat) => void
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
setHighlight: (highlight: boolean) => void
|
setHighlight: (highlight: boolean) => void
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue