From 12004b8584e6f80afb391d88b15e4f3150bc7880 Mon Sep 17 00:00:00 2001 From: Ben Grant Date: Sun, 21 May 2023 21:34:06 +1000 Subject: [PATCH] Rebuild TextField and CalendarField --- frontend/.eslintrc.json | 5 +- frontend/src/app/home.module.scss | 4 - frontend/src/app/page.tsx | 13 +- .../src/components/Button/Button.module.scss | 10 + frontend/src/components/Button/Button.tsx | 7 +- .../CalendarField/CalendarField.jsx | 264 --------------- .../CalendarField/CalendarField.styles.js | 104 ------ .../CalendarField/CalendarField.tsx | 61 ++++ .../components/Month/Month.module.scss | 86 +++++ .../CalendarField/components/Month/Month.tsx | 182 +++++++++++ .../components/Weekdays/Weekdays.tsx | 97 ++++++ .../CreateForm/CreateForm.module.scss | 8 + .../src/components/CreateForm/CreateForm.tsx | 172 ++++++++++ .../src/components/Field/Field.module.scss | 16 + frontend/src/components/Field/Field.tsx | 22 ++ frontend/src/components/Recents/Recents.tsx | 3 +- .../src/components/TextField/TextField.jsx | 24 -- .../TextField/TextField.module.scss | 19 ++ .../components/TextField/TextField.styles.js | 47 --- .../src/components/TextField/TextField.tsx | 31 ++ .../components/ToggleField/ToggleField.jsx | 43 --- ...ield.styles.js => ToggleField.module.scss} | 35 +- .../components/ToggleField/ToggleField.tsx | 47 +++ frontend/src/config/dayjs.ts | 12 + frontend/src/i18n/locales/en/home.json | 5 - frontend/src/pages-old/Home/Home.jsx | 301 ------------------ frontend/src/stores/index.ts | 6 - frontend/src/stores/settingsStore.ts | 4 +- 28 files changed, 783 insertions(+), 845 deletions(-) delete mode 100644 frontend/src/app/home.module.scss delete mode 100644 frontend/src/components/CalendarField/CalendarField.jsx delete mode 100644 frontend/src/components/CalendarField/CalendarField.styles.js create mode 100644 frontend/src/components/CalendarField/CalendarField.tsx create mode 100644 frontend/src/components/CalendarField/components/Month/Month.module.scss create mode 100644 frontend/src/components/CalendarField/components/Month/Month.tsx create mode 100644 frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx create mode 100644 frontend/src/components/CreateForm/CreateForm.module.scss create mode 100644 frontend/src/components/CreateForm/CreateForm.tsx create mode 100644 frontend/src/components/Field/Field.module.scss create mode 100644 frontend/src/components/Field/Field.tsx delete mode 100644 frontend/src/components/TextField/TextField.jsx create mode 100644 frontend/src/components/TextField/TextField.module.scss delete mode 100644 frontend/src/components/TextField/TextField.styles.js create mode 100644 frontend/src/components/TextField/TextField.tsx delete mode 100644 frontend/src/components/ToggleField/ToggleField.jsx rename frontend/src/components/ToggleField/{ToggleField.styles.js => ToggleField.module.scss} (65%) create mode 100644 frontend/src/components/ToggleField/ToggleField.tsx delete mode 100644 frontend/src/pages-old/Home/Home.jsx diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 58035d5..3a8a688 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -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": [ { diff --git a/frontend/src/app/home.module.scss b/frontend/src/app/home.module.scss deleted file mode 100644 index 85e7903..0000000 --- a/frontend/src/app/home.module.scss +++ /dev/null @@ -1,4 +0,0 @@ -.nav { - text-align: center; - margin: 20px 0; -} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 22a099d..9be3966 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 () => { {/* @ts-expect-error Async Server Component */}
- - - Form here - +
diff --git a/frontend/src/components/Button/Button.module.scss b/frontend/src/components/Button/Button.module.scss index f32d3d4..1c1d4ea 100644 --- a/frontend/src/components/Button/Button.module.scss +++ b/frontend/src/components/Button/Button.module.scss @@ -58,6 +58,16 @@ } } +.iconButton { + height: 30px; + width: 30px; + padding: 0; + + & svg, & img { + margin: 0; + } +} + .small { padding: .4em 1.3em; } diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx index 46f6349..7250954 100644 --- a/frontend/src/components/Button/Button.tsx +++ b/frontend/src/components/Button/Button.tsx @@ -16,8 +16,6 @@ type ButtonProps = { surfaceColor?: string /** Override the shadow color of the button */ shadowColor?: string - // TODO: evaluate - size?: string } & Omit & React.ComponentProps<'a'>, 'ref'> const Button: React.FC = ({ @@ -30,7 +28,6 @@ const Button: React.FC = ({ isLoading, surfaceColor, shadowColor, - size, style, ...props }) => { @@ -40,14 +37,14 @@ const Button: React.FC = ({ 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, } diff --git a/frontend/src/components/CalendarField/CalendarField.jsx b/frontend/src/components/CalendarField/CalendarField.jsx deleted file mode 100644 index 478994f..0000000 --- a/frontend/src/components/CalendarField/CalendarField.jsx +++ /dev/null @@ -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 ( - - {label && {label}} - {subLabel && {subLabel}} - - - setType(value === 'specific' ? 0 : 1)} - /> - - {type === 0 ? ( - <> - - - {dayjs.months()[month]} {year} - - - - - {(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name => - {name} - )} - - - {dates.length > 0 && dates.map((dateRow, y) => - dateRow.map((date, x) => - { - 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()} - ) - )} - - - ) : ( - - {(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map((name, 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} - )} - - )} - - ) -}) - -export default CalendarField diff --git a/frontend/src/components/CalendarField/CalendarField.styles.js b/frontend/src/components/CalendarField/CalendarField.styles.js deleted file mode 100644 index c85554f..0000000 --- a/frontend/src/components/CalendarField/CalendarField.styles.js +++ /dev/null @@ -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')}; - `} -` diff --git a/frontend/src/components/CalendarField/CalendarField.tsx b/frontend/src/components/CalendarField/CalendarField.tsx new file mode 100644 index 0000000..f17bc70 --- /dev/null +++ b/frontend/src/components/CalendarField/CalendarField.tsx @@ -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 extends UseControllerProps { + label?: React.ReactNode + description?: React.ReactNode +} + +const CalendarField = ({ + label, + description, + ...props +}: CalendarFieldProps) => { + const { t } = useTranslation('home') + + const { field } = useController(props) + + const [type, setType] = useState<'specific' | 'week'>('specific') + + const [innerValue, setInnerValue] = useState({ + specific: [], + week: [], + } satisfies Record) + + useEffect(() => { + setInnerValue({ ...innerValue, [type]: field.value }) + }, [type, field.value]) + + return + {label && } + {description && {description}} + + { + setType(t) + field.onChange(innerValue[t]) + }} + /> + + {type === 'specific' ? ( + + ) : ( + + )} + +} + +export default CalendarField diff --git a/frontend/src/components/CalendarField/components/Month/Month.module.scss b/frontend/src/components/CalendarField/components/Month/Month.module.scss new file mode 100644 index 0000000..82b964f --- /dev/null +++ b/frontend/src/components/CalendarField/components/Month/Month.module.scss @@ -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); + } +} diff --git a/frontend/src/components/CalendarField/components/Month/Month.tsx b/frontend/src/components/CalendarField/components/Month/Month.tsx new file mode 100644 index 0000000..c2c4e09 --- /dev/null +++ b/frontend/src/components/CalendarField/components/Month/Month.tsx @@ -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 = (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([]) + const [selecting, _setSelecting] = useState([]) + 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 <> +
+
+ +
+ {(rotateArray(dayjs.weekdaysShort(), -weekStart)).map(name => + + )} +
+ +
+ {dates.length > 0 && dates.map((dateRow, y) => + dateRow.map((date, x) => ) + )} +
+ +} + +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 +} diff --git a/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx b/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx new file mode 100644 index 0000000..3fe591a --- /dev/null +++ b/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx @@ -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 = (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([]) + const [selecting, _setSelecting] = useState([]) + 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
+ {weekdays.map((day, i) => + + )} +
+} + +export default Weekdays diff --git a/frontend/src/components/CreateForm/CreateForm.module.scss b/frontend/src/components/CreateForm/CreateForm.module.scss new file mode 100644 index 0000000..66f3f75 --- /dev/null +++ b/frontend/src/components/CreateForm/CreateForm.module.scss @@ -0,0 +1,8 @@ +.form { + margin: 0 0 60px; +} + +.buttonWrapper { + display: flex; + justify-content: center; +} diff --git a/frontend/src/components/CreateForm/CreateForm.tsx b/frontend/src/components/CreateForm/CreateForm.tsx new file mode 100644 index 0000000..690fb31 --- /dev/null +++ b/frontend/src/components/CreateForm/CreateForm.tsx @@ -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() + + const onSubmit: SubmitHandler = 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
+ + + + + {/* + + */} + + setError(undefined)}>{error} + +
+ +
+ +} + +export default CreateForm diff --git a/frontend/src/components/Field/Field.module.scss b/frontend/src/components/Field/Field.module.scss new file mode 100644 index 0000000..39c7a00 --- /dev/null +++ b/frontend/src/components/Field/Field.module.scss @@ -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; +} diff --git a/frontend/src/components/Field/Field.tsx b/frontend/src/components/Field/Field.tsx new file mode 100644 index 0000000..0b49696 --- /dev/null +++ b/frontend/src/components/Field/Field.tsx @@ -0,0 +1,22 @@ +import styles from './Field.module.scss' + +interface WrapperProps { + children: React.ReactNode + style?: React.CSSProperties +} + +export const Wrapper = (props: WrapperProps) => +
+ +interface LabelProps { + htmlFor?: string + children: React.ReactNode + style?: React.CSSProperties + title?: string +} + +export const Label = (props: LabelProps) => +