Rename main folders and write sql backend adaptor

This commit is contained in:
Ben Grant 2023-05-11 17:04:17 +10:00
parent 1d34f8e06d
commit fdc58b428b
212 changed files with 3577 additions and 4775 deletions

View file

@ -0,0 +1,186 @@
import { useState, useRef, Fragment, Suspense, lazy } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import localeData from 'dayjs/plugin/localeData'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import isBetween from 'dayjs/plugin/isBetween'
import dayjs_timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
ScrollWrapper,
Container,
Date,
Times,
DateLabel,
DayLabel,
Spacer,
TimeLabels,
TimeLabel,
TimeSpace,
StyledMain,
} from '/src/components/AvailabilityViewer/AvailabilityViewer.styles'
import { Time } from './AvailabilityEditor.styles'
import { _GoogleCalendar, _OutlookCalendar, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
const GoogleCalendar = lazy(() => _GoogleCalendar())
const OutlookCalendar = lazy(() => _OutlookCalendar())
dayjs.extend(localeData)
dayjs.extend(customParseFormat)
dayjs.extend(isBetween)
dayjs.extend(utc)
dayjs.extend(dayjs_timezone)
const AvailabilityEditor = ({
times,
timeLabels,
dates,
timezone,
isSpecificDates,
value = [],
onChange,
}) => {
const { t } = useTranslation('event')
const locale = useLocaleUpdateStore(state => state.locale)
const [selectingTimes, _setSelectingTimes] = useState([])
const staticSelectingTimes = useRef([])
const setSelectingTimes = newTimes => {
staticSelectingTimes.current = newTimes
_setSelectingTimes(newTimes)
}
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode
_setMode(newMode)
}
return (
<>
<StyledMain>
<Center style={{textAlign: 'center'}}>{t('event:you.info')}</Center>
</StyledMain>
{isSpecificDates && (
<StyledMain>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<Suspense fallback={<Loader />}>
<GoogleCalendar
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
timeZone={timezone}
onImport={busyArray => onChange(
times.filter(time => !busyArray.some(busy =>
dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)')
))
)}
/>
<OutlookCalendar
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
timeZone={timezone}
onImport={busyArray => onChange(
times.filter(time => !busyArray.some(busy =>
dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)')
))
)}
/>
</Suspense>
</div>
</StyledMain>
)}
<Wrapper locale={locale}>
<ScrollWrapper>
<Container>
<TimeLabels>
{!!timeLabels.length && timeLabels.map((label, i) =>
<TimeSpace key={i}>
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
</TimeSpace>
)}
</TimeLabels>
{dates.map((date, x) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1
return (
<Fragment key={x}>
<Date>
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times
$borderRight={last}
$borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1}
>
{timeLabels.map((timeLabel, y) => {
if (!timeLabel.time) return null
if (!times.includes(`${timeLabel.time}-${date}`)) {
return (
<TimeSpace key={x+y} className="timespace" title={t('event:greyed_times')} />
)
}
const time = `${timeLabel.time}-${date}`
return (
<Time
key={x+y}
$time={time}
className="time"
$selected={value.includes(time)}
$selecting={selectingTimes.includes(time)}
$mode={mode}
onPointerDown={e => {
e.preventDefault()
startPos.current = {x, y}
setMode(value.includes(time) ? 'remove' : 'add')
setSelectingTimes([time])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
onChange([...value, ...staticSelectingTimes.current])
} else if (staticMode.current === 'remove') {
onChange(value.filter(t => !staticSelectingTimes.current.includes(t)))
}
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})
}
}
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`))
}
}}
/>
)
})}
</Times>
</Date>
{last && dates.length !== x+1 && (
<Spacer />
)}
</Fragment>
)
})}
</Container>
</ScrollWrapper>
</Wrapper>
</>
)
}
export default AvailabilityEditor

View file

@ -0,0 +1,24 @@
import { styled } from 'goober'
export const Time = styled('div')`
height: 10px;
touch-action: none;
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);
`}
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
background-color: var(--primary);
`};
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--background);
`};
`

View file

@ -0,0 +1,235 @@
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,
})), [tempFocus, filteredPeople, max, colormap])
const heatmap = useMemo(() => (
<Container>
<TimeLabels>
{!!timeLabels.length && timeLabels.map((label, i) =>
<TimeSpace key={i}>
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
</TimeSpace>
)}
</TimeLabels>
{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 (
<Fragment key={i}>
<Date>
{isSpecificDates && <DateLabel locale={locale}>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times
$borderRight={last}
$borderLeft={i === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[i-1], 'DDMMYYYY') : dayjs().day(dates[i-1]), 'day') > 1}
>
{timeLabels.map((timeLabel, i) => {
if (!timeLabel.time) return null
if (!times.includes(`${timeLabel.time}-${date}`)) {
return (
<TimeSpace className="timespace" key={i} title={t('event:greyed_times')} />
)
}
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 (
<Time
key={i}
$time={time}
className="time"
$peopleCount={focusCount !== null && focusCount !== peopleHere.length ? null : peopleHere.length}
$palette={palette}
aria-label={peopleHere.join(', ')}
$maxPeople={tempFocus !== null ? 1 : Math.min(max, filteredPeople.length)}
$minPeople={tempFocus !== null ? 0 : Math.min(min, filteredPeople.length)}
$highlight={highlight}
onMouseEnter={e => {
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)
}}
/>
)
})}
</Times>
</Date>
{last && dates.length !== i+1 && <Spacer />}
</Fragment>
)
})}
</Container>
), [
people,
filteredPeople,
tempFocus,
focusCount,
highlight,
locale,
dates,
isSpecificDates,
max,
min,
t,
timeFormat,
timeLabels,
times,
palette,
])
return (
<>
<StyledMain>
<Legend
min={Math.min(min, filteredPeople.length)}
max={Math.min(max, filteredPeople.length)}
total={filteredPeople.length}
onSegmentFocus={count => setFocusCount(count)}
/>
<Info>{t('event:group.info1')}</Info>
{people.length > 1 && (
<>
<Info>{t('event:group.info2')}</Info>
<People>
{people.map((person, i) =>
<Person
key={i}
$filtered={filteredPeople.includes(person.name)}
onClick={() => {
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}</Person>
)}
</People>
</>
)}
</StyledMain>
<Wrapper ref={wrapper}>
<ScrollWrapper>
{heatmap}
{tooltip && (
<Tooltip
$x={tooltip.x}
$y={tooltip.y}
>
<TooltipTitle>{tooltip.available}</TooltipTitle>
<TooltipDate>{tooltip.date}</TooltipDate>
{!!filteredPeople.length && (
<TooltipContent>
{tooltip.people.map(person =>
<TooltipPerson key={person}>{person}</TooltipPerson>
)}
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
<TooltipPerson key={person} disabled>{person}</TooltipPerson>
)}
</TooltipContent>
)}
</Tooltip>
)}
</ScrollWrapper>
</Wrapper>
</>
)
}
export default AvailabilityViewer

View file

@ -0,0 +1,232 @@
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')`
display: inline-flex;
box-sizing: border-box;
min-width: 100%;
align-items: flex-end;
justify-content: center;
padding: 0 calc(calc(100% - 600px) / 2);
@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')`
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 40px;
padding-right: 6px;
`
export const TimeSpace = styled('div')`
height: 10px;
position: relative;
border-top: 2px solid transparent;
&.timespace {
background-origin: border-box;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4.3px,
var(--loading) 4.3px,
var(--loading) 8.6px
);
}
`
export const TimeLabel = styled('label')`
display: block;
position: absolute;
top: -.7em;
font-size: 12px;
text-align: right;
user-select: none;
width: 100%;
`
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`
export const People = styled('div')`
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin: 14px auto;
`
export const Person = styled('button')`
font: inherit;
font-size: 15px;
border-radius: 3px;
border: 1px solid var(--text);
color: var(--text);
font-weight: 500;
background: transparent;
cursor: pointer;
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;
}
`

View file

@ -0,0 +1,33 @@
import { Pressable } from './Button.styles'
const Button = ({
href,
type = 'button',
icon,
children,
secondary,
primaryColor,
secondaryColor,
small,
size,
isLoading,
...props
}) => (
<Pressable
type={type}
as={href ? 'a' : 'button'}
href={href}
$secondary={secondary}
$primaryColor={primaryColor}
$secondaryColor={secondaryColor}
$small={small}
$size={size}
$isLoading={isLoading}
{...props}
>
{icon}
{children}
</Pressable>
)
export default Button

View file

@ -0,0 +1,134 @@
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;
}
}
`

View file

@ -0,0 +1,264 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import localeData from 'dayjs/plugin/localeData'
import updateLocale from 'dayjs/plugin/updateLocale'
import { Button, ToggleField } from '/src/components'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
CalendarHeader,
CalendarDays,
CalendarBody,
Date,
Day,
} from './CalendarField.styles'
dayjs.extend(isToday)
dayjs.extend(localeData)
dayjs.extend(updateLocale)
const calculateMonth = (month, year, weekStart) => {
const date = dayjs().month(month).year(year)
const daysInMonth = date.daysInMonth()
const daysBefore = date.date(1).day() - weekStart
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
const dates = []
let curDate = date.date(1).subtract(daysBefore, 'day')
let y = 0
let x = 0
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
if (x === 0) dates[y] = []
dates[y][x] = curDate.clone()
curDate = curDate.add(1, 'day')
x++
if (x > 6) {
x = 0
y++
}
}
return dates
}
const CalendarField = forwardRef(({
label,
subLabel,
id,
setValue,
...props
}, ref) => {
const weekStart = useSettingsStore(state => state.weekStart)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation('home')
const [type, setType] = useState(0)
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart))
const [month, setMonth] = useState(dayjs().month())
const [year, setYear] = useState(dayjs().year())
const [selectedDates, setSelectedDates] = useState([])
const [selectingDates, _setSelectingDates] = useState([])
const staticSelectingDates = useRef([])
const setSelectingDates = newDates => {
staticSelectingDates.current = newDates
_setSelectingDates(newDates)
}
const [selectedDays, setSelectedDays] = useState([])
const [selectingDays, _setSelectingDays] = useState([])
const staticSelectingDays = useRef([])
const setSelectingDays = newDays => {
staticSelectingDays.current = newDays
_setSelectingDays(newDays)
}
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode
_setMode(newMode)
}
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name])
useEffect(() => {
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart })
}
setDates(calculateMonth(month, year, weekStart))
}, [weekStart, month, year, locale])
return (
<Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
ref={ref}
value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)}
{...props}
/>
<ToggleField
id="calendarMode"
name="calendarMode"
options={{
'specific': t('form.dates.options.specific'),
'week': t('form.dates.options.week'),
}}
value={type === 0 ? 'specific' : 'week'}
onChange={value => setType(value === 'specific' ? 0 : 1)}
/>
{type === 0 ? (
<>
<CalendarHeader>
<Button
size="30px"
title={t('form.dates.tooltips.previous')}
onClick={() => {
if (month-1 < 0) {
setYear(year-1)
setMonth(11)
} else {
setMonth(month-1)
}
}}
>&lt;</Button>
<span>{dayjs.months()[month]} {year}</span>
<Button
size="30px"
title={t('form.dates.tooltips.next')}
onClick={() => {
if (month+1 > 11) {
setYear(year+1)
setMonth(0)
} else {
setMonth(month+1)
}
}}
>&gt;</Button>
</CalendarHeader>
<CalendarDays>
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name =>
<Day key={name}>{name}</Day>
)}
</CalendarDays>
<CalendarBody>
{dates.length > 0 && dates.map((dateRow, y) =>
dateRow.map((date, x) =>
<Date
key={y+x}
$otherMonth={date.month() !== month}
$isToday={date.isToday()}
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
$selected={selectedDates.includes(date.format('DDMMYYYY'))}
$selecting={selectingDates.includes(date)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDates.includes(date.format('DDMMYYYY'))) {
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')))
} else {
setSelectedDates([...selectedDates, date.format('DDMMYYYY')])
}
}
}}
onPointerDown={e => {
startPos.current = {x, y}
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add')
setSelectingDates([date])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'))
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)))
}
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx})
}
}
setSelectingDates(found.map(d => dates[d.y][d.x]))
}
}}
>{date.date()}</Date>
)
)}
</CalendarBody>
</>
) : (
<CalendarBody>
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map((name, i) =>
<Date
key={name}
$isToday={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name}
title={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name ? t('form.dates.tooltips.today') : ''}
$selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
$selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDays.includes(((i + weekStart) % 7 + 7) % 7)) {
setSelectedDays(selectedDays.filter(d => d !== ((i + weekStart) % 7 + 7) % 7))
} else {
setSelectedDays([...selectedDays, ((i + weekStart) % 7 + 7) % 7])
}
}
}}
onPointerDown={e => {
startPos.current = i
setMode(selectedDays.includes(((i + weekStart) % 7 + 7) % 7) ? 'remove' : 'add')
setSelectingDays([((i + weekStart) % 7 + 7) % 7])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDays([...selectedDays, ...staticSelectingDays.current])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDays.current
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)))
}
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
const found = []
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i)+1; ci++) {
found.push(((ci + weekStart) % 7 + 7) % 7)
}
setSelectingDays(found)
}
}}
>{name}</Date>
)}
</CalendarBody>
)}
</Wrapper>
)
})
export default CalendarField

View file

@ -0,0 +1,104 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
margin: 30px 0;
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
`
export const StyledSubLabel = styled('label')`
display: block;
font-size: 13px;
opacity: .6;
`
export const CalendarHeader = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
padding: 6px 0;
font-size: 1.2em;
font-weight: bold;
`
export const CalendarDays = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
`
export const Day = styled('div')`
display: flex;
align-items: center;
justify-content: center;
padding: 3px 0;
font-weight: bold;
user-select: none;
opacity: .7;
@media (max-width: 350px) {
font-size: 12px;
}
`
export const CalendarBody = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
& button:first-of-type {
border-top-left-radius: 3px;
}
& button:nth-of-type(7) {
border-top-right-radius: 3px;
}
& button:nth-last-of-type(7) {
border-bottom-left-radius: 3px;
}
& button:last-of-type {
border-bottom-right-radius: 3px;
}
`
export const Date = styled('button')`
font: inherit;
color: inherit;
background: none;
border: 0;
margin: 0;
appearance: none;
transition: background-color .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
user-select: none;
touch-action: none;
${props => props.$otherMonth && `
color: var(--tertiary);
`}
${props => props.$isToday && `
font-weight: 900;
color: var(--secondary);
`}
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
color: ${props.$otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: var(--primary);
`}
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--surface);
color: ${props.$isToday ? 'var(--secondary)' : (props.$otherMonth ? 'var(--tertiary)' : 'inherit')};
`}
`

View file

@ -0,0 +1,9 @@
import { styled } from 'goober'
const Center = styled('div')`
display: flex;
align-items: center;
justify-content: center;
`
export default Center

View file

@ -0,0 +1,159 @@
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 (
<Wrapper>
<Button
small
title={t('donate.title')}
onClick={event => {
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&currency_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')}</Button>
<Options
$isOpen={isOpen}
ref={modalRef}
onBlur={e => {
if (modalRef.current?.contains(e.relatedTarget)) return
setIsOpen(false)
if (e.relatedTarget && e.relatedTarget.id === 'donate_button') {
setClosed(true)
}
}}
>
<img src={paypal_logo} alt="Donate with PayPal" />
<a onClick={linkPressed} ref={firstLinkRef} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=2" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$2')}</a>
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=5" target="_blank" rel="noreferrer noopener payment"><strong>{t('donate.options.$5')}</strong></a>
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=10" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$10')}</a>
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
</Options>
</Wrapper>
)
}
export default Donate

View file

@ -0,0 +1,64 @@
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;
}
`

View file

@ -0,0 +1,21 @@
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 (
<Wrapper title="Click anywhere to close" onClick={() => onClose()}>
<Image
src={`https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v=${eggKey}`}
onLoadStart={() => setIsLoading(true)}
onLoad={() => setIsLoading(false)}
/>
{isLoading && <Loading />}
</Wrapper>
)
}
export default Egg

View file

@ -0,0 +1,23 @@
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;
`

View file

@ -0,0 +1,17 @@
import { X } from 'lucide-react'
import { Wrapper, CloseButton } from './Error.styles'
const Error = ({
children,
onClose,
open = true,
...props
}) => (
<Wrapper role="alert" open={open} {...props}>
{children}
<CloseButton type="button" onClick={onClose} title="Close error"><X /></CloseButton>
</Wrapper>
)
export default Error

View file

@ -0,0 +1,44 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
border-radius: 3px;
background-color: var(--error);
color: #FFFFFF;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
opacity: 0;
max-height: 0;
margin: 0;
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')`
border: 0;
background: none;
height: 30px;
width: 30px;
cursor: pointer;
color: inherit;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px;
padding: 0;
`

View file

@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
import { Donate } from '/src/components'
import { Wrapper } from './Footer.styles'
const Footer = props => {
const { t } = useTranslation('common')
return (
<Wrapper id="donate" {...props}>
<span>{t('donate.info')}</span>
<Donate />
</Wrapper>
)
}
export default Footer

View file

@ -0,0 +1,26 @@
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;
}
`

View file

@ -0,0 +1,167 @@
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 ? (
<Center>
<Button
onClick={() => signIn()}
isLoading={signedIn === undefined}
primaryColor="#4286F5"
secondaryColor="#3367BD"
icon={<img aria-hidden="true" focusable="false" src={googleLogo} alt="" />}
>
{t('event:you.google_cal.login')}
</Button>
</Center>
) : (
<CalendarList>
<Title>
<Icon src={googleLogo} alt="" />
<strong>{t('event:you.google_cal.login')}</strong>
(<LinkButton type="button" onClick={e => {
e.preventDefault()
signOut()
}}>{t('event:you.google_cal.logout')}</LinkButton>)
</Title>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: true})))
}}>{t('event:you.google_cal.select_all')}</LinkButton>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: false})))
}}>{t('event:you.google_cal.select_none')}</LinkButton>
)}
</Options>
{calendars !== undefined ? calendars.map(calendar => (
<div key={calendar.id}>
<CheckboxInput
type="checkbox"
role="checkbox"
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
</div>
)) : (
<Loader />
)}
{calendars !== undefined && (
<>
<Info>{t('event:you.google_cal.info')}</Info>
<Button
small
isLoading={freeBusyLoading}
disabled={freeBusyLoading}
onClick={() => importAvailability()}
>{t('event:you.google_cal.button')}</Button>
</>
)}
</CalendarList>
)}
</>
)
}
export default GoogleCalendar

View file

@ -0,0 +1,122 @@
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;
`

View file

@ -0,0 +1,56 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import createPalette from 'hue-map'
import { useSettingsStore } from '/src/stores'
import {
Wrapper,
Label,
Bar,
Grade,
} from './Legend.styles'
const Legend = ({
min,
max,
total,
onSegmentFocus,
}) => {
const { t } = useTranslation('event')
const highlight = useSettingsStore(state => state.highlight)
const colormap = useSettingsStore(state => state.colormap)
const setHighlight = useSettingsStore(state => state.setHighlight)
const [palette, setPalette] = useState([])
useEffect(() => setPalette(createPalette({
map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
steps: max+1-min,
})), [min, max, colormap])
return (
<Wrapper>
<Label>{min}/{total} {t('event:available')}</Label>
<Bar
onMouseOut={() => onSegmentFocus(null)}
onClick={() => setHighlight(!highlight)}
title={t('event:group.legend_tooltip')}
>
{[...Array(max+1-min).keys()].map(i => i+min).map(i =>
<Grade
key={i}
$color={palette[i]}
$highlight={highlight && i === max && max > 0}
onMouseOver={() => onSegmentFocus(i)}
/>
)}
</Bar>
<Label>{max}/{total} {t('event:available')}</Label>
</Wrapper>
)
}
export default Legend

View file

@ -0,0 +1,52 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
& label:last-of-type {
text-align: right;
}
@media (max-width: 400px) {
display: block;
}
`
export const Label = styled('label')`
display: block;
font-size: 14px;
text-align: left;
`
export const Bar = styled('div')`
display: flex;
width: 40%;
height: 20px;
border-radius: 3px;
overflow: hidden;
margin: 0 8px;
border: 1px solid var(--text);
@media (max-width: 400px) {
width: 100%;
margin: 8px 0;
}
`
export const Grade = styled('div')`
flex: 1;
background-color: ${props => props.$color};
${props => props.$highlight && `
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4.5px,
rgba(0,0,0,.5) 4.5px,
rgba(0,0,0,.5) 9px
);
`}
`

View file

@ -0,0 +1,5 @@
import { Wrapper, Loader } from './Loading.styles'
const Loading = () => <Wrapper><Loader /></Wrapper>
export default Loading

View file

@ -0,0 +1,35 @@
import { styled } from 'goober'
export const Wrapper = styled('main')`
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
`
export const Loader = styled('div')`
@keyframes load {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
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...';
}
}
`

View file

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import {
Wrapper,
A,
Top,
Image,
Title,
Tagline,
} from './Logo.styles'
import image from '/src/res/logo.svg'
const Logo = () => {
const { t } = useTranslation('common')
return (
<Wrapper>
<A as={Link} to="/">
<Top>
<Image src={image} alt="" />
<Title>CRAB FIT</Title>
</Top>
<Tagline>{t('common:tagline')}</Tagline>
</A>
</Wrapper>
)
}
export default Logo

View file

@ -0,0 +1,69 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
display: flex;
align-items: center;
justify-content: center;
`
export const A = styled('a')`
text-decoration: none;
@keyframes jelly {
from,to {
transform: scale(1,1)
}
25% {
transform: scale(.9,1.1)
}
50% {
transform: scale(1.1,.9)
}
75% {
transform: scale(.95,1.05)
}
}
&:hover img {
animation: jelly .5s 1;
}
@media (prefers-reduced-motion: reduce) {
&:hover img {
animation: none;
}
}
`
export const Top = styled('div')`
display: inline-flex;
justify-content: center;
align-items: center;
`
export const Image = styled('img')`
width: 2.5rem;
margin-right: 16px;
`
export const Title = styled('span')`
display: block;
font-size: 2rem;
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 2px 0 var(--shadow);
line-height: 1em;
`
export const Tagline = styled('span')`
text-decoration: underline;
font-size: 14px;
padding-top: 2px;
display: flex;
align-items: center;
justify-content: center;
@media print {
display: none;
}
`

View file

@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { PublicClientApplication } from '@azure/msal-browser'
import { Client } from '@microsoft/microsoft-graph-client'
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/GoogleCalendar.styles'
import outlookLogo from '/src/res/outlook.svg'
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
// Initialise the MSAL object
const publicClientApplication = new PublicClientApplication({
auth: {
clientId: '5d1ab8af-1ba3-4b79-b033-b0ee509c2be6',
redirectUri: process.env.NODE_ENV === 'production' ? 'https://crab.fit' : 'http://localhost:3000',
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: true,
},
})
const getAuthenticatedClient = accessToken => {
const client = Client.init({
authProvider: done => done(null, accessToken),
})
return client
}
const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [client, setClient] = useState(undefined)
const [calendars, setCalendars] = useState(undefined)
const [freeBusyLoading, setFreeBusyLoading] = useState(false)
const { t } = useTranslation('event')
const checkLogin = async () => {
const accounts = publicClientApplication.getAllAccounts()
if (accounts && accounts.length > 0) {
try {
const accessToken = await getAccessToken()
setClient(getAuthenticatedClient(accessToken))
} catch (e) {
console.error(e)
signOut()
}
} else {
setClient(null)
}
}
const signIn = async () => {
try {
await publicClientApplication.loginPopup({ scopes })
} catch (e) {
console.error(e)
} finally {
checkLogin()
}
}
const signOut = async () => {
try {
await publicClientApplication.logoutRedirect({
onRedirectNavigate: () => false,
})
} catch (e) {
console.error(e)
} finally {
checkLogin()
}
}
const getAccessToken = async () => {
try {
const accounts = publicClientApplication.getAllAccounts()
if (accounts.length <= 0) throw new Error('login_required')
// Try to get silently
const result = await publicClientApplication.acquireTokenSilent({
scopes,
account: accounts[0],
})
return result.accessToken
} catch (e) {
if ([
'consent_required',
'interaction_required',
'login_required',
'no_account_in_silent_request'
].includes(e.message)) {
// Try to get with popup
const result = await publicClientApplication.acquireTokenPopup({ scopes })
return result.accessToken
} else {
throw e
}
}
}
const importAvailability = () => {
setFreeBusyLoading(true)
gtag('event', 'outlook_cal_sync', {
'event_category': 'event',
})
client.api('/me/calendar/getSchedule').post({
schedules: calendars.filter(c => c.checked).map(c => c.id),
startTime: {
dateTime: timeMin,
timeZone,
},
endTime: {
dateTime: timeMax,
timeZone,
},
availabilityViewInterval: 30,
})
.then(response => {
onImport(response.value.reduce((busy, c) => c.error ? busy : [...busy, ...c.scheduleItems.filter(item => item.status === 'busy' || item.status === 'tentative')], []))
})
.catch(e => {
console.error(e)
signOut()
})
.finally(() => setFreeBusyLoading(false))
}
useEffect(() => void checkLogin(), [])
useEffect(() => {
if (client) {
client.api('/me/calendars').get()
.then(response => {
setCalendars(response.value.map(item => ({
'name': item.name,
'description': item.owner.name,
'id': item.owner.address,
'color': item.hexColor,
'checked': item.isDefaultCalendar === true,
})))
})
.catch(e => {
console.error(e)
signOut()
})
}
}, [client])
return (
<>
{!client ? (
<Center>
<Button
onClick={() => signIn()}
isLoading={client === undefined}
primaryColor="#0364B9"
secondaryColor="#02437D"
icon={<img aria-hidden="true" focusable="false" src={outlookLogo} alt="" />}
>{t('event:you.outlook_cal')}</Button>
</Center>
) : (
<CalendarList>
<Title>
<Icon src={outlookLogo} alt="" />
<strong>{t('event:you.outlook_cal')}</strong>
(<LinkButton type="button" onClick={e => {
e.preventDefault()
signOut()
}}>{t('event:you.google_cal.logout')}</LinkButton>)
</Title>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: true})))
}}>{t('event:you.google_cal.select_all')}</LinkButton>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: false})))
}}>{t('event:you.google_cal.select_none')}</LinkButton>
)}
</Options>
{calendars !== undefined ? calendars.map(calendar => (
<div key={calendar.id}>
<CheckboxInput
type="checkbox"
role="checkbox"
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
</div>
)) : <Loader />}
{calendars !== undefined && (
<>
<Info>{t('event:you.google_cal.info')}</Info>
<Button
small
isLoading={freeBusyLoading}
disabled={freeBusyLoading}
onClick={() => importAvailability()}
>{t('event:you.google_cal.button')}</Button>
</>
)}
</CalendarList>
)}
</>
)
}
export default OutlookCalendar

View file

@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRecentsStore, useLocaleUpdateStore } from '/src/stores'
import { AboutSection, StyledMain } from '../../pages/Home/Home.styles'
import { Wrapper, Recent } from './Recents.styles'
dayjs.extend(relativeTime)
const Recents = ({ target }) => {
const recents = useRecentsStore(state => state.recents)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['home', 'common'])
return !!recents.length && (
<Wrapper>
<AboutSection id="recents">
<StyledMain>
<h2>{t('home:recently_visited')}</h2>
{recents.map(event => (
<Recent href={`/${event.id}`} target={target} key={event.id}>
<span className="name">{event.name}</span>
<span locale={locale} className="date" title={dayjs.unix(event.created).format('D MMMM, YYYY')}>{t('common:created', { date: dayjs.unix(event.created).fromNow() })}</span>
</Recent>
))}
</StyledMain>
</AboutSection>
</Wrapper>
)
}
export default Recents

View file

@ -0,0 +1,42 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
@media print {
display: none;
}
`
export const Recent = styled('a')`
text-decoration: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
flex-wrap: wrap;
& .name {
font-weight: 700;
font-size: 1.1em;
color: var(--secondary);
flex: 1;
display: block;
}
& .date {
font-weight: 400;
opacity: .8;
white-space: nowrap;
color: var(--text);
}
&:hover .name {
text-decoration: underline;
}
@media (max-width: 500px) {
display: block;
& .date {
white-space: normal;
}
}
`

View file

@ -0,0 +1,44 @@
import { forwardRef } from 'react'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledSelect,
} from './SelectField.styles'
const SelectField = forwardRef(({
label,
subLabel,
id,
options = [],
inline = false,
small = false,
defaultOption,
...props
}, ref) => (
<Wrapper $inline={inline} $small={small}>
{label && <StyledLabel htmlFor={id} $inline={inline} $small={small}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledSelect
id={id}
$small={small}
ref={ref}
{...props}
>
{defaultOption && <option value="">{defaultOption}</option>}
{Array.isArray(options) ? (
options.map(value =>
<option key={value} value={value}>{value}</option>
)
) : (
Object.entries(options).map(([key, value]) =>
<option key={key} value={key}>{value}</option>
)
)}
</StyledSelect>
</Wrapper>
))
export default SelectField

View file

@ -0,0 +1,61 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
${props => props.$inline && `
margin: 0;
`}
${props => props.$small && `
margin: 10px 0;
`}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.$inline && `
font-size: 16px;
`}
${props => props.$small && `
font-size: .9rem;
`}
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const StyledSelect = styled('select', forwardRef)`
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
outline: none;
transition: border-color .15s, box-shadow .15s;
appearance: none;
background-image: url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><foreignObject width="100px" height="100px"><div xmlns="http://www.w3.org/1999/xhtml" style="color:#F79E00;font-size:60px;display:flex;align-items:center;justify-content:center;height:100%;width:100%;"></div></foreignObject></svg>')}');
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 1em;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
${props => props.$small && `
padding: 6px 8px;
`}
`

View file

@ -0,0 +1,179 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import { Settings as SettingsIcon } from 'lucide-react'
import { maps } from 'hue-map'
import { ToggleField, SelectField } from '/src/components'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
OpenButton,
Modal,
Heading,
Cover,
} from './Settings.styles'
import locales from '/src/i18n/locales'
import { unhyphenate } from '/src/utils'
// Language specific options
const setDefaults = (lang, store) => {
if (locales[lang]) {
store.setWeekStart(locales[lang].weekStart)
store.setTimeFormat(locales[lang].timeFormat)
}
}
const Settings = () => {
const { pathname } = useLocation()
const store = useSettingsStore()
const [isOpen, _setIsOpen] = useState(false)
const { t, i18n } = useTranslation('common')
const setLocale = useLocaleUpdateStore(state => state.setLocale)
const firstControlRef = useRef()
const onEsc = e => {
if (e.key === 'Escape') {
setIsOpen(false)
}
}
const setIsOpen = open => {
_setIsOpen(open)
if (open) {
window.setTimeout(() => firstControlRef.current?.focus(), 150)
document.addEventListener('keyup', onEsc, true)
} else {
document.removeEventListener('keyup', onEsc)
}
}
useEffect(() => {
if (Object.keys(locales).includes(i18n.language)) {
locales[i18n.language].import().then(() => {
dayjs.locale(i18n.language)
setLocale(dayjs.locale())
document.documentElement.setAttribute('lang', i18n.language)
})
} else {
setLocale('en')
document.documentElement.setAttribute('lang', 'en')
}
}, [i18n.language, setLocale])
if (!i18n.options.storedLang) {
setDefaults(i18n.language, store)
i18n.options.storedLang = i18n.language
}
i18n.on('languageChanged', lang => {
setDefaults(lang, store)
})
// Reset scroll on navigation
useEffect(() => window.scrollTo(0, 0), [pathname])
return (
<>
<OpenButton
$isOpen={isOpen}
type="button"
onClick={() => setIsOpen(!isOpen)} title={t('options.name')}
><SettingsIcon /></OpenButton>
<Cover $isOpen={isOpen} onClick={() => setIsOpen(false)} />
<Modal $isOpen={isOpen}>
<Heading>{t('options.name')}</Heading>
<ToggleField
label={t('options.weekStart.label')}
name="weekStart"
id="weekStart"
options={{
'Sunday': t('options.weekStart.options.Sunday'),
'Monday': t('options.weekStart.options.Monday'),
}}
value={store.weekStart === 0 ? 'Sunday' : 'Monday'}
onChange={value => store.setWeekStart(value === 'Sunday' ? 0 : 1)}
inputRef={firstControlRef}
/>
<ToggleField
label={t('options.timeFormat.label')}
name="timeFormat"
id="timeFormat"
options={{
'12h': t('options.timeFormat.options.12h'),
'24h': t('options.timeFormat.options.24h'),
}}
value={store.timeFormat}
onChange={value => store.setTimeFormat(value)}
/>
<ToggleField
label={t('options.theme.label')}
name="theme"
id="theme"
options={{
'System': t('options.theme.options.System'),
'Light': t('options.theme.options.Light'),
'Dark': t('options.theme.options.Dark'),
}}
value={store.theme}
onChange={value => store.setTheme(value)}
/>
<SelectField
label={t('options.colormap.label')}
name="colormap"
id="colormap"
options={{
'crabfit': t('options.colormap.classic'),
...Object.fromEntries(Object.keys(maps).sort().map(palette => [
palette,
unhyphenate(palette)
])),
}}
small
value={store.colormap}
onChange={event => store.setColormap(event.target.value)}
/>
<ToggleField
label={t('options.highlight.label')}
name="highlight"
id="highlight"
title={t('options.highlight.title')}
options={{
'Off': t('options.highlight.options.Off'),
'On': t('options.highlight.options.On'),
}}
value={store.highlight ? 'On' : 'Off'}
onChange={value => store.setHighlight(value === 'On')}
/>
<SelectField
label={t('options.language.label')}
name="language"
id="language"
options={{
...Object.keys(locales).reduce((ls, l) => {
ls[l] = locales[l].name
return ls
}, {}),
...process.env.NODE_ENV !== 'production' && { 'cimode': 'DEV' },
}}
small
value={i18n.language}
onChange={event => i18n.changeLanguage(event.target.value)}
/>
</Modal>
</>
)
}
export default Settings

View file

@ -0,0 +1,88 @@
import { styled } from 'goober'
export const OpenButton = styled('button')`
border: 0;
background: none;
height: 50px;
width: 50px;
cursor: pointer;
color: inherit;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 12px;
right: 12px;
z-index: 200;
border-radius: 100%;
transition: background-color .15s;
transition: transform .15s;
padding: 0;
${props => props.$isOpen && `
transform: rotate(-60deg);
`}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media print {
display: none;
}
`
export const Cover = styled('div')`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: none;
${props => props.$isOpen && `
display: block;
`}
`
export const Modal = styled('div')`
position: absolute;
top: 70px;
right: 12px;
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 150;
padding: 10px 18px;
border-radius: 3px;
width: 270px;
box-sizing: border-box;
max-width: calc(100% - 20px);
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
pointer-events: none;
opacity: 0;
transform: translateY(-10px);
visibility: hidden;
transition: opacity .15s, transform .15s, visibility .15s;
${props => props.$isOpen && `
pointer-events: all;
opacity: 1;
transform: translateY(0);
visibility: visible;
`}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media print {
display: none;
}
`
export const Heading = styled('span')`
font-size: 1.5rem;
display: block;
margin: 6px 0;
line-height: 1em;
`

View file

@ -0,0 +1,24 @@
import { forwardRef } from 'react'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledInput,
} from './TextField.styles'
const TextField = forwardRef(({
label,
subLabel,
id,
inline = false,
...props
}, ref) => (
<Wrapper $inline={inline}>
{label && <StyledLabel htmlFor={id} $inline={inline}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledInput id={id} ref={ref} {...props} />
</Wrapper>
))
export default TextField

View file

@ -0,0 +1,47 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
${props => props.$inline && `
margin: 0;
`}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.$inline && `
font-size: 16px;
`}
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const StyledInput = styled('input', forwardRef)`
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
`

View file

@ -0,0 +1,146 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import dayjs from 'dayjs'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
Range,
Handle,
Selected,
} from './TimeRangeField.styles'
const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24']
const TimeRangeField = forwardRef(({
label,
subLabel,
id,
setValue,
...props
}, ref) => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const locale = useLocaleUpdateStore(state => state.locale)
const [start, setStart] = useState(9)
const [end, setEnd] = useState(17)
const isStartMoving = useRef(false)
const isEndMoving = useRef(false)
const rangeRef = useRef()
const rangeRect = useRef()
useEffect(() => {
if (rangeRef.current) {
rangeRect.current = rangeRef.current.getBoundingClientRect()
}
}, [rangeRef])
useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name])
const handleMouseMove = e => {
if (isStartMoving.current || isEndMoving.current) {
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
if (isStartMoving.current) {
setStart(step)
} else if (isEndMoving.current) {
setEnd(step)
}
}
}
return (
<Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
value={JSON.stringify({start, end})}
ref={ref}
{...props}
/>
<Range ref={rangeRef}>
<Selected $start={start} $end={start > end ? 24 : end} />
{start > end && <Selected $start={start > end ? 0 : start} $end={end} />}
<Handle
$value={start}
label={timeFormat === '24h' ? times[start] : dayjs().hour(times[start]).format('ha')}
$extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove)
isStartMoving.current = true
document.addEventListener('mouseup', () => {
isStartMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setStart(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
setStart(Math.max(start-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
setStart(Math.min(start+1, 24))
}
}}
/>
<Handle
$value={end}
label={timeFormat === '24h' ? times[end] : dayjs().hour(times[end]).format('ha')}
$extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove)
isEndMoving.current = true
document.addEventListener('mouseup', () => {
isEndMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setEnd(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
setEnd(Math.max(end-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
setEnd(Math.min(end+1, 24))
}
}}
/>
</Range>
</Wrapper>
)
})
export default TimeRangeField

View file

@ -0,0 +1,85 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
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;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const Range = styled('div', forwardRef)`
user-select: none;
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 3px;
height: 50px;
position: relative;
margin: 38px 6px 18px;
`
export const Handle = styled('div')`
height: calc(100% + 20px);
width: 20px;
border: 1px solid var(--primary);
background-color: var(--highlight);
border-radius: 3px;
position: absolute;
top: -10px;
left: calc(${props => props.$value * 4.166}% - 11px);
cursor: ew-resize;
touch-action: none;
transition: left .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
&:after {
content: '|||';
font-size: 8px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--shadow);
}
&:before {
content: '${props => props.label}';
position: absolute;
bottom: calc(100% + 8px);
text-align: center;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
${props => props.$extraPadding}
}
`
export const Selected = styled('div')`
position: absolute;
height: 100%;
left: ${props => props.$start * 4.166}%;
right: calc(100% - ${props => props.$end * 4.166}%);
top: 0;
background-color: var(--primary);
border-radius: 2px;
transition: left .1s, right .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`

View file

@ -0,0 +1,43 @@
import { Info } from 'lucide-react'
import {
Wrapper,
ToggleContainer,
StyledLabel,
Option,
HiddenInput,
LabelButton,
} from './ToggleField.styles'
const ToggleField = ({
label,
name,
title = '',
options = [],
value,
onChange,
inputRef,
}) => (
<Wrapper>
{label && <StyledLabel title={title}>{label} {title !== '' && <Info />}</StyledLabel>}
<ToggleContainer>
{Object.entries(options).map(([key, label]) =>
<Option key={label}>
<HiddenInput
type="radio"
name={name}
value={label}
id={`${name}-${label}`}
checked={value === key}
onChange={() => onChange(key)}
ref={inputRef}
/>
<LabelButton htmlFor={`${name}-${label}`}>{label}</LabelButton>
</Option>
)}
</ToggleContainer>
</Wrapper>
)
export default ToggleField

View file

@ -0,0 +1,75 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 10px 0;
`
export const ToggleContainer = styled('div')`
display: flex;
border: 1px solid var(--primary);
border-radius: 3px;
overflow: hidden;
--focus-color: var(--primary);
transition: border .15s;
&:focus-within {
--focus-color: var(--secondary);
border: 1px solid var(--focus-color);
& label {
box-shadow: inset 0 -3px 0 0 var(--focus-color);
}
}
& > div:first-of-type label {
border-end-start-radius: 2px;
}
& > div:last-of-type label {
border-end-end-radius: 2px;
}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: .9rem;
& svg {
height: 1em;
width: 1em;
vertical-align: middle;
}
`
export const Option = styled('div')`
flex: 1;
position: relative;
`
export const HiddenInput = styled('input', forwardRef)`
height: 0;
width: 0;
position: absolute;
top: 0;
left: 0;
opacity: 0;
appearance: none;
&:checked + label {
color: var(--background);
background-color: var(--focus-color);
}
`
export const LabelButton = styled('label')`
padding: 6px;
display: flex;
text-align: center;
cursor: pointer;
user-select: none;
height: 100%;
box-sizing: border-box;
align-items: center;
justify-content: center;
transition: box-shadow .15s, background-color .15s;
`

View file

@ -0,0 +1,32 @@
import { Button } from '/src/components'
import { useTranslateStore } from '/src/stores'
import {
Wrapper,
ButtonWrapper,
} from './TranslateDialog.styles'
const TranslateDialog = () => {
const navigatorLang = useTranslateStore(state => state.navigatorLang)
const setDialogDismissed = useTranslateStore(state => state.setDialogDismissed)
return (
<Wrapper>
<div>
<h2>Translate Crab Fit</h2>
<p>Crab Fit hasn't been translated to your language yet.</p>
</div>
<ButtonWrapper>
<Button
target="_blank"
rel="noreferrer noopener"
href={`https://docs.google.com/forms/d/e/1FAIpQLSd5bcs8LTP_8Ydrh2e4iMlZft5x81qSfAxekuuQET27A2mBhA/viewform?usp=pp_url&entry.1530835706=__other_option__&entry.1530835706.other_option_response=${encodeURIComponent(navigatorLang)}`}
>Help translate!</Button>
<Button secondary onClick={() => setDialogDismissed(true)}>Close</Button>
</ButtonWrapper>
</Wrapper>
)
}
export default TranslateDialog

View file

@ -0,0 +1,47 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
position: fixed;
top: 20px;
left: 20px;
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 900;
padding: 20px;
border-radius: 3px;
box-sizing: border-box;
width: 500px;
max-width: calc(100% - 40px);
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
display: flex;
align-items: center;
& h2 {
margin: 0;
font-size: 1.3rem;
}
& p {
margin: 12px 0 0;
font-size: 1rem;
}
@media (max-width: 400px) {
display: block;
}
`
export const ButtonWrapper = styled('div')`
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-left: 20px;
white-space: nowrap;
@media (max-width: 400px) {
margin: 20px 0 0;
white-space: normal;
}
`

View file

@ -0,0 +1,24 @@
export { default as TextField } from './TextField/TextField'
export { default as SelectField } from './SelectField/SelectField'
export { default as CalendarField } from './CalendarField/CalendarField'
export { default as TimeRangeField } from './TimeRangeField/TimeRangeField'
export { default as ToggleField } from './ToggleField/ToggleField'
export { default as Button } from './Button/Button'
export { default as Legend } from './Legend/Legend'
export { default as AvailabilityViewer } from './AvailabilityViewer/AvailabilityViewer'
export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'
export { default as Error } from './Error/Error'
export { default as Loading } from './Loading/Loading'
export { default as Center } from './Center/Center'
export { default as Donate } from './Donate/Donate'
export { default as Settings } from './Settings/Settings'
export { default as Egg } from './Egg/Egg'
export { default as Footer } from './Footer/Footer'
export { default as Recents } from './Recents/Recents'
export { default as Logo } from './Logo/Logo'
export { default as TranslateDialog } from './TranslateDialog/TranslateDialog'
export const _GoogleCalendar = () => import('./GoogleCalendar/GoogleCalendar')
export const _OutlookCalendar = () => import('./OutlookCalendar/OutlookCalendar')