Rebuild TextField and CalendarField

This commit is contained in:
Ben Grant 2023-05-21 21:34:06 +10:00
parent 1e77205518
commit 12004b8584
28 changed files with 783 additions and 845 deletions

View file

@ -5,7 +5,10 @@
"rules": {
"react/no-unescaped-entities": "off",
"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": [
{

View file

@ -1,4 +0,0 @@
.nav {
text-align: center;
margin: 20px 0;
}

View file

@ -1,8 +1,8 @@
import { Trans } from 'react-i18next/TransWithoutContext'
import Link from 'next/link'
import Button from '/src/components/Button/Button'
import Content from '/src/components/Content/Content'
import CreateForm from '/src/components/CreateForm/CreateForm'
import DownloadButtons from '/src/components/DownloadButtons/DownloadButtons'
import Footer from '/src/components/Footer/Footer'
import Header from '/src/components/Header/Header'
@ -13,8 +13,6 @@ import Stats from '/src/components/Stats/Stats'
import Video from '/src/components/Video/Video'
import { useTranslation } from '/src/i18n/server'
import styles from './home.module.scss'
const Page = async () => {
const { t } = await useTranslation('home')
@ -22,19 +20,12 @@ const Page = async () => {
<Content>
{/* @ts-expect-error Async Server Component */}
<Header isFull />
<nav className={styles.nav}>
<a href="#about">{t('nav.about')}</a>
{' / '}
<a href="#donate">{t('nav.donate')}</a>
</nav>
</Content>
<Recents />
<Content>
<span>Form here</span>
<Button>Create</Button>
<CreateForm />
</Content>
<Section id="about">

View file

@ -58,6 +58,16 @@
}
}
.iconButton {
height: 30px;
width: 30px;
padding: 0;
& svg, & img {
margin: 0;
}
}
.small {
padding: .4em 1.3em;
}

View file

@ -16,8 +16,6 @@ type ButtonProps = {
surfaceColor?: string
/** Override the shadow color of the button */
shadowColor?: string
// TODO: evaluate
size?: string
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
const Button: React.FC<ButtonProps> = ({
@ -30,7 +28,6 @@ const Button: React.FC<ButtonProps> = ({
isLoading,
surfaceColor,
shadowColor,
size,
style,
...props
}) => {
@ -40,14 +37,14 @@ const Button: React.FC<ButtonProps> = ({
isSecondary && styles.secondary,
isSmall && styles.small,
isLoading && styles.loading,
!children && icon && styles.iconButton,
),
style: {
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
...shadowColor && { '--override-shadow-color': shadowColor },
...size && { padding: 0, height: size, width: size },
...style,
},
children: [icon, children],
children: <>{icon}{children}</>,
...props,
}

View file

@ -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)
}
}}
>&lt;</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)
}
}}
>&gt;</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

View file

@ -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')};
`}
`

View 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

View file

@ -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);
}
}

View 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
}

View file

@ -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

View file

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

View 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

View 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;
}

View 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} />

View file

@ -6,7 +6,8 @@ import Content from '/src/components/Content/Content'
import Section from '/src/components/Section/Section'
import dayjs from '/src/config/dayjs'
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'

View file

@ -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

View 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);
}
}

View file

@ -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);
}
`

View 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

View file

@ -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

View file

@ -1,11 +1,4 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 10px 0;
`
export const ToggleContainer = styled('div')`
.toggleContainer {
display: flex;
border: 1px solid var(--primary);
border-radius: 3px;
@ -27,26 +20,14 @@ export const ToggleContainer = styled('div')`
& > div:last-of-type label {
border-end-end-radius: 2px;
}
`
}
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: .9rem;
& svg {
height: 1em;
width: 1em;
vertical-align: middle;
}
`
export const Option = styled('div')`
.option {
flex: 1;
position: relative;
`
}
export const HiddenInput = styled('input', forwardRef)`
.hiddenInput {
height: 0;
width: 0;
position: absolute;
@ -59,9 +40,9 @@ export const HiddenInput = styled('input', forwardRef)`
color: var(--background);
background-color: var(--focus-color);
}
`
}
export const LabelButton = styled('label')`
.button {
padding: 6px;
display: flex;
text-align: center;
@ -72,4 +53,4 @@ export const LabelButton = styled('label')`
align-items: center;
justify-content: center;
transition: box-shadow .15s, background-color .15s;
`
}

View 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

View file

@ -1,6 +1,18 @@
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 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(timezone)
dayjs.extend(updateLocale)
dayjs.extend(utc)
export default dayjs

View file

@ -1,10 +1,6 @@
{
"create": "CREATE A",
"recently_visited": "Recently visited",
"nav": {
"about": "About",
"donate": "Donate"
},
"form": {
"name": {
"label": "Give your event a name!",
@ -40,7 +36,6 @@
"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": {
"name": "About Crab Fit",

View file

@ -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

View file

@ -1,11 +1,5 @@
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 */
export const useStore = <T, F>(
store: (callback?: (state: T) => unknown) => unknown,

View file

@ -5,13 +5,13 @@ type TimeFormat = '12h' | '24h'
type Theme = 'System' | 'Light' | 'Dark'
interface SettingsStore {
weekStart: number
weekStart: 0 | 1
timeFormat: TimeFormat
theme: Theme
highlight: boolean
colormap: string
setWeekStart: (weekStart: number) => void
setWeekStart: (weekStart: 0 | 1) => void
setTimeFormat: (timeFormat: TimeFormat) => void
setTheme: (theme: Theme) => void
setHighlight: (highlight: boolean) => void