+
+
+ {rows.map((row, i) =>
+
+ {row &&
+ {row.label}
+ }
+
+ )}
+
+
+ {columns.map((column, x) =>
+ {column ?
+ {column.header.dateLabel &&
{column.header.dateLabel} }
+
{column.header.weekdayLabel}
+
+
+ {column.cells.map((cell, y) => {
+ if (y === column.cells.length - 1) return null
+
+ if (!cell) return
('greyed_times')}
+ />
+
+ const isSelected = (
+ (!(mode.current === 'remove' && selecting.includes(cell.serialized)) && value.includes(cell.serialized))
+ || (mode.current === 'add' && selecting.includes(cell.serialized))
+ )
+
+ return
{
+ e.preventDefault()
+ startPos.current = { x, y }
+ mode.current = value.includes(cell.serialized) ? 'remove' : 'add'
+ setSelecting([cell.serialized])
+ e.currentTarget.releasePointerCapture(e.pointerId)
+
+ document.addEventListener('pointerup', () => {
+ if (mode.current === 'add') {
+ onChange([...value, ...selectingRef.current])
+ } else if (mode.current === 'remove') {
+ onChange(value.filter(t => !selectingRef.current.includes(t)))
+ }
+ setSelecting([])
+ mode.current = undefined
+ }, { once: true })
+ }}
+ onPointerEnter={() => {
+ if (mode.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 })
+ }
+ }
+ setSelecting(found.flatMap(d => {
+ const serialized = columns[d.x]?.cells[d.y]?.serialized
+ if (serialized && times.includes(serialized)) {
+ return [serialized]
+ }
+ return []
+ }))
+ }
+ }}
+ />
+ })}
+
+
:
}
+ )}
+
+
+
+ >
+}
+
+export default AvailabilityEditor
diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.jsx b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.jsx
deleted file mode 100644
index dd032eb..0000000
--- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.jsx
+++ /dev/null
@@ -1,235 +0,0 @@
-import { useState, useEffect, useRef, useMemo, Fragment } from 'react'
-import { useTranslation } from 'react-i18next'
-import dayjs from 'dayjs'
-import localeData from 'dayjs/plugin/localeData'
-import customParseFormat from 'dayjs/plugin/customParseFormat'
-import relativeTime from 'dayjs/plugin/relativeTime'
-import { createPalette } from 'hue-map'
-
-import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
-
-import { Legend } from '/src/components'
-import {
- Wrapper,
- ScrollWrapper,
- Container,
- Date,
- Times,
- DateLabel,
- DayLabel,
- Time,
- Spacer,
- Tooltip,
- TooltipTitle,
- TooltipDate,
- TooltipContent,
- TooltipPerson,
- TimeLabels,
- TimeLabel,
- TimeSpace,
- People,
- Person,
- StyledMain,
- Info,
-} from './AvailabilityViewer.styles'
-
-import locales from '/src/i18n/locales'
-
-dayjs.extend(localeData)
-dayjs.extend(customParseFormat)
-dayjs.extend(relativeTime)
-
-const AvailabilityViewer = ({
- times,
- timeLabels,
- dates,
- isSpecificDates,
- people = [],
- min = 0,
- max = 0,
-}) => {
- const [tooltip, setTooltip] = useState(null)
- const timeFormat = useSettingsStore(state => state.timeFormat)
- const highlight = useSettingsStore(state => state.highlight)
- const colormap = useSettingsStore(state => state.colormap)
- const [filteredPeople, setFilteredPeople] = useState([])
- const [touched, setTouched] = useState(false)
- const [tempFocus, setTempFocus] = useState(null)
- const [focusCount, setFocusCount] = useState(null)
-
- const { t } = useTranslation('event')
- const locale = useLocaleUpdateStore(state => state.locale)
-
- const wrapper = useRef()
-
- useEffect(() => {
- setFilteredPeople(people.map(p => p.name))
- setTouched(people.length <= 1)
- }, [people])
-
- const [palette, setPalette] = useState([])
-
- useEffect(() => setPalette(createPalette({
- map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
- steps: tempFocus !== null ? 2 : Math.min(max, filteredPeople.length)+1,
- }).format()), [tempFocus, filteredPeople, max, colormap])
-
- const heatmap = useMemo(() => (
-
-
- {!!timeLabels.length && timeLabels.map((label, i) =>
-
- {label.label?.length !== '' && {label.label} }
-
- )}
-
- {dates.map((date, i) => {
- const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
- const last = dates.length === i+1 || (isSpecificDates ? dayjs(dates[i+1], 'DDMMYYYY') : dayjs().day(dates[i+1])).diff(parsedDate, 'day') > 1
- return (
-
-
- {isSpecificDates && {parsedDate.format('MMM D')} }
- {parsedDate.format('ddd')}
-
- 1}
- >
- {timeLabels.map((timeLabel, i) => {
- if (!timeLabel.time) return null
- if (!times.includes(`${timeLabel.time}-${date}`)) {
- return (
-
- )
- }
- const time = `${timeLabel.time}-${date}`
- const peopleHere = tempFocus !== null
- ? people.filter(person => person.availability.includes(time) && tempFocus === person.name).map(person => person.name)
- : people.filter(person => person.availability.includes(time) && filteredPeople.includes(person.name)).map(person => person.name)
-
- return (
- {
- const cellBox = e.currentTarget.getBoundingClientRect()
- const wrapperBox = wrapper?.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
- const timeText = timeFormat === '12h' ? `h${locales[locale]?.separator ?? ':'}mma` : `HH${locales[locale]?.separator ?? ':'}mm`
- setTooltip({
- x: Math.round(cellBox.x-wrapperBox.x + cellBox.width/2),
- y: Math.round(cellBox.y-wrapperBox.y + cellBox.height)+6,
- available: `${peopleHere.length} / ${filteredPeople.length} ${t('event:available')}`,
- date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`),
- people: peopleHere,
- })
- }}
- onMouseLeave={() => {
- setTooltip(null)
- }}
- />
- )
- })}
-
-
- {last && dates.length !== i+1 && }
-
- )
- })}
-
- ), [
- people,
- filteredPeople,
- tempFocus,
- focusCount,
- highlight,
- locale,
- dates,
- isSpecificDates,
- max,
- min,
- t,
- timeFormat,
- timeLabels,
- times,
- palette,
- ])
-
- return (
- <>
-
- setFocusCount(count)}
- />
- {t('event:group.info1')}
- {people.length > 1 && (
- <>
- {t('event:group.info2')}
-
- {people.map((person, i) =>
- {
- setTempFocus(null)
- if (filteredPeople.includes(person.name)) {
- if (!touched) {
- setTouched(true)
- setFilteredPeople([person.name])
- } else {
- setFilteredPeople(filteredPeople.filter(n => n !== person.name))
- }
- } else {
- setFilteredPeople([...filteredPeople, person.name])
- }
- }}
- onMouseOver={() => setTempFocus(person.name)}
- onMouseOut={() => setTempFocus(null)}
- title={person.created && dayjs.unix(person.created).fromNow()}
- >{person.name}
- )}
-
- >
- )}
-
-
-
-
- {heatmap}
-
- {tooltip && (
-
- {tooltip.available}
- {tooltip.date}
- {!!filteredPeople.length && (
-
- {tooltip.people.map(person =>
- {person}
- )}
- {filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
- {person}
- )}
-
- )}
-
- )}
-
-
- >
- )
-}
-
-export default AvailabilityViewer
diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.styles.js b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss
similarity index 50%
rename from frontend/src/components/AvailabilityViewer/AvailabilityViewer.styles.js
rename to frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss
index ec0c444..ac1acab 100644
--- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.styles.js
+++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss
@@ -1,17 +1,4 @@
-import { styled } from 'goober'
-import { forwardRef } from 'react'
-
-export const Wrapper = styled('div', forwardRef)`
- overflow-y: visible;
- margin: 20px 0;
- position: relative;
-`
-
-export const ScrollWrapper = styled('div')`
- overflow-x: auto;
-`
-
-export const Container = styled('div')`
+.heatmap {
display: inline-flex;
box-sizing: border-box;
min-width: 100%;
@@ -22,152 +9,22 @@ export const Container = styled('div')`
@media (max-width: 660px) {
padding: 0 30px;
}
-`
+}
-export const Date = styled('div')`
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- width: 60px;
- min-width: 60px;
- margin-bottom: 10px;
-`
-
-export const Times = styled('div')`
- display: flex;
- flex-direction: column;
-
- border-bottom: 2px solid var(--text);
- border-left: 1px solid var(--text);
- border-right: 1px solid var(--text);
-
- ${props => props.$borderLeft && `
- border-left: 2px solid var(--text);
- border-top-left-radius: 3px;
- border-bottom-left-radius: 3px;
- `}
- ${props => props.$borderRight && `
- border-right: 2px solid var(--text);
- border-top-right-radius: 3px;
- border-bottom-right-radius: 3px;
- `}
-
- & .time + .timespace, & .timespace:first-of-type {
- border-top: 2px solid var(--text);
- }
-`
-
-export const DateLabel = styled('label')`
- display: block;
- font-size: 12px;
- text-align: center;
- user-select: none;
-`
-
-export const DayLabel = styled('label')`
- display: block;
- font-size: 15px;
- text-align: center;
- user-select: none;
-`
-
-export const Time = styled('div')`
- height: 10px;
- background-origin: border-box;
- transition: background-color .1s;
-
- ${props => props.$time.slice(2, 4) === '00' && `
- border-top: 2px solid var(--text);
- `}
- ${props => props.$time.slice(2, 4) !== '00' && `
- border-top: 2px solid transparent;
- `}
- ${props => props.$time.slice(2, 4) === '30' && `
- border-top: 2px dotted var(--text);
- `}
-
- background-color: ${props => props.$palette[props.$peopleCount] ?? 'transparent'};
-
- ${props => props.$highlight && props.$peopleCount === props.$maxPeople && props.$peopleCount > 0 && `
- background-image: repeating-linear-gradient(
- 45deg,
- transparent,
- transparent 4.3px,
- rgba(0,0,0,.5) 4.3px,
- rgba(0,0,0,.5) 8.6px
- );
- `}
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- }
-`
-
-export const Spacer = styled('div')`
- width: 12px;
- flex-shrink: 0;
-`
-
-export const Tooltip = styled('div')`
- position: absolute;
- top: ${props => props.$y}px;
- left: ${props => props.$x}px;
- transform: translateX(-50%);
- border: 1px solid var(--text);
- border-radius: 3px;
- padding: 4px 8px;
- background-color: var(--background);
- max-width: 200px;
- pointer-events: none;
- z-index: 100;
- user-select: none;
-`
-
-export const TooltipTitle = styled('span')`
- font-size: 15px;
- display: block;
- font-weight: 700;
-`
-
-export const TooltipDate = styled('span')`
- font-size: 13px;
- display: block;
- opacity: .8;
- font-weight: 600;
-`
-
-export const TooltipContent = styled('div')`
- font-size: 13px;
- padding: 4px 0;
-`
-
-export const TooltipPerson = styled('span')`
- display: inline-block;
- margin: 2px;
- padding: 1px 4px;
- border: 1px solid var(--primary);
- border-radius: 3px;
-
- ${props => props.disabled && `
- opacity: .5;
- border-color: var(--text);
- `}
-`
-
-export const TimeLabels = styled('div')`
+.timeLabels {
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 40px;
padding-right: 6px;
-`
+}
-export const TimeSpace = styled('div')`
+.timeSpace {
height: 10px;
position: relative;
border-top: 2px solid transparent;
- &.timespace {
+ &.grey {
background-origin: border-box;
background-image: repeating-linear-gradient(
45deg,
@@ -177,9 +34,9 @@ export const TimeSpace = styled('div')`
var(--loading) 8.6px
);
}
-`
+}
-export const TimeLabel = styled('label')`
+.timeLabel {
display: block;
position: absolute;
top: -.7em;
@@ -187,23 +44,107 @@ export const TimeLabel = styled('label')`
text-align: right;
user-select: none;
width: 100%;
-`
+}
-export const StyledMain = styled('div')`
- width: 600px;
- margin: 20px auto;
- max-width: calc(100% - 60px);
-`
+.dateColumn {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ width: 60px;
+ min-width: 60px;
+ margin-bottom: 10px;
+}
-export const People = styled('div')`
+.dateLabel {
+ display: block;
+ font-size: 12px;
+ text-align: center;
+ user-select: none;
+}
+
+.dayLabel {
+ display: block;
+ font-size: 15px;
+ text-align: center;
+ user-select: none;
+}
+
+.times {
+ display: flex;
+ flex-direction: column;
+
+ border-bottom: 2px solid var(--text);
+ border-left: 1px solid var(--text);
+ border-right: 1px solid var(--text);
+
+ &[data-border-left=true] {
+ border-left: 2px solid var(--text);
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+ }
+ &[data-border-right=true] {
+ border-right: 2px solid var(--text);
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+ }
+
+ & .time + .timespace, & .timespace:first-of-type {
+ border-top: 2px solid var(--text);
+ }
+}
+
+.time {
+ height: 10px;
+ background-origin: border-box;
+ transition: background-color .1s;
+ touch-action: none;
+
+ border-top-width: 2px;
+ border-top-style: solid;
+ border-top-color: var(--text);
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+}
+
+.editable {
+ @media (hover: hover) {
+ &:hover:not(:active) {
+ opacity: .8;
+ background-image: linear-gradient(var(--hover-color), var(--hover-color));
+ }
+ }
+}
+
+.highlight {
+ background-image: repeating-linear-gradient(
+ 45deg,
+ transparent,
+ transparent 4.3px,
+ var(--highlight-color, rgba(0,0,0,.5)) 4.3px,
+ var(--highlight-color, rgba(0,0,0,.5)) 8.6px
+ );
+}
+
+.info {
+ display: block;
+ text-align: center;
+
+ @media print {
+ display: none;
+ }
+}
+
+.people {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin: 14px auto;
-`
+}
-export const Person = styled('button')`
+.person {
font: inherit;
font-size: 15px;
border-radius: 3px;
@@ -215,18 +156,71 @@ export const Person = styled('button')`
padding: 2px 8px;
user-select: none;
- ${props => props.$filtered && `
- background: var(--primary);
- color: #FFFFFF;
- border-color: var(--primary);
- `}
-`
-
-export const Info = styled('span')`
- display: block;
- text-align: center;
-
- @media print {
- display: none;
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: 2px;
}
-`
+}
+
+.personSelected {
+ background: var(--primary);
+ color: #FFFFFF;
+ border-color: var(--primary);
+}
+
+.wrapper {
+ overflow-y: visible;
+ margin: 20px 0;
+ position: relative;
+
+ & > div {
+ overflow-x: auto;
+ }
+}
+
+.columnSpacer {
+ width: 12px;
+ flex-shrink: 0;
+}
+
+.tooltip {
+ position: absolute;
+ transform: translateX(-50%);
+ border: 1px solid var(--text);
+ border-radius: 3px;
+ padding: 4px 8px;
+ background-color: var(--background);
+ max-width: 200px;
+ pointer-events: none;
+ z-index: 100;
+ user-select: none;
+
+ h3 {
+ font-size: 15px;
+ margin: 0;
+ font-weight: 700;
+ }
+ & > span {
+ font-size: 13px;
+ display: block;
+ opacity: .8;
+ font-weight: 600;
+ }
+ & > div {
+ font-size: 13px;
+ padding: 4px 0;
+
+ span {
+ display: inline-block;
+ margin: 2px;
+ padding: 1px 4px;
+ border: 1px solid var(--primary);
+ border-radius: 3px;
+
+ &[data-disabled=true] {
+ opacity: .5;
+ border-color: var(--text);
+ }
+ }
+ }
+}
diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx
new file mode 100644
index 0000000..7c06cec
--- /dev/null
+++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx
@@ -0,0 +1,200 @@
+'use client'
+
+import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
+import { Temporal } from '@js-temporal/polyfill'
+
+import Content from '/src/components/Content/Content'
+import Legend from '/src/components/Legend/Legend'
+import { PersonResponse } from '/src/config/api'
+import { usePalette } from '/src/hooks/usePalette'
+import { useTranslation } from '/src/i18n/client'
+import { useStore } from '/src/stores'
+import useSettingsStore from '/src/stores/settingsStore'
+import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils'
+
+import styles from './AvailabilityViewer.module.scss'
+
+interface AvailabilityViewerProps {
+ times: string[]
+ timezone: string
+ people: PersonResponse[]
+}
+
+const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
+ const { t, i18n } = useTranslation('event')
+
+ const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
+ const highlight = useStore(useSettingsStore, state => state.highlight)
+ const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
+ const [tempFocus, setTempFocus] = useState
()
+ const [focusCount, setFocusCount] = useState()
+
+ const wrapperRef = useRef(null)
+ const [tooltip, setTooltip] = useState<{
+ x: number
+ y: number
+ available: string
+ date: string
+ people: string[]
+ }>()
+
+ // Calculate table
+ const { rows, columns } = useMemo(() =>
+ calculateTable(times, i18n.language, timeFormat, timezone),
+ [times, i18n.language, timeFormat, timezone])
+
+ // Calculate availabilities
+ const { availabilities, min, max } = useMemo(() =>
+ calculateAvailability(times, people.filter(p => filteredPeople.includes(p.name))),
+ [times, filteredPeople, people])
+
+ // Create the colour palette
+ const palette = usePalette(Math.max((max - min) + 1, 2))
+
+ // Reselect everyone if the amount of people changes
+ useEffect(() => {
+ setFilteredPeople(people.map(p => p.name))
+ }, [people.length])
+
+ const heatmap = useMemo(() => columns.map((column, x) =>
+ {column ?
+ {column.header.dateLabel &&
{column.header.dateLabel} }
+
{column.header.weekdayLabel}
+
+
+ {column.cells.map((cell, y) => {
+ if (y === column.cells.length - 1) return null
+
+ if (!cell) return
('greyed_times')}
+ />
+
+ let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
+ if (tempFocus) {
+ peopleHere = peopleHere.filter(p => p === tempFocus)
+ }
+ const color = palette[tempFocus && peopleHere.length ? max : peopleHere.length - min]
+
+ return
0 && styles.highlight,
+ )}
+ style={{
+ backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? color.string : 'transparent',
+ '--highlight-color': color.highlight,
+ ...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
+ ...cell.minute === 30 && { borderTopStyle: 'dotted' },
+ } as React.CSSProperties}
+ aria-label={peopleHere.join(', ')}
+ onMouseEnter={e => {
+ const cellBox = e.currentTarget.getBoundingClientRect()
+ const wrapperBox = wrapperRef.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
+ setTooltip({
+ x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
+ y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
+ available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
+ date: cell.label,
+ people: peopleHere,
+ })
+ }}
+ onMouseLeave={() => setTooltip(undefined)}
+ />
+ })}
+
+
:
}
+ ), [
+ availabilities,
+ columns,
+ highlight,
+ max,
+ min,
+ t,
+ palette,
+ tempFocus,
+ focusCount,
+ filteredPeople,
+ ])
+
+ return <>
+
+
+
+ {t('group.info1')}
+
+ {people.length > 1 && <>
+ {t('group.info2')}
+
+ {people.map(person =>
+ {
+ setTempFocus(undefined)
+ if (filteredPeople.includes(person.name)) {
+ setFilteredPeople(filteredPeople.filter(n => n !== person.name))
+ } else {
+ setFilteredPeople([...filteredPeople, person.name])
+ }
+ }}
+ onMouseOver={() => setTempFocus(person.name)}
+ onMouseOut={() => setTempFocus(undefined)}
+ title={relativeTimeFormat(Temporal.Instant.fromEpochSeconds(person.created_at), i18n.language)}
+ >{person.name}
+ )}
+
+ >}
+
+
+
+
+
+ {useMemo(() =>
+ {rows.map((row, i) =>
+
+ {row &&
+ {row.label}
+ }
+
+ )}
+
, [rows])}
+
+ {heatmap}
+
+
+ {tooltip &&
+
{tooltip.available}
+
{tooltip.date}
+ {!!filteredPeople.length &&
+ {tooltip.people.map(person => {person} )}
+ {filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
+ {person}
+ )}
+
}
+
}
+
+
+ >
+}
+
+export default AvailabilityViewer
diff --git a/frontend/src/components/Button/Button.jsx b/frontend/src/components/Button/Button.jsx
deleted file mode 100644
index c069309..0000000
--- a/frontend/src/components/Button/Button.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Pressable } from './Button.styles'
-
-const Button = ({
- href,
- type = 'button',
- icon,
- children,
- secondary,
- primaryColor,
- secondaryColor,
- small,
- size,
- isLoading,
- ...props
-}) => (
-
- {icon}
- {children}
-
-)
-
-export default Button
diff --git a/frontend/src/components/Button/Button.module.scss b/frontend/src/components/Button/Button.module.scss
new file mode 100644
index 0000000..8801537
--- /dev/null
+++ b/frontend/src/components/Button/Button.module.scss
@@ -0,0 +1,157 @@
+.button {
+ cursor: pointer;
+ border: 0;
+ text-decoration: none;
+ font: inherit;
+ padding: 0;
+ margin: 0;
+ background: none;
+ border-radius: 3px;
+
+ & > div {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ box-sizing: border-box;
+ background: var(--override-surface-color, var(--primary));
+ color: var(--override-text-color, var(--background));
+ font-weight: 600;
+ transition: transform 150ms cubic-bezier(0, 0, 0.58, 1);
+ border-radius: inherit;
+ padding: .6em 1.5em;
+ transform-style: preserve-3d;
+ margin-bottom: 5px;
+
+ & svg, & img {
+ height: 1.2em;
+ width: 1.2em;
+ margin-right: .5em;
+ }
+
+ &::before {
+ content: '';
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ top: 0;
+ left: 0;
+ background: var(--override-shadow-color, var(--shadow));
+ border-radius: inherit;
+ transform: translate3d(0, 5px, -1em);
+ transition: transform 150ms cubic-bezier(0, 0, 0.58, 1), box-shadow 150ms cubic-bezier(0, 0, 0.58, 1);
+ }
+ }
+
+ &:hover > div, &:focus > div {
+ transform: translate(0, 1px);
+ &::before {
+ transform: translate3d(0, 4px, -1em);
+ }
+ }
+
+ &:active > div {
+ transform: translate(0, 5px);
+ &::before {
+ transform: translate3d(0, 0, -1em);
+ }
+ }
+
+ @media print {
+ & > div::before {
+ display: none;
+ }
+ }
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: 2px;
+ }
+}
+
+.iconButton > div {
+ height: 30px;
+ width: 30px;
+ padding: 0;
+
+ & svg, & img {
+ margin: 0;
+ }
+}
+
+.small > div {
+ padding: .4em 1.3em;
+}
+
+.loading {
+ cursor: wait;
+
+ & > div {
+ color: transparent;
+ }
+
+ & img {
+ opacity: 0;
+ }
+
+ @keyframes load {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ & > div::after {
+ content: '';
+ position: absolute;
+ top: calc(50% - 12px);
+ left: calc(50% - 12px);
+ height: 18px;
+ width: 18px;
+ border: 3px solid var(--override-text-color, var(--background));
+ border-left-color: transparent;
+ border-radius: 100px;
+ animation: load .5s linear infinite;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ & > div::after {
+ content: 'loading...';
+ color: var(--override-text-color, var(--background));
+ animation: none;
+ width: initial;
+ height: initial;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 0;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+}
+
+.secondary {
+ & > div {
+ background: transparent;
+ border: 1px solid var(--override-surface-color, var(--secondary));
+ color: var(--override-surface-color, var(--secondary));
+ margin-bottom: 0;
+
+ @media print {
+ box-shadow: 0 4px 0 0 var(--override-shadow-color, var(--secondary));
+ }
+ }
+
+ & > div::before {
+ content: none;
+ }
+ &:hover > div, &:active > div, &:focus > div {
+ transform: none;
+ }
+}
diff --git a/frontend/src/components/Button/Button.styles.js b/frontend/src/components/Button/Button.styles.js
deleted file mode 100644
index 295c25f..0000000
--- a/frontend/src/components/Button/Button.styles.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import { styled } from 'goober'
-
-export const Pressable = styled('button')`
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- text-align: center;
- cursor: pointer;
- border: 0;
- text-decoration: none;
- font: inherit;
- box-sizing: border-box;
- background: ${props => props.$primaryColor || 'var(--primary)'};
- color: ${props => props.$primaryColor ? '#FFF' : 'var(--background)'};
- font-weight: 600;
- transition: transform 150ms cubic-bezier(0, 0, 0.58, 1);
- border-radius: 3px;
- padding: ${props => props.$small ? '.4em 1.3em' : '.6em 1.5em'};
- transform-style: preserve-3d;
- margin-bottom: 5px;
-
- & svg, & img {
- height: 1.2em;
- width: 1.2em;
- margin-right: .5em;
- }
-
- ${props => props.$size && `
- padding: 0;
- height: ${props.$size};
- width: ${props.$size};
- `}
-
- &::before {
- content: '';
- position: absolute;
- height: 100%;
- width: 100%;
- top: 0;
- left: 0;
- background: ${props => props.$secondaryColor || 'var(--shadow)'};
- border-radius: inherit;
- transform: translate3d(0, 5px, -1em);
- transition: transform 150ms cubic-bezier(0, 0, 0.58, 1), box-shadow 150ms cubic-bezier(0, 0, 0.58, 1);
- }
-
- &:hover, &:focus {
- transform: translate(0, 1px);
- &::before {
- transform: translate3d(0, 4px, -1em);
- }
- }
-
- &:active {
- transform: translate(0, 5px);
- &::before {
- transform: translate3d(0, 0, -1em);
- }
- }
-
- ${props => props.$isLoading && `
- color: transparent;
- cursor: wait;
-
- & img {
- opacity: 0;
- }
-
- @keyframes load {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
-
- &:after {
- content: '';
- position: absolute;
- top: calc(50% - 12px);
- left: calc(50% - 12px);
- height: 18px;
- width: 18px;
- border: 3px solid ${props.$primaryColor ? '#FFF' : 'var(--background)'};
- border-left-color: transparent;
- border-radius: 100px;
- animation: load .5s linear infinite;
- }
-
- @media (prefers-reduced-motion: reduce) {
- &:after {
- content: 'loading...';
- color: ${props.$primaryColor ? '#FFF' : 'var(--background)'};
- animation: none;
- width: initial;
- height: initial;
- left: 50%;
- transform: translateX(-50%);
- border: 0;
- top: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- }
- `}
-
- ${props => props.$secondary && `
- background: transparent;
- border: 1px solid ${props.$primaryColor || 'var(--secondary)'};
- color: ${props.$primaryColor || 'var(--secondary)'};
- margin-bottom: 0;
-
- &::before {
- content: none;
- }
- &:hover, &:active, &:focus {
- transform: none;
- }
- `}
-
- @media print {
- ${props => !props.$secondary && `
- box-shadow: 0 4px 0 0 ${props.$secondaryColor || 'var(--secondary)'};
- `}
-
- &::before {
- display: none;
- }
- }
-`
diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx
new file mode 100644
index 0000000..0bff039
--- /dev/null
+++ b/frontend/src/components/Button/Button.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link'
+
+import { makeClass } from '/src/utils'
+
+import styles from './Button.module.scss'
+
+type ButtonProps = {
+ /** If provided, will render a link that looks like a button */
+ href?: string
+ icon?: React.ReactNode
+ children?: React.ReactNode
+ isSecondary?: boolean
+ isSmall?: boolean
+ isLoading?: boolean
+ /** Override the surface color of the button. Will force the text to #FFFFFF. */
+ surfaceColor?: string
+ /** Override the shadow color of the button */
+ shadowColor?: string
+} & Omit
& React.ComponentProps<'a'>, 'ref'>
+
+const Button: React.FC = ({
+ href,
+ type = 'button',
+ icon,
+ children,
+ isSecondary,
+ isSmall,
+ isLoading,
+ surfaceColor,
+ shadowColor,
+ style,
+ ...props
+}) => {
+ const sharedProps = {
+ className: makeClass(
+ styles.button,
+ 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 },
+ ...style,
+ },
+ children: {icon}{children}
,
+ ...props,
+ }
+
+ return href
+ ?
+ :
+}
+
+export default Button
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 ? (
- <>
-
- {
- if (month-1 < 0) {
- setYear(year-1)
- setMonth(11)
- } else {
- setMonth(month-1)
- }
- }}
- ><
- {dayjs.months()[month]} {year}
- {
- if (month+1 > 11) {
- setYear(year+1)
- setMonth(0)
- } else {
- setMonth(month+1)
- }
- }}
- >>
-
-
-
- {(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..58e9286
--- /dev/null
+++ b/frontend/src/components/CalendarField/CalendarField.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react'
+import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import { Temporal } from '@js-temporal/polyfill'
+
+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 && {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..1443824
--- /dev/null
+++ b/frontend/src/components/CalendarField/components/Month/Month.module.scss
@@ -0,0 +1,93 @@
+.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;
+ position: relative;
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: 2px;
+ z-index: 1;
+ }
+
+ @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..6bd14c0
--- /dev/null
+++ b/frontend/src/components/CalendarField/components/Month/Month.tsx
@@ -0,0 +1,156 @@
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { rotateArray } from '@giraugh/tools'
+import { Temporal } from '@js-temporal/polyfill'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
+
+import Button from '/src/components/Button/Button'
+import { useTranslation } from '/src/i18n/client'
+import { useStore } from '/src/stores'
+import useSettingsStore from '/src/stores/settingsStore'
+import { getWeekdayNames, makeClass } from '/src/utils'
+
+import styles from './Month.module.scss'
+
+interface MonthProps {
+ /** Stringified PlainDate `YYYY-MM-DD` */
+ value: string[]
+ onChange: (value: string[]) => void
+}
+
+const Month = ({ value, onChange }: MonthProps) => {
+ const { t, i18n } = useTranslation('home')
+
+ const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
+
+ const [page, setPage] = useState(Temporal.Now.plainDateISO().toPlainYearMonth())
+ const dates = useMemo(() => calculateMonth(page, weekStart, i18n.language), [page, weekStart, i18n.language])
+
+ // 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'>()
+
+ 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 <>
+ {useMemo(() =>
+ ('form.dates.tooltips.previous')}
+ onClick={() => setPage(page.subtract({ months: 1 }))}
+ icon={ }
+ />
+ {page.toPlainDate({ day: 1 }).toLocaleString(i18n.language, { month: 'long', year: 'numeric' })}
+ ('form.dates.tooltips.next')}
+ onClick={() => setPage(page.add({ months: 1 }))}
+ icon={ }
+ />
+
, [page, i18n.language])}
+
+ {useMemo(() =>
+ {(rotateArray(getWeekdayNames(i18n.language, 'short'), weekStart ? 0 : 1)).map(name =>
+ {name}
+ )}
+
, [i18n.language, weekStart])}
+
+
+ {dates.length > 0 && dates.map((dateRow, y) =>
+ dateRow.map((date, x) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ if (value.includes(date.string)) {
+ onChange(value.filter(d => d !== date.string))
+ } else {
+ onChange([...value, date.string])
+ }
+ }
+ }}
+ onPointerDown={e => {
+ startPos.current = { x, y }
+ mode.current = value.includes(date.string) ? 'remove' : 'add'
+ setSelecting([date.string])
+ 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].string))
+ }
+ }}
+ >{date.label} )
+ )}
+
+ >
+}
+
+export default Month
+
+interface Day {
+ month: number
+ isToday: boolean
+ string: string
+ title: string
+ label: string
+}
+
+/** Calculate the dates to show for the month in a 2d array */
+const calculateMonth = (month: Temporal.PlainYearMonth, weekStart: 0 | 1, locale: string) => {
+ const today = Temporal.Now.plainDateISO()
+ const daysBefore = month.toPlainDate({ day: 1 }).dayOfWeek - weekStart
+ const daysAfter = 6 - month.toPlainDate({ day: month.daysInMonth }).dayOfWeek + weekStart
+
+ const dates: Day[][] = []
+ let curDate = month.toPlainDate({ day: 1 }).subtract({ days: daysBefore })
+ let y = 0
+ let x = 0
+ for (let i = 0; i < daysBefore + month.daysInMonth + daysAfter; i++) {
+ if (x === 0) dates[y] = []
+ dates[y][x] = {
+ month: curDate.month,
+ isToday: curDate.equals(today),
+ string: curDate.toString(),
+ title: curDate.toLocaleString(locale, { day: 'numeric', month: 'long' }),
+ label: curDate.toLocaleString(locale, { day: 'numeric' }),
+ }
+ curDate = curDate.add({ days: 1 })
+ 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..20497af
--- /dev/null
+++ b/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx
@@ -0,0 +1,91 @@
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { range, rotateArray } from '@giraugh/tools'
+import { Temporal } from '@js-temporal/polyfill'
+
+import { useTranslation } from '/src/i18n/client'
+import { useStore } from '/src/stores'
+import useSettingsStore from '/src/stores/settingsStore'
+import { makeClass } from '/src/utils'
+
+// Use styles from Month picker
+import styles from '../Month/Month.module.scss'
+
+interface WeekdaysProps {
+ /** dayOfWeek 1-7 as a string */
+ value: string[]
+ onChange: (value: string[]) => void
+}
+
+const Weekdays = ({ value, onChange }: WeekdaysProps) => {
+ const { t, i18n } = useTranslation('home')
+
+ const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
+
+ const weekdays = useMemo(() => rotateArray(range(1, 7).map(i => Temporal.Now.plainDateISO().add({ days: i - Temporal.Now.plainDateISO().dayOfWeek })), weekStart ? 0 : 1), [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) =>
+ ('form.dates.tooltips.today') : undefined}
+ onKeyDown={e => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ if (value.includes(day.dayOfWeek.toString())) {
+ onChange(value.filter(d => d !== day.dayOfWeek.toString()))
+ } else {
+ onChange([...value, day.dayOfWeek.toString()])
+ }
+ }
+ }}
+ onPointerDown={e => {
+ startPos.current = i
+ mode.current = value.includes(day.dayOfWeek.toString()) ? 'remove' : 'add'
+ setSelecting([day.dayOfWeek.toString()])
+ 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].dayOfWeek.toString())
+ }
+ setSelecting(found)
+ }
+ }}
+ >{day.toLocaleString(i18n.language, { weekday: 'short' })}
+ )}
+
+}
+
+export default Weekdays
diff --git a/frontend/src/components/Center/Center.js b/frontend/src/components/Center/Center.js
deleted file mode 100644
index a696734..0000000
--- a/frontend/src/components/Center/Center.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { styled } from 'goober'
-
-const Center = styled('div')`
- display: flex;
- align-items: center;
- justify-content: center;
-`
-
-export default Center
diff --git a/frontend/src/components/Content/Content.module.scss b/frontend/src/components/Content/Content.module.scss
new file mode 100644
index 0000000..387f1d4
--- /dev/null
+++ b/frontend/src/components/Content/Content.module.scss
@@ -0,0 +1,17 @@
+.content {
+ width: 600px;
+ margin: 20px auto;
+ max-width: calc(100% - 60px);
+}
+
+.centered {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+.slim {
+ margin-block: 10px;
+ max-width: calc(100% - 30px);
+}
diff --git a/frontend/src/components/Content/Content.tsx b/frontend/src/components/Content/Content.tsx
new file mode 100644
index 0000000..85055c1
--- /dev/null
+++ b/frontend/src/components/Content/Content.tsx
@@ -0,0 +1,21 @@
+import { makeClass } from '/src/utils'
+
+import styles from './Content.module.scss'
+
+interface ContentProps {
+ children: React.ReactNode
+ isCentered?: boolean
+ isSlim?: boolean
+}
+
+const Content = ({ isCentered, isSlim, ...props }: ContentProps) =>
+
+
+export default Content
diff --git a/frontend/src/components/Copyable/Copyable.module.scss b/frontend/src/components/Copyable/Copyable.module.scss
new file mode 100644
index 0000000..a988948
--- /dev/null
+++ b/frontend/src/components/Copyable/Copyable.module.scss
@@ -0,0 +1,7 @@
+.copyable {
+ cursor: pointer;
+
+ &:hover {
+ color: var(--secondary);
+ }
+}
diff --git a/frontend/src/components/Copyable/Copyable.tsx b/frontend/src/components/Copyable/Copyable.tsx
new file mode 100644
index 0000000..d3bce21
--- /dev/null
+++ b/frontend/src/components/Copyable/Copyable.tsx
@@ -0,0 +1,33 @@
+'use client'
+
+import { useState } from 'react'
+
+import { useTranslation } from '/src/i18n/client'
+import { makeClass } from '/src/utils'
+
+import styles from './Copyable.module.scss'
+
+interface CopyableProps extends Omit, 'children'> {
+ children: string
+}
+
+const Copyable = ({ children, className, ...props }: CopyableProps) => {
+ const { t } = useTranslation('event')
+
+ const [copied, setCopied] = useState()
+
+ return navigator.clipboard?.writeText(children)
+ .then(() => {
+ setCopied(t('nav.copied'))
+ setTimeout(() => setCopied(undefined), 1000)
+ })
+ .catch(e => console.error('Failed to copy', e))
+ }
+ title={'clipboard' in navigator ? t('nav.title') : undefined}
+ className={makeClass(className, 'clipboard' in navigator && styles.copyable)}
+ {...props}
+ >{copied ?? children}
+}
+
+export default Copyable
diff --git a/frontend/src/components/CreateForm/CreateForm.module.scss b/frontend/src/components/CreateForm/CreateForm.module.scss
new file mode 100644
index 0000000..e649e05
--- /dev/null
+++ b/frontend/src/components/CreateForm/CreateForm.module.scss
@@ -0,0 +1,4 @@
+.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..83d9a57
--- /dev/null
+++ b/frontend/src/components/CreateForm/CreateForm.tsx
@@ -0,0 +1,169 @@
+'use client'
+
+import { useState } from 'react'
+import { SubmitHandler, useForm } from 'react-hook-form'
+import { useRouter } from 'next/navigation'
+import { range } from '@giraugh/tools'
+import { Temporal } from '@js-temporal/polyfill'
+
+import Button from '/src/components/Button/Button'
+import CalendarField from '/src/components/CalendarField/CalendarField'
+import { default as ErrorAlert } from '/src/components/Error/Error'
+import SelectField from '/src/components/SelectField/SelectField'
+import TextField from '/src/components/TextField/TextField'
+import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
+import { createEvent, EventResponse } from '/src/config/api'
+import { useTranslation } from '/src/i18n/client'
+import timezones from '/src/res/timezones.json'
+import useRecentsStore from '/src/stores/recentsStore'
+
+import EventInfo from './components/EventInfo/EventInfo'
+import styles from './CreateForm.module.scss'
+
+interface Fields {
+ name: string
+ /** As `YYYY-MM-DD` or `d` */
+ 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 = ({ noRedirect }: { noRedirect?: boolean }) => {
+ const { t } = useTranslation('home')
+ const { push } = useRouter()
+
+ const addRecent = useRecentsStore(state => state.addRecent)
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ } = useForm({ defaultValues })
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [createdEvent, setCreatedEvent] = useState()
+ const [error, setError] = useState()
+
+ const onSubmit: SubmitHandler = async values => {
+ setIsLoading(true)
+ setError(undefined)
+
+ const { name, dates, time, timezone } = values
+
+ try {
+ if (dates.length === 0) {
+ return setError(t('form.errors.no_dates'))
+ }
+ if (time.start === time.end) {
+ return setError(t('form.errors.same_times'))
+ }
+
+ // If format is `YYYY-MM-DD` or `d`
+ const isSpecificDates = dates[0].length !== 1
+
+ const times = dates.flatMap(dateStr => {
+ const date = isSpecificDates
+ ? Temporal.PlainDate.from(dateStr)
+ : Temporal.Now.plainDateISO().add({ days: Number(dateStr) - Temporal.Now.plainDateISO().dayOfWeek })
+
+ const hours = time.start > time.end ? [...range(0, time.end - 1), ...range(time.start, 23)] : range(time.start, time.end - 1)
+
+ return hours.map(hour => {
+ const dateTime = date.toZonedDateTime({ timeZone: timezone, plainTime: Temporal.PlainTime.from({ hour }) }).withTimeZone('UTC')
+ if (isSpecificDates) {
+ // Format as `HHmm-DDMMYYYY`
+ return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${dateTime.day.toString().padStart(2, '0')}${dateTime.month.toString().padStart(2, '0')}${dateTime.year.toString().padStart(4, '0')}`
+ } else {
+ // Format as `HHmm-d`
+ return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${String(dateTime.dayOfWeek === 7 ? 0 : dateTime.dayOfWeek)}`
+ }
+ })
+ })
+
+ if (times.length === 0) {
+ return setError(t('form.errors.no_time'))
+ }
+
+ const newEvent = await createEvent({ name, times, timezone }).catch(e => {
+ console.error(e)
+ throw new Error('Failed to create event')
+ })
+
+ if (noRedirect) {
+ // Show event link
+ setCreatedEvent(newEvent)
+ addRecent({
+ id: newEvent.id,
+ name: newEvent.name,
+ created_at: newEvent.created_at,
+ })
+ } else {
+ // Navigate to the new event
+ push(`/${newEvent.id}`)
+ }
+ } catch (e) {
+ setError(t('form.errors.unknown'))
+ console.error(e)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return createdEvent ? :
+}
+
+export default CreateForm
diff --git a/frontend/src/components/CreateForm/components/EventInfo/EventInfo.module.scss b/frontend/src/components/CreateForm/components/EventInfo/EventInfo.module.scss
new file mode 100644
index 0000000..e977ea7
--- /dev/null
+++ b/frontend/src/components/CreateForm/components/EventInfo/EventInfo.module.scss
@@ -0,0 +1,11 @@
+.wrapper {
+ text-align: center;
+ margin: 50px 0 20px;
+}
+
+.info {
+ margin: 6px 0;
+ text-align: center;
+ font-size: 15px;
+ padding: 10px 0;
+}
diff --git a/frontend/src/components/CreateForm/components/EventInfo/EventInfo.tsx b/frontend/src/components/CreateForm/components/EventInfo/EventInfo.tsx
new file mode 100644
index 0000000..e589cb6
--- /dev/null
+++ b/frontend/src/components/CreateForm/components/EventInfo/EventInfo.tsx
@@ -0,0 +1,27 @@
+import { Trans } from 'react-i18next/TransWithoutContext'
+
+import Copyable from '/src/components/Copyable/Copyable'
+import { EventResponse } from '/src/config/api'
+import { useTranslation } from '/src/i18n/client'
+
+import styles from './EventInfo.module.scss'
+
+interface EventInfoProps {
+ event: EventResponse
+}
+
+const EventInfo = ({ event }: EventInfoProps) => {
+ const { t, i18n } = useTranslation('event')
+
+ return
+}
+
+export default EventInfo
diff --git a/frontend/src/components/Donate/Donate.jsx b/frontend/src/components/Donate/Donate.jsx
deleted file mode 100644
index a40f9d6..0000000
--- a/frontend/src/components/Donate/Donate.jsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useState, useEffect, useRef } from 'react'
-import { useTranslation } from 'react-i18next'
-
-import { Button } from '/src/components'
-import { useTWAStore } from '/src/stores'
-
-import {
- Wrapper,
- Options,
-} from './Donate.styles'
-
-import paypal_logo from '/src/res/paypal.svg'
-
-const PAYMENT_METHOD = 'https://play.google.com/billing'
-const SKU = 'crab_donation'
-
-const Donate = () => {
- const store = useTWAStore()
- const { t } = useTranslation('common')
-
- const firstLinkRef = useRef()
- const modalRef = useRef()
- const [isOpen, _setIsOpen] = useState(false)
- const [closed, setClosed] = useState(false)
-
- const setIsOpen = open => {
- _setIsOpen(open)
-
- if (open) {
- window.setTimeout(() => firstLinkRef.current.focus(), 150)
- }
- }
-
- const linkPressed = () => {
- setIsOpen(false)
- gtag('event', 'donate', { 'event_category': 'donate' })
- }
-
- useEffect(() => {
- if (store.TWA === undefined) {
- store.setTWA(document.referrer.includes('android-app://fit.crab'))
- }
- }, [store])
-
- const acknowledge = async (token, type='repeatable', onComplete = () => {}) => {
- try {
- const service = await window.getDigitalGoodsService(PAYMENT_METHOD)
- await service.acknowledge(token, type)
- if ('acknowledge' in service) {
- // DGAPI 1.0
- service.acknowledge(token, type)
- } else {
- // DGAPI 2.0
- service.consume(token)
- }
- onComplete()
- } catch (error) {
- console.error(error)
- }
- }
-
- const purchase = () => {
- if (!window.PaymentRequest) return false
- if (!window.getDigitalGoodsService) return false
-
- const supportedInstruments = [{
- supportedMethods: PAYMENT_METHOD,
- data: {
- sku: SKU
- }
- }]
-
- const details = {
- total: {
- label: 'Total',
- amount: { currency: 'AUD', value: '0' }
- },
- }
-
- const request = new PaymentRequest(supportedInstruments, details)
-
- request.show()
- .then(response => {
- response
- .complete('success')
- .then(() => {
- console.log(`Payment done: ${JSON.stringify(response, undefined, 2)}`)
- if (response.details && response.details.token) {
- const token = response.details.token
- console.log(`Read Token: ${token.substring(0, 6)}...`)
- alert(t('donate.messages.success'))
- acknowledge(token)
- }
- })
- .catch(e => {
- console.error(e.message)
- alert(t('donate.messages.error'))
- })
- })
- .catch(e => {
- console.error(e)
- alert(t('donate.messages.error'))
- })
- }
-
- return (
-
- {
- if (closed) {
- event.preventDefault()
- return setClosed(false)
- }
- if (store.TWA) {
- gtag('event', 'donate', { 'event_category': 'donate' })
- event.preventDefault()
- if (window.confirm(t('donate.messages.about'))) {
- if (purchase() === false) {
- alert(t('donate.messages.error'))
- }
- }
- } else {
- event.preventDefault()
- setIsOpen(true)
- }
- }}
- href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=5"
- target="_blank"
- rel="noreferrer noopener payment"
- id="donate_button"
- role="button"
- aria-expanded={isOpen ? 'true' : 'false'}
- style={{ whiteSpace: 'nowrap' }}
- >{t('donate.button')}
-
- {
- if (modalRef.current?.contains(e.relatedTarget)) return
- setIsOpen(false)
- if (e.relatedTarget && e.relatedTarget.id === 'donate_button') {
- setClosed(true)
- }
- }}
- >
-
- {t('donate.options.$2')}
- {t('donate.options.$5')}
- {t('donate.options.$10')}
- {t('donate.options.choose')}
-
-
- )
-}
-
-export default Donate
diff --git a/frontend/src/components/Donate/Donate.styles.js b/frontend/src/components/Donate/Donate.styles.js
deleted file mode 100644
index ee22153..0000000
--- a/frontend/src/components/Donate/Donate.styles.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import { styled } from 'goober'
-import { forwardRef } from 'react'
-
-export const Wrapper = styled('div')`
- margin-top: 6px;
- margin-left: 12px;
- position: relative;
-`
-
-export const Options = styled('div', forwardRef)`
- position: absolute;
- bottom: calc(100% + 20px);
- right: 0;
- background-color: var(--background);
- border: 1px solid var(--surface);
- z-index: 60;
- padding: 4px 10px;
- border-radius: 14px;
- box-sizing: border-box;
- max-width: calc(100vw - 20px);
- box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
-
- visibility: hidden;
- pointer-events: none;
- opacity: 0;
- transform: translateY(5px);
- transition: opacity .15s, transform .15s, visibility .15s;
-
- ${props => props.$isOpen && `
- pointer-events: all;
- opacity: 1;
- transform: translateY(0);
- visibility: visible;
- `}
-
- & img {
- width: 80px;
- margin: 10px auto 0;
- display: block;
- }
-
- & a {
- display: block;
- white-space: nowrap;
- text-align: center;
- padding: 4px 20px;
- margin: 6px 0;
- text-decoration: none;
- border-radius: 100px;
- background-color: var(--primary);
- color: var(--background);
-
- &:hover {
- text-decoration: underline;
- }
- & strong {
- font-weight: 800;
- }
- }
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- }
-`
diff --git a/frontend/src/components/DownloadButtons/DownloadButtons.module.scss b/frontend/src/components/DownloadButtons/DownloadButtons.module.scss
new file mode 100644
index 0000000..d6c08cc
--- /dev/null
+++ b/frontend/src/components/DownloadButtons/DownloadButtons.module.scss
@@ -0,0 +1,8 @@
+.buttonWrapper {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ margin: 30px 0;
+}
diff --git a/frontend/src/components/DownloadButtons/DownloadButtons.tsx b/frontend/src/components/DownloadButtons/DownloadButtons.tsx
new file mode 100644
index 0000000..a62a8a9
--- /dev/null
+++ b/frontend/src/components/DownloadButtons/DownloadButtons.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+import Button from '/src/components/Button/Button'
+import { useTranslation } from '/src/i18n/client'
+import { detectBrowser } from '/src/utils'
+
+import styles from './DownloadButtons.module.scss'
+
+const DownloadButtons = () => {
+ const { t } = useTranslation('home')
+
+ const [isVisible, setIsVisible] = useState(true)
+ const [browser, setBrowser] = useState>()
+
+ useEffect(() => {
+ // Don't show buttons in the Android app
+ if (document.referrer.includes('android-app://fit.crab')) {
+ setIsVisible(false)
+ }
+
+ // Detect which browser the user is using
+ setBrowser(detectBrowser())
+ }, [])
+
+ return isVisible ?
+ {(browser === 'firefox' || browser === 'safari') && (
+
,
+ firefox: ,
+ safari: ,
+ }[browser]}
+ target="_blank"
+ rel="noreferrer noopener"
+ isSecondary
+ >{{
+ // chrome: t('about.chrome_extension'),
+ firefox: t('about.firefox_extension'),
+ safari: t('about.safari_extension'),
+ }[browser]}
+ )}
+
+
}
+ target="_blank"
+ rel="noreferrer noopener"
+ isSecondary
+ >{t('about.android_app')}
+
: null
+}
+
+export default DownloadButtons
diff --git a/frontend/src/components/Egg/Egg.jsx b/frontend/src/components/Egg/Egg.jsx
deleted file mode 100644
index b6ed7db..0000000
--- a/frontend/src/components/Egg/Egg.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useState } from 'react'
-
-import { Loading } from '/src/components'
-import { Image, Wrapper } from './Egg.styles'
-
-const Egg = ({ eggKey, onClose }) => {
- const [isLoading, setIsLoading] = useState(true)
-
- return (
- onClose()}>
- setIsLoading(true)}
- onLoad={() => setIsLoading(false)}
- />
- {isLoading && }
-
- )
-}
-
-export default Egg
diff --git a/frontend/src/components/Egg/Egg.module.scss b/frontend/src/components/Egg/Egg.module.scss
new file mode 100644
index 0000000..73128cf
--- /dev/null
+++ b/frontend/src/components/Egg/Egg.module.scss
@@ -0,0 +1,55 @@
+.modal {
+ background: none;
+ border: 0;
+ padding: 0;
+ outline: none;
+ width: 100%;
+ height: 100%;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ overflow: visible;
+
+ &[open] {
+ display: flex;
+ }
+
+ &::backdrop {
+ background: rgba(0,0,0,.6);
+ }
+}
+
+.image {
+ max-width: 80vw;
+ max-height: 80vh;
+ border-radius: 10px;
+ display: block;
+ position: absolute;
+}
+
+@keyframes load {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.loader {
+ height: 24px;
+ width: 24px;
+ border: 3px solid var(--primary);
+ border-left-color: transparent;
+ border-radius: 100px;
+ animation: load .5s linear infinite;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ border: 0;
+
+ &::before {
+ content: 'loading...';
+ }
+ }
+}
diff --git a/frontend/src/components/Egg/Egg.styles.js b/frontend/src/components/Egg/Egg.styles.js
deleted file mode 100644
index 715dbed..0000000
--- a/frontend/src/components/Egg/Egg.styles.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { styled } from 'goober'
-
-export const Wrapper = styled('div')`
- position: fixed;
- background: rgba(0,0,0,.6);
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- height: 100%;
- width: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1000;
- cursor: pointer;
-`
-
-export const Image = styled('img')`
- max-width: 80%;
- max-height: 80%;
- position: absolute;
-`
diff --git a/frontend/src/components/Egg/Egg.tsx b/frontend/src/components/Egg/Egg.tsx
new file mode 100644
index 0000000..d1ec88d
--- /dev/null
+++ b/frontend/src/components/Egg/Egg.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import styles from './Egg.module.scss'
+
+const PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
+const API_URL = 'https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v='
+
+const Egg = () => {
+ const ref = useRef(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [patternCompletion, setPatternCompletion] = useState(0)
+ const [url, setUrl] = useState('')
+ const [key, setKey] = useState(0)
+
+ const keyHandler = useCallback((e: KeyboardEvent) => {
+ // Key pressed not next in pattern
+ if (PATTERN.indexOf(e.key) < 0 || e.key !== PATTERN[patternCompletion]) {
+ return setPatternCompletion(0)
+ }
+
+ setPatternCompletion(patternCompletion + 1)
+
+ // Pattern completed
+ if (PATTERN.length === patternCompletion + 1) {
+ setUrl(`${API_URL}${key}`)
+ setKey(key + 1)
+ setPatternCompletion(0)
+ setIsLoading(true)
+ ref.current?.showModal()
+ }
+ }, [patternCompletion, key])
+
+ // Listen to key presses
+ useEffect(() => {
+ document.addEventListener('keyup', keyHandler)
+ return () => document.removeEventListener('keyup', keyHandler)
+ }, [keyHandler])
+
+ return {
+ e.currentTarget.close()
+ setUrl('')
+ }}
+ className={styles.modal}
+ ref={ref}
+ >
+ setIsLoading(true)}
+ onLoad={() => setIsLoading(false)}
+ />
+ {isLoading &&
}
+
+}
+
+export default Egg
diff --git a/frontend/src/components/Error/Error.jsx b/frontend/src/components/Error/Error.jsx
deleted file mode 100644
index fdea594..0000000
--- a/frontend/src/components/Error/Error.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { X } from 'lucide-react'
-
-import { Wrapper, CloseButton } from './Error.styles'
-
-const Error = ({
- children,
- onClose,
- open = true,
- ...props
-}) => (
-
- {children}
-
-
-)
-
-export default Error
diff --git a/frontend/src/components/Error/Error.styles.js b/frontend/src/components/Error/Error.module.scss
similarity index 62%
rename from frontend/src/components/Error/Error.styles.js
rename to frontend/src/components/Error/Error.module.scss
index 4164a3b..0a9e38f 100644
--- a/frontend/src/components/Error/Error.styles.js
+++ b/frontend/src/components/Error/Error.module.scss
@@ -1,6 +1,4 @@
-import { styled } from 'goober'
-
-export const Wrapper = styled('div')`
+.error {
border-radius: 3px;
background-color: var(--error);
color: #FFFFFF;
@@ -15,21 +13,21 @@ export const Wrapper = styled('div')`
visibility: hidden;
transition: margin .2s, padding .2s, max-height .2s;
- ${props => props.open && `
- opacity: 1;
- visibility: visible;
- margin: 20px 0;
- padding: 12px 16px;
- max-height: 60px;
- transition: opacity .15s .2s, max-height .2s, margin .2s, padding .2s, visibility .2s;
- `}
-
@media (prefers-reduced-motion: reduce) {
transition: none;
}
-`
+}
-export const CloseButton = styled('button')`
+.open {
+ opacity: 1;
+ visibility: visible;
+ margin: 20px 0;
+ padding: 12px 16px;
+ max-height: 60px;
+ transition: opacity .15s .2s, max-height .2s, margin .2s, padding .2s, visibility .2s;
+}
+
+.closeButton {
border: 0;
background: none;
height: 30px;
@@ -41,4 +39,4 @@ export const CloseButton = styled('button')`
justify-content: center;
margin-left: 16px;
padding: 0;
-`
+}
diff --git a/frontend/src/components/Error/Error.tsx b/frontend/src/components/Error/Error.tsx
new file mode 100644
index 0000000..0c644c4
--- /dev/null
+++ b/frontend/src/components/Error/Error.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import { X } from 'lucide-react'
+
+import { makeClass } from '/src/utils'
+
+import styles from './Error.module.scss'
+
+interface ErrorProps {
+ children?: React.ReactNode
+ onClose: () => void
+}
+
+const Error = ({ children, onClose }: ErrorProps) =>
+
+ {children}
+
+
+
+export default Error
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) =>
+
+
+export const Description = (props: LabelProps) =>
+
diff --git a/frontend/src/components/Footer/Footer.jsx b/frontend/src/components/Footer/Footer.jsx
deleted file mode 100644
index 4666fe7..0000000
--- a/frontend/src/components/Footer/Footer.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useTranslation } from 'react-i18next'
-
-import { Donate } from '/src/components'
-import { Wrapper } from './Footer.styles'
-
-const Footer = props => {
- const { t } = useTranslation('common')
-
- return (
-
- {t('donate.info')}
-
-
- )
-}
-
-export default Footer
diff --git a/frontend/src/components/Footer/Footer.module.scss b/frontend/src/components/Footer/Footer.module.scss
new file mode 100644
index 0000000..e1dbea5
--- /dev/null
+++ b/frontend/src/components/Footer/Footer.module.scss
@@ -0,0 +1,24 @@
+.footer {
+ width: 600px;
+ margin: 20px auto;
+ max-width: calc(100% - 60px);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @media print {
+ display: none;
+ }
+}
+
+.small {
+ margin: 60px auto 0;
+ width: 250px;
+ max-width: initial;
+ display: block;
+
+ & span {
+ display: block;
+ margin-bottom: 20px;
+ }
+}
diff --git a/frontend/src/components/Footer/Footer.styles.js b/frontend/src/components/Footer/Footer.styles.js
deleted file mode 100644
index b9b9884..0000000
--- a/frontend/src/components/Footer/Footer.styles.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { styled } from 'goober'
-
-export const Wrapper = styled('footer')`
- width: 600px;
- margin: 20px auto;
- max-width: calc(100% - 60px);
- display: flex;
- align-items: center;
- justify-content: space-between;
-
- ${props => props.small && `
- margin: 60px auto 0;
- width: 250px;
- max-width: initial;
- display: block;
-
- & span {
- display: block;
- margin-bottom: 20px;
- }
- `}
-
- @media print {
- display: none;
- }
-`
diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx
new file mode 100644
index 0000000..2a668af
--- /dev/null
+++ b/frontend/src/components/Footer/Footer.tsx
@@ -0,0 +1,35 @@
+import { headers } from 'next/headers'
+
+import Button from '/src/components/Button/Button'
+import { useTranslation } from '/src/i18n/server'
+import { makeClass } from '/src/utils'
+
+import styles from './Footer.module.scss'
+
+interface FooterProps {
+ isSmall?: boolean
+}
+
+const Footer = async ({ isSmall }: FooterProps) => {
+ const { t } = await useTranslation('common')
+ const isRunningInApp = headers().get('referer')?.includes('android-app://fit.crab')
+
+ return isRunningInApp
+ ? null // Cannot show external donation link in an Android app
+ :
+ {t('donate.info')}
+ ('donate.title')}
+ href="https://ko-fi.com/A06841WZ"
+ target="_blank"
+ rel="noreferrer noopener payment"
+ style={{ whiteSpace: 'nowrap' }}
+ >{t('donate.button')}
+
+}
+
+export default Footer
diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.jsx b/frontend/src/components/GoogleCalendar/GoogleCalendar.jsx
deleted file mode 100644
index 34fdbea..0000000
--- a/frontend/src/components/GoogleCalendar/GoogleCalendar.jsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { useState, useEffect } from 'react'
-import { loadGapiInsideDOM } from 'gapi-script'
-import { useTranslation } from 'react-i18next'
-
-import { Button, Center } from '/src/components'
-import { Loader } from '../Loading/Loading.styles'
-import {
- CalendarList,
- CheckboxInput,
- CheckboxLabel,
- CalendarLabel,
- Info,
- Options,
- Title,
- Icon,
- LinkButton,
-} from './GoogleCalendar.styles'
-
-import googleLogo from '/src/res/google.svg'
-
-const signIn = () => window.gapi.auth2.getAuthInstance().signIn()
-
-const signOut = () => window.gapi.auth2.getAuthInstance().signOut()
-
-const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
- const [signedIn, setSignedIn] = useState(undefined)
- const [calendars, setCalendars] = useState(undefined)
- const [freeBusyLoading, setFreeBusyLoading] = useState(false)
- const { t } = useTranslation('event')
-
- const calendarLogin = async () => {
- const gapi = await loadGapiInsideDOM()
- gapi.load('client:auth2', () => {
- window.gapi.client.init({
- clientId: '276505195333-9kjl7e48m272dljbspkobctqrpet0n8m.apps.googleusercontent.com',
- discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
- scope: 'https://www.googleapis.com/auth/calendar.readonly',
- })
- .then(() => {
- // Listen for state changes
- window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn))
-
- // Handle initial sign-in state
- setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get())
- })
- .catch(e => {
- console.error(e)
- setSignedIn(false)
- })
- })
- }
-
- const importAvailability = () => {
- setFreeBusyLoading(true)
- gtag('event', 'google_cal_sync', {
- 'event_category': 'event',
- })
- window.gapi.client.calendar.freebusy.query({
- timeMin,
- timeMax,
- timeZone,
- items: calendars.filter(c => c.checked).map(c => ({id: c.id})),
- })
- .then(response => {
- onImport(response.result.calendars ? Object.values(response.result.calendars).reduce((busy, c) => [...busy, ...c.busy], []) : [])
- setFreeBusyLoading(false)
- }, e => {
- console.error(e)
- setFreeBusyLoading(false)
- })
- }
-
- useEffect(() => void calendarLogin(), [])
-
- useEffect(() => {
- if (signedIn) {
- window.gapi.client.calendar.calendarList.list({
- 'minAccessRole': 'freeBusyReader'
- })
- .then(response => {
- setCalendars(response.result.items.map(item => ({
- 'name': item.summary,
- 'description': item.description,
- 'id': item.id,
- 'color': item.backgroundColor,
- 'checked': item.primary === true,
- })))
- })
- .catch(e => {
- console.error(e)
- signOut()
- })
- }
- }, [signedIn])
-
- return (
- <>
- {!signedIn ? (
-
- signIn()}
- isLoading={signedIn === undefined}
- primaryColor="#4286F5"
- secondaryColor="#3367BD"
- icon={ }
- >
- {t('event:you.google_cal.login')}
-
-
- ) : (
-
-
-
- {t('event:you.google_cal.login')}
- ( {
- e.preventDefault()
- signOut()
- }}>{t('event:you.google_cal.logout')} )
-
-
- {calendars !== undefined && !calendars.every(c => c.checked) && (
- {
- e.preventDefault()
- setCalendars(calendars.map(c => ({...c, checked: true})))
- }}>{t('event:you.google_cal.select_all')}
- )}
- {calendars !== undefined && calendars.every(c => c.checked) && (
- {
- e.preventDefault()
- setCalendars(calendars.map(c => ({...c, checked: false})))
- }}>{t('event:you.google_cal.select_none')}
- )}
-
- {calendars !== undefined ? calendars.map(calendar => (
-
- setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
- />
-
- {calendar.name}
-
- )) : (
-
- )}
- {calendars !== undefined && (
- <>
- {t('event:you.google_cal.info')}
- importAvailability()}
- >{t('event:you.google_cal.button')}
- >
- )}
-
- )}
- >
- )
-}
-
-export default GoogleCalendar
diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss b/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss
new file mode 100644
index 0000000..89d75da
--- /dev/null
+++ b/frontend/src/components/GoogleCalendar/GoogleCalendar.module.scss
@@ -0,0 +1,134 @@
+.wrapper {
+ width: 100%;
+
+ & > div {
+ display: flex;
+ margin-block: 2px;
+ }
+}
+
+.title {
+ display: flex;
+ align-items: center;
+
+ & strong {
+ margin-right: 1ex;
+ }
+}
+
+.icon {
+ height: 24px;
+ width: 24px;
+ margin-right: 12px;
+
+ @media (prefers-color-scheme: light) {
+ filter: invert(1);
+ }
+ :global(.light) & {
+ filter: invert(1);
+ }
+}
+
+.linkButton {
+ font: inherit;
+ color: var(--primary);
+ border: 0;
+ background: none;
+ text-decoration: underline;
+ padding: 0;
+ margin: 0;
+ display: inline;
+ cursor: pointer;
+ appearance: none;
+ border-radius: .2em;
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: 2px;
+ }
+}
+
+.options {
+ font-size: 14px;
+ padding: 0 0 5px;
+}
+
+.checkbox {
+ height: 0px;
+ width: 0px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: 0;
+ font-size: 0;
+ transform: scale(0);
+ position: absolute;
+
+ &:checked + label::after {
+ opacity: 1;
+ transform: scale(1);
+ }
+ &[disabled] + label {
+ opacity: .6;
+ }
+ &[disabled] + label::after {
+ border: 2px solid var(--text);
+ background-color: var(--text);
+ }
+
+ & + label {
+ display: inline-block;
+ height: 24px;
+ width: 24px;
+ min-width: 24px;
+ position: relative;
+ border-radius: 3px;
+ transition: background-color 0.2s, box-shadow 0.2s;
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ height: 14px;
+ width: 14px;
+ border: 2px solid var(--text);
+ border-radius: 2px;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ }
+ &::after {
+ content: '';
+ display: inline-block;
+ height: 14px;
+ width: 14px;
+ border: 2px solid var(--cal-color, var(--primary));
+ background-color: var(--cal-color, var(--primary));
+ border-radius: 2px;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
+ background-size: 16px;
+ background-position: center;
+ background-repeat: no-repeat;
+ opacity: 0;
+ transform: scale(.5);
+ transition: opacity 0.15s, transform 0.15s;
+ }
+ }
+}
+
+.calendarName {
+ margin-left: .6em;
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 24px;
+}
+
+.info {
+ font-size: 14px;
+ opacity: .6;
+ font-weight: 500;
+ padding: 14px 0 10px;
+}
diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js b/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js
deleted file mode 100644
index b30b622..0000000
--- a/frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { styled } from 'goober'
-
-export const CalendarList = styled('div')`
- width: 100%;
- & > div {
- display: flex;
- margin: 2px 0;
- }
-`
-
-export const CheckboxInput = styled('input')`
- height: 0px;
- width: 0px;
- margin: 0;
- padding: 0;
- border: 0;
- background: 0;
- font-size: 0;
- transform: scale(0);
- position: absolute;
-
- &:checked + label::after {
- opacity: 1;
- transform: scale(1);
- }
- &[disabled] + label {
- opacity: .6;
- }
- &[disabled] + label:after {
- border: 2px solid var(--text);
- background-color: var(--text);
- }
-`
-
-export const CheckboxLabel = styled('label')`
- display: inline-block;
- height: 24px;
- width: 24px;
- min-width: 24px;
- position: relative;
- border-radius: 3px;
- transition: background-color 0.2s, box-shadow 0.2s;
-
- &::before {
- content: '';
- display: inline-block;
- height: 14px;
- width: 14px;
- border: 2px solid var(--text);
- border-radius: 2px;
- position: absolute;
- top: 3px;
- left: 3px;
- }
- &::after {
- content: '';
- display: inline-block;
- height: 14px;
- width: 14px;
- border: 2px solid ${props => props.color || 'var(--primary)'};
- background-color: ${props => props.color || 'var(--primary)'};
- border-radius: 2px;
- position: absolute;
- top: 3px;
- left: 3px;
- background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
- background-size: 16px;
- background-position: center;
- background-repeat: no-repeat;
- opacity: 0;
- transform: scale(.5);
- transition: opacity 0.15s, transform 0.15s;
- }
-`
-
-export const CalendarLabel = styled('label')`
- margin-left: .6em;
- font-size: 15px;
- font-weight: 500;
- line-height: 24px;
-`
-
-export const Info = styled('div')`
- font-size: 14px;
- opacity: .6;
- font-weight: 500;
- padding: 14px 0 10px;
-`
-
-export const Options = styled('div')`
- font-size: 14px;
- padding: 0 0 5px;
-`
-
-export const Title = styled('p')`
- display: flex;
- align-items: center;
-
- & strong {
- margin-right: 1ex;
- }
-`
-
-export const Icon = styled('img')`
- height: 24px;
- width: 24px;
- margin-right: 12px;
- filter: invert(1);
-`
-
-export const LinkButton = styled('button')`
- font: inherit;
- color: var(--primary);
- border: 0;
- background: none;
- text-decoration: underline;
- padding: 0;
- margin: 0;
- display: inline;
- cursor: pointer;
- appearance: none;
-`
diff --git a/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx b/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
new file mode 100644
index 0000000..7b567a1
--- /dev/null
+++ b/frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
@@ -0,0 +1,194 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import Script from 'next/script'
+import { Temporal } from '@js-temporal/polyfill'
+
+import Button from '/src/components/Button/Button'
+import { useTranslation } from '/src/i18n/client'
+import googleLogo from '/src/res/google.svg'
+import { allowUrlToWrap, parseSpecificDate } from '/src/utils'
+
+import styles from './GoogleCalendar.module.scss'
+
+const [clientId, apiKey] = [process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, process.env.NEXT_PUBLIC_GOOGLE_API_KEY]
+
+interface Calendar {
+ id: string
+ name: string
+ description?: string
+ color?: string
+ isChecked: boolean
+}
+
+const login = (callback: (tokenResponse: google.accounts.oauth2.TokenResponse) => void) => {
+ if (!clientId) return
+
+ const client = google.accounts.oauth2.initTokenClient({
+ client_id: clientId,
+ scope: 'https://www.googleapis.com/auth/calendar.readonly',
+ callback,
+ })
+ if (gapi?.client?.getToken()) {
+ // Skip dialog for existing session
+ client.requestAccessToken({ prompt: '' })
+ } else {
+ client.requestAccessToken()
+ }
+}
+
+interface GoogleCalendarProps {
+ timezone: string
+ timeStart: Temporal.ZonedDateTime
+ timeEnd: Temporal.ZonedDateTime
+ times: string[]
+ onImport: (availability: string[]) => void
+}
+
+const GoogleCalendar = ({ timezone, timeStart, timeEnd, times, onImport }: GoogleCalendarProps) => {
+ if (!clientId || !apiKey) return null
+
+ const { t } = useTranslation('event')
+
+ // Prevent Google scripts from loading until button pressed
+ const [canLoad, setCanLoad] = useState(false)
+ const [calendars, setCalendars] = useState()
+
+ // Clear calendars if logged out
+ useEffect(() => {
+ if (!canLoad) setCalendars(undefined)
+ }, [canLoad])
+
+ const fetchCalendars = useCallback((res: google.accounts.oauth2.TokenResponse) => {
+ if (res.error !== undefined) return setCanLoad(false)
+ if ('gapi' in window) {
+ gapi.client.calendar.calendarList.list({
+ 'minAccessRole': 'freeBusyReader'
+ })
+ .then(res => setCalendars(res.result.items.map(item => ({
+ id: item.id,
+ name: item.summary,
+ description: item.description,
+ color: item.backgroundColor,
+ isChecked: item.primary === true,
+ }))))
+ .catch(console.warn)
+ } else {
+ setCanLoad(false)
+ }
+ }, [])
+
+ // Process times so they can be checked quickly
+ const epochTimes = useMemo(() => times.map(t => parseSpecificDate(t).epochMilliseconds), [times])
+
+ const [isLoadingAvailability, setIsLoadingAvailability] = useState(false)
+ const importAvailability = useCallback(() => {
+ if (!calendars) return
+
+ setIsLoadingAvailability(true)
+ gapi.client.calendar.freebusy.query({
+ timeMin: timeStart.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z',
+ timeMax: timeEnd.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z',
+ timeZone: timezone,
+ items: calendars.filter(c => c.isChecked).map(c => ({ id: c.id })),
+ })
+ .then(response => {
+ const availabilities = response.result.calendars ? Object.values(response.result.calendars).flatMap(cal => cal.busy.map(a => ({
+ start: new Date(a.start).valueOf(),
+ end: new Date(a.end).valueOf(),
+ }))) : []
+
+ onImport(times.filter((_, i) => !availabilities.some(a => epochTimes[i] >= a.start && epochTimes[i] < a.end)))
+ setIsLoadingAvailability(false)
+ }, e => {
+ console.error(e)
+ setIsLoadingAvailability(false)
+ })
+ }, [calendars])
+
+ return <>
+ {!calendars && {
+ if (!canLoad) {
+ setCanLoad(true)
+ if ('google' in window) {
+ login(fetchCalendars)
+ }
+ } else {
+ setCanLoad(false)
+ }
+ }}
+ isLoading={canLoad}
+ surfaceColor="#4286F5"
+ shadowColor="#3367BD"
+ icon={ }
+ >
+ {t('you.google_cal.login')}
+ }
+
+ {calendars &&
+
+
+ {t('you.google_cal.login')}
+ ( setCanLoad(false)}
+ >{t('you.google_cal.logout')} )
+
+
+
+ {!calendars.every(c => c.isChecked) && setCalendars(calendars.map(c => ({ ...c, isChecked: true })))}
+ >{t('event:you.google_cal.select_all')} }
+ {calendars.every(c => c.isChecked) && setCalendars(calendars.map(c => ({ ...c, isChecked: false })))}
+ >{t('event:you.google_cal.select_none')} }
+
+
+ {calendars.map(calendar =>
+ setCalendars(calendars.map(c => c.id === calendar.id ? {...c, isChecked: !c.isChecked} : c))}
+ />
+
+ {allowUrlToWrap(calendar.name)}
+
)}
+
+
{t('you.google_cal.info')}
+
importAvailability()}
+ >{t('you.google_cal.button')}
+
}
+
+ {/* Load google api scripts */}
+ {canLoad && <>
+