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

62
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,62 @@
import { useState, useEffect, useCallback, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import * as Pages from '/src/pages'
import { Settings, Loading, Egg, TranslateDialog } from '/src/components'
import { useSettingsStore, useTranslateStore } from '/src/stores'
const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
const App = () => {
const [eggCount, setEggCount] = useState(0)
const [eggVisible, setEggVisible] = useState(false)
const [eggKey, setEggKey] = useState(0)
const languageSupported = useTranslateStore(state => state.navigatorSupported)
const translateDialogDismissed = useTranslateStore(state => state.translateDialogDismissed)
const eggHandler = useCallback(e => {
if (EGG_PATTERN.indexOf(e.key) < 0 || e.key !== EGG_PATTERN[eggCount]) return setEggCount(0)
setEggCount(eggCount+1)
if (EGG_PATTERN.length === eggCount+1) {
setEggKey(eggKey+1)
setEggCount(0)
setEggVisible(true)
}
}, [eggCount, eggKey])
useEffect(() => {
document.addEventListener('keyup', eggHandler, false)
return () => document.removeEventListener('keyup', eggHandler, false)
}, [eggHandler])
// Use user theme preference
const theme = useSettingsStore(state => state.theme)
useEffect(() => {
document.body.classList.toggle('light', theme === 'Light')
document.body.classList.toggle('dark', theme === 'Dark')
}, [theme])
return (
<>
{!languageSupported && !translateDialogDismissed && <TranslateDialog />}
<Suspense fallback={<Loading />}>
<Settings />
<Routes>
<Route path="/" element={<Pages.Home />} />
<Route path="/how-to" element={<Pages.Help />} />
<Route path="/privacy" element={<Pages.Privacy />} />
<Route path="/create" element={<Pages.Create />} />
<Route path="/:id" element={<Pages.Event />} />
</Routes>
</Suspense>
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
</>
)
}
export default App

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')

View file

@ -0,0 +1,28 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-http-backend'
import locales from './locales'
const storedLang = localStorage.getItem('i18nextLng')
i18n
.use(LanguageDetector)
.use(Backend)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: Object.keys(locales),
ns: 'common',
debug: process.env.NODE_ENV !== 'production',
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/i18n/{{lng}}/{{ns}}.json',
},
storedLang,
}).then(() => document.documentElement.setAttribute('lang', i18n.language))
export default i18n

View file

@ -0,0 +1,83 @@
const locales = {
'de': { // German
name: 'Deutsch',
import: () => import('dayjs/locale/de'),
weekStart: 1,
timeFormat: '24h',
},
'en': { // English (US)
name: 'English (US)',
import: () => import('dayjs/locale/en'),
weekStart: 0,
timeFormat: '12h',
},
'en-GB': { // English (UK)
name: 'English (UK)',
import: () => import('dayjs/locale/en-gb'),
weekStart: 1,
timeFormat: '12h',
},
'es': { // Spanish
name: 'Español',
import: () => import('dayjs/locale/es'),
weekStart: 1,
timeFormat: '24h',
},
'fr': { // French
name: 'Français',
import: () => import('dayjs/locale/fr'),
weekStart: 1,
timeFormat: '24h',
},
'hi': { // Hindi
name: 'हिंदी',
import: () => import('dayjs/locale/hi'),
weekStart: 1,
timeFormat: '12h',
},
'id': { // Indonesian
name: 'Indonesia',
import: () => import('dayjs/locale/id'),
weekStart: 1,
timeFormat: '24h',
separator: '.',
},
'ja': { // Japanese
name: '日本語',
import: () => import('dayjs/locale/ja'),
weekStart: 0,
timeFormat: '12h',
},
'ko': { // Korean
name: '한국어',
import: () => import('dayjs/locale/ko'),
weekStart: 0,
timeFormat: '24h',
},
'pl': { // Polish
name: 'Polskie',
import: () => import('dayjs/locale/pl'),
weekStart: 1,
timeFormat: '12h',
},
'pt-BR': { // Portuguese (Brazil)
name: 'Português (do Brasil)',
import: () => import('dayjs/locale/pt-br'),
weekStart: 0,
timeFormat: '24h',
},
'ru': { // Russian
name: 'Pусский',
import: () => import('dayjs/locale/ru'),
weekStart: 1,
timeFormat: '24h',
},
// 'zh-CN': { // Chinese
// name: '中文',
// import: () => import('dayjs/locale/zh-cn'),
// weekStart: 1,
// timeFormat: '12h',
// },
}
export default locales

24
frontend/src/index.jsx Normal file
View file

@ -0,0 +1,24 @@
import { StrictMode, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { setup } from 'goober'
import { shouldForwardProp } from 'goober/should-forward-prop'
import { BrowserRouter } from 'react-router-dom'
import '/src/i18n'
import App from './App'
setup(
createElement,
undefined, undefined,
shouldForwardProp(prop => !prop.startsWith('$'))
)
const root = createRoot(document.getElementById('app'))
root.render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View file

@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
CalendarField,
TimeRangeField,
SelectField,
Button,
Error,
Recents,
Footer,
} from '/src/components'
import {
StyledMain,
CreateForm,
TitleSmall,
TitleLarge,
P,
OfflineMessage,
ShareInfo,
} from './Create.styles'
import api from '/src/services'
import { useRecentsStore } from '/src/stores'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Create = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [createdEvent, setCreatedEvent] = useState(null)
const [copied, setCopied] = useState(null)
const [showFooter, setShowFooter] = useState(true)
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home', 'event'])
const addRecent = useRecentsStore(state => state.addRecent)
useEffect(() => {
if (window.self === window.top) {
navigate('/')
}
document.title = 'Create a Crab Fit'
if (window.parent) {
window.parent.postMessage('crabfit-create', '*')
window.addEventListener('message', e => {
if (e.data === 'safari-extension') {
setShowFooter(false)
}
}, {
once: true
})
}
}, [navigate])
const onSubmit = async data => {
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'))
}
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
if (start > end) {
for (let i = 0; i < end; i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'))
}
const event = await api.post('/event', {
event: {
name: data.name,
times: times,
timezone: data.timezone,
},
})
setCreatedEvent(event)
addRecent({
id: event.id,
created: event.created,
name: event.name,
})
gtag('event', 'create_event', {
'event_category': 'create',
})
} catch (e) {
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false)
}
}
return (
<>
<StyledMain>
<TitleSmall>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge>
</StyledMain>
{createdEvent ? (
<StyledMain>
<OfflineMessage>
<h2>{createdEvent?.name}</h2>
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${createdEvent.id}`)
.then(() => {
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
})
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo>
<ShareInfo>
{/* eslint-disable-next-line */}
<Trans i18nKey="event:nav.shareinfo_alt">Click the link above to copy it to your clipboard, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: createdEvent?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${createdEvent?.id}`)}`} target="_blank">email</a>.</Trans>
</ShareInfo>
{showFooter && <Footer small />}
</OfflineMessage>
</StyledMain>
) : (
<>
<Recents target="_blank" />
<StyledMain>
{offline ? (
<OfflineMessage>
<h1>🦀📵</h1>
<P>{t('home:offline')}</P>
</OfflineMessage>
) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField
label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')}
type="text"
id="name"
{...register('name')}
/>
<CalendarField
label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')}
id="dates"
required
setValue={setValue}
{...register('dates')}
/>
<TimeRangeField
label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')}
id="times"
required
setValue={setValue}
{...register('times')}
/>
<SelectField
label={t('home:form.timezone.label')}
id="timezone"
options={timezones}
required
{...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')}
/>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Button type="submit" isLoading={isLoading} disabled={isLoading} style={{ width: '100%' }}>{t('home:form.button')}</Button>
</CreateForm>
)}
</StyledMain>
</>
)}
</>
)
}
export default Create

View file

@ -0,0 +1,60 @@
import { styled } from 'goober'
export const StyledMain = styled('div')`
width: 600px;
margin: 10px auto;
max-width: calc(100% - 30px);
`
export const CreateForm = styled('form')`
margin: 0 0 30px;
`
export const TitleSmall = styled('span')`
display: block;
margin: 0;
font-size: 2rem;
text-align: center;
font-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
`
export const TitleLarge = styled('h1')`
margin: 0;
font-size: 2rem;
text-align: center;
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 3px 0 var(--secondary);
line-height: 1em;
text-transform: uppercase;
`
export const P = styled('p')`
font-weight: 500;
line-height: 1.6em;
`
export const OfflineMessage = styled('div')`
text-align: center;
margin: 50px 0 20px;
`
export const ShareInfo = styled('p')`
margin: 6px 0;
text-align: center;
font-size: 15px;
padding: 10px 0;
${props => props.onClick && `
cursor: pointer;
&:hover {
color: var(--secondary);
}
`}
`

View file

@ -0,0 +1,470 @@
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { useParams } from 'react-router-dom'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
Footer,
TextField,
SelectField,
Button,
AvailabilityViewer,
AvailabilityEditor,
Error,
Logo,
} from '/src/components'
import { StyledMain } from '../Home/Home.styles'
import {
EventName,
EventDate,
LoginForm,
LoginSection,
Info,
ShareInfo,
Tabs,
Tab,
} from './Event.styles'
import api from '/src/services'
import { useSettingsStore, useRecentsStore, useLocaleUpdateStore } from '/src/stores'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
dayjs.extend(relativeTime)
const Event = () => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const weekStart = useSettingsStore(state => state.weekStart)
const addRecent = useRecentsStore(state => state.addRecent)
const removeRecent = useRecentsStore(state => state.removeRecent)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['common', 'event'])
const { register, handleSubmit, setFocus, reset } = useForm()
const { id } = useParams()
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
const [user, setUser] = useState(null)
const [password, setPassword] = useState(null)
const [tab, setTab] = useState(user ? 'you' : 'group')
const [isLoading, setIsLoading] = useState(true)
const [isLoginLoading, setIsLoginLoading] = useState(false)
const [error, setError] = useState(null)
const [event, setEvent] = useState(null)
const [people, setPeople] = useState([])
const [times, setTimes] = useState([])
const [timeLabels, setTimeLabels] = useState([])
const [dates, setDates] = useState([])
const [min, setMin] = useState(0)
const [max, setMax] = useState(0)
const [copied, setCopied] = useState(null)
useEffect(() => {
const fetchEvent = async () => {
try {
const event = await api.get(`/event/${id}`)
setEvent(event)
addRecent({
id: event.id,
created: event.created,
name: event.name,
})
document.title = `${event.name} | Crab Fit`
} catch (e) {
console.error(e)
if (e.status === 404) {
removeRecent(id)
}
} finally {
setIsLoading(false)
}
}
fetchEvent()
}, [id, addRecent, removeRecent])
useEffect(() => {
const fetchPeople = async () => {
try {
const { people } = await api.get(`/event/${id}/people`)
const adjustedPeople = people.map(person => ({
...person,
availability: (!!person.availability.length && person.availability[0].length === 13)
? person.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: person.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}))
setPeople(adjustedPeople)
} catch (e) {
console.error(e)
}
}
if (tab === 'group') {
fetchPeople()
}
}, [tab, id, timezone])
// Convert to timezone and expand minute segments
useEffect(() => {
if (event) {
const isSpecificDates = event.times[0].length === 13
setTimes(event.times.reduce(
(allTimes, time) => {
const date = isSpecificDates ?
dayjs(time, 'HHmm-DDMMYYYY').utc(true).tz(timezone)
: dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone)
const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d'
return [
...allTimes,
date.minute(0).format(format),
date.minute(15).format(format),
date.minute(30).format(format),
date.minute(45).format(format),
]
},
[]
).sort((a, b) => {
if (isSpecificDates) {
return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY'))
} else {
return dayjs(a, 'HHmm').day((parseInt(a.substring(5))-weekStart % 7 + 7) % 7)
.diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7))
}
}))
}
}, [event, timezone, weekStart])
useEffect(() => {
if (!!times.length && !!people.length) {
setMin(times.reduce((min, time) => {
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
)
return total < min ? total : min
}, Infinity))
setMax(times.reduce((max, time) => {
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
)
return total > max ? total : max
}, -Infinity))
}
}, [times, people])
useEffect(() => {
if (times.length) {
setTimeLabels(times.reduce((labels, datetime) => {
const time = datetime.substring(0, 4)
if (labels.includes(time)) return labels
return [...labels, time]
}, [])
.sort((a, b) => parseInt(a) - parseInt(b))
.reduce((labels, time, i, allTimes) => {
if (time.substring(2) === '30') return [...labels, { label: '', time }]
if (allTimes.length - 1 === i) return [
...labels,
{ label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: null }
]
if (allTimes.length - 1 > i && parseInt(allTimes[i+1].substring(0, 2))-1 > parseInt(time.substring(0, 2))) return [
...labels,
{ label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: 'space' },
{ label: '', time: 'space' },
{ label: '', time: 'space' },
]
if (time.substring(2) !== '00') return [...labels, { label: '', time }]
return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }]
}, []))
setDates(times.reduce((allDates, time) => {
if (time.substring(2, 4) !== '00') return allDates
const date = time.substring(5)
if (allDates.includes(date)) return allDates
return [...allDates, date]
}, []))
}
}, [times, timeFormat, locale])
useEffect(() => {
const fetchUser = async () => {
try {
const resUser = await api.post(`/event/${id}/people/${user.name}`, { person: { password } })
const adjustedUser = {
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
} catch (e) {
console.log(e)
}
}
if (user) {
fetchUser()
}
}, [timezone])
const onSubmit = async data => {
if (!data.name || data.name.length === 0) {
setFocus('name')
return setError(t('event:form.errors.name_required'))
}
setIsLoginLoading(true)
setError(null)
try {
const resUser = await api.post(`/event/${id}/people/${data.name}`, {
person: {
password: data.password,
},
})
setPassword(data.password)
const adjustedUser = {
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
setTab('you')
} catch (e) {
if (e.status === 401) {
setError(t('event:form.errors.password_incorrect'))
} else if (e.status === 404) {
// Create user
try {
await api.post(`/event/${id}/people`, {
person: {
name: data.name,
password: data.password,
},
})
setPassword(data.password)
setUser({
name: data.name,
availability: [],
})
setTab('you')
} catch (e) {
setError(t('event:form.errors.unknown'))
}
}
} finally {
setIsLoginLoading(false)
gtag('event', 'login', {
'event_category': 'event',
})
reset()
}
}
return (
<>
<StyledMain>
<Logo />
{(!!event || isLoading) ? (
<>
<EventName $isLoading={isLoading}>{event?.name}</EventName>
<EventDate $isLoading={isLoading} locale={locale} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && t('common:created', { date: dayjs.unix(event?.created).fromNow() })}</EventDate>
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`)
.then(() => {
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
})
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${id}`}</ShareInfo>
<ShareInfo $isLoading={isLoading} className="instructions">
{!!event?.name &&
<Trans i18nKey="event:nav.shareinfo">Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${id}`)}`}>email</a>.</Trans>
}
</ShareInfo>
</>
) : (
<div style={{ margin: '100px 0' }}>
<EventName>{t('event:error.title')}</EventName>
<ShareInfo>{t('event:error.body')}</ShareInfo>
</div>
)}
</StyledMain>
{(!!event || isLoading) && (
<>
<LoginSection id="login">
<StyledMain>
{user ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ margin: 0 }}>{t('event:form.signed_in', { name: user.name })}</h2>
<Button small onClick={() => {
setTab('group')
setUser(null)
setPassword(null)
}}>{t('event:form.logout_button')}</Button>
</div>
) : (
<>
<h2>{t('event:form.signed_out')}</h2>
<LoginForm onSubmit={handleSubmit(onSubmit)}>
<TextField
label={t('event:form.name')}
type="text"
id="name"
inline
required
{...register('name')}
/>
<TextField
label={t('event:form.password')}
type="password"
id="password"
inline
{...register('password')}
/>
<Button
type="submit"
$isLoading={isLoginLoading}
disabled={isLoginLoading || isLoading}
>{t('event:form.button')}</Button>
</LoginForm>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Info>{t('event:form.info')}</Info>
</>
)}
<SelectField
label={t('event:form.timezone')}
name="timezone"
id="timezone"
inline
value={timezone}
onChange={event => setTimezone(event.currentTarget.value)}
options={timezones}
/>
{/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p><Trans i18nKey="event:form.created_in_timezone">This event was created in the timezone <strong>{{timezone: event.timezone}}</strong>. <a href="#" onClick={e => {
e.preventDefault()
setTimezone(event.timezone)
}}>Click here</a> to use it.</Trans></p>}
{((
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
&& (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone)
) || (
event?.timezone === undefined
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
)) && (
/* eslint-disable-next-line */
<p><Trans i18nKey="event:form.local_timezone">Your local timezone is detected to be <strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>. <a href="#" onClick={e => {
e.preventDefault()
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
}}>Click here</a> to use it.</Trans></p>
)}
</StyledMain>
</LoginSection>
<StyledMain>
<Tabs>
<Tab
href="#you"
onClick={e => {
e.preventDefault()
if (user) {
setTab('you')
} else {
setFocus('name')
}
}}
$selected={tab === 'you'}
disabled={!user}
title={user ? '' : t('event:tabs.you_tooltip')}
>{t('event:tabs.you')}</Tab>
<Tab
href="#group"
onClick={e => {
e.preventDefault()
setTab('group')
}}
$selected={tab === 'group'}
>{t('event:tabs.group')}</Tab>
</Tabs>
</StyledMain>
{tab === 'group' ? (
<section id="group">
<AvailabilityViewer
times={times}
timeLabels={timeLabels}
dates={dates}
isSpecificDates={!!dates.length && dates[0].length === 8}
people={people.filter(p => p.availability.length > 0)}
min={min}
max={max}
/>
</section>
) : (
<section id="you">
<AvailabilityEditor
times={times}
timeLabels={timeLabels}
dates={dates}
timezone={timezone}
isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability}
onChange={async availability => {
const oldAvailability = [...user.availability]
const utcAvailability = (!!availability.length && availability[0].length === 13)
? availability.map(date => dayjs.tz(date, 'HHmm-DDMMYYYY', timezone).utc().format('HHmm-DDMMYYYY'))
: availability.map(date => dayjs.tz(date, 'HHmm', timezone).day(date.substring(5)).utc().format('HHmm-d'))
setUser({ ...user, availability })
try {
await api.patch(`/event/${id}/people/${user.name}`, {
person: {
password,
availability: utcAvailability,
},
})
} catch (e) {
console.log(e)
setUser({ ...user, oldAvailability })
}
}}
/>
</section>
)}
</>
)}
<Footer />
</>
)
}
export default Event

View file

@ -0,0 +1,148 @@
import { styled } from 'goober'
export const EventName = styled('h1')`
text-align: center;
font-weight: 800;
margin: 20px 0 5px;
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 400px;
max-width: 100%;
background-color: var(--loading);
border-radius: 3px;
}
`}
`
export const EventDate = styled('span')`
display: block;
text-align: center;
font-size: 14px;
opacity: .8;
margin: 0 0 10px;
font-weight: 500;
letter-spacing: .01em;
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 200px;
max-width: 100%;
background-color: var(--loading);
border-radius: 3px;
}
`}
@media print {
&::after {
content: ' - ' attr(title);
}
}
`
export const LoginForm = styled('form')`
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: flex-end;
grid-gap: 18px;
@media (max-width: 500px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 400px) {
grid-template-columns: 1fr;
& div:last-child {
--btn-width: 100%;
}
}
`
export const LoginSection = styled('section')`
background-color: var(--surface);
padding: 10px 0;
@media print {
display: none;
}
`
export const Info = styled('p')`
margin: 18px 0;
opacity: .6;
font-size: 12px;
`
export const ShareInfo = styled('p')`
margin: 6px 0;
text-align: center;
font-size: 15px;
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 300px;
max-width: 100%;
background-color: var(--loading);
border-radius: 3px;
}
`}
${props => props.onClick && `
cursor: pointer;
&:hover {
color: var(--secondary);
}
`}
@media print {
&.instructions {
display: none;
}
}
`
export const Tabs = styled('div')`
display: flex;
align-items: center;
justify-content: center;
margin: 30px 0 20px;
@media print {
display: none;
}
`
export const Tab = styled('a')`
user-select: none;
text-decoration: none;
display: block;
color: var(--text);
padding: 8px 18px;
background-color: var(--surface);
border: 1px solid var(--primary);
border-bottom: 0;
margin: 0 4px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
${props => props.$selected && `
color: #FFF;
background-color: var(--primary);
border-color: var(--primary);
`}
${props => props.disabled && `
opacity: .5;
cursor: not-allowed;
`}
`

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,116 @@
import { styled } from 'goober'
export const Step = styled('h2')`
text-decoration-color: var(--primary);
text-decoration-style: solid;
text-decoration-line: underline;
margin-top: 30px;
`
export const FakeCalendar = styled('div')`
user-select: none;
& div {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
}
& .days span {
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;
}
}
& .dates span {
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
&.selected {
color: #FFF;
background-color: var(--primary);
}
}
& .dates span:first-of-type {
border-start-start-radius: 3px;
border-end-start-radius: 3px;
}
& .dates span:last-of-type {
border-end-end-radius: 3px;
border-start-end-radius: 3px;
}
`
export const FakeTimeRange = styled('div')`
user-select: none;
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 3px;
height: 50px;
position: relative;
margin: 38px 6px 18px;
& div {
height: calc(100% + 20px);
width: 20px;
border: 1px solid var(--primary);
background-color: var(--highlight);
border-radius: 3px;
position: absolute;
top: -10px;
&: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: attr(data-label);
position: absolute;
bottom: calc(100% + 8px);
text-align: center;
left: 50%;
transform: translateX(-50%);
}
}
& .start {
left: calc(${11 * 4.166}% - 11px);
}
& .end {
left: calc(${17 * 4.166}% - 11px);
}
&:before {
content: '';
position: absolute;
height: 100%;
left: ${11 * 4.166}%;
right: calc(100% - ${17 * 4.166}%);
top: 0;
background-color: var(--primary);
border-radius: 2px;
}
`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`

View file

@ -0,0 +1,304 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
CalendarField,
TimeRangeField,
SelectField,
Button,
Center,
Error,
Footer,
Recents,
} from '/src/components'
import {
StyledMain,
CreateForm,
TitleSmall,
TitleLarge,
Logo,
Links,
AboutSection,
P,
Stats,
Stat,
StatNumber,
StatLabel,
OfflineMessage,
ButtonArea,
VideoWrapper,
VideoLink,
} from './Home.styles'
import api from '/src/services'
import { detect_browser } from '/src/utils'
import { useTWAStore } from '/src/stores'
import logo from '/src/res/logo.svg'
import video_thumb from '/src/res/video_thumb.jpg'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Home = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [stats, setStats] = useState({
eventCount: null,
personCount: null,
version: 'loading...',
})
const [browser, setBrowser] = useState(undefined)
const [videoPlay, setVideoPlay] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home'])
const isTWA = useTWAStore(state => state.TWA)
useEffect(() => {
const fetch = async () => {
try {
const response = await api.get('/stats')
setStats(response)
} catch (e) {
console.error(e)
}
}
fetch()
document.title = 'Crab Fit'
setBrowser(detect_browser())
}, [])
const onSubmit = async data => {
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'))
}
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
if (start > end) {
for (let i = 0; i < end; i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'))
}
const response = await api.post('/event', {
event: {
name: data.name,
times: times,
timezone: data.timezone,
},
})
navigate(`/${response.id}`)
gtag('event', 'create_event', {
'event_category': 'home',
})
} catch (e) {
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false)
}
}
return (
<>
<StyledMain>
<Center>
<Logo src={logo} alt="" />
</Center>
<TitleSmall $altChars={/^[A-Za-z ]+$/.test(t('home:create'))}>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge>
<Links>
<a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
</Links>
</StyledMain>
<Recents />
<StyledMain>
{offline ? (
<OfflineMessage>
<h1>🦀📵</h1>
<P>{t('home:offline')}</P>
</OfflineMessage>
) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField
label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')}
type="text"
id="name"
{...register('name')}
/>
<CalendarField
label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')}
id="dates"
required
setValue={setValue}
{...register('dates')}
/>
<TimeRangeField
label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')}
id="times"
required
setValue={setValue}
{...register('times')}
/>
<SelectField
label={t('home:form.timezone.label')}
id="timezone"
options={timezones}
required
{...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')}
/>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Center>
<Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button>
</Center>
</CreateForm>
)}
</StyledMain>
<AboutSection id="about">
<StyledMain>
<h2>{t('home:about.name')}</h2>
<Stats>
<Stat>
<StatNumber>{new Intl.NumberFormat().format(stats.eventCount ?? 7000)}{!stats.eventCount && '+'}</StatNumber>
<StatLabel>{t('home:about.events')}</StatLabel>
</Stat>
<Stat>
<StatNumber>{new Intl.NumberFormat().format(stats.personCount ?? 25000)}{!stats.personCount && '+'}</StatNumber>
<StatLabel>{t('home:about.availabilities')}</StatLabel>
</Stat>
</Stats>
<P><Trans i18nKey="home:about.content.p1">Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to" rel="help">Learn more about how to Crab Fit</Link>.</Trans></P>
{videoPlay ? (
<VideoWrapper>
<iframe width="560" height="315" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" title={t('common:video.title')} frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
</VideoWrapper>
) : (
<VideoLink
href="https://www.youtube.com/watch?v=yXGd4VXZzcY"
onClick={e => {
e.preventDefault()
setVideoPlay(true)
}}
>
<img src={video_thumb} alt={t('common:video.button')} />
<span>{t('common:video.button')}</span>
</VideoLink>
)}
{isTWA !== true && (
<ButtonArea>
{['chrome', 'firefox', 'safari'].includes(browser) && (
<Button
href={{
chrome: 'https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj',
firefox: 'https://addons.mozilla.org/en-US/firefox/addon/crab-fit/',
safari: 'https://apps.apple.com/us/app/crab-fit/id1570803259',
}[browser]}
icon={{
chrome: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>,
firefox: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M9.27 7.94C9.27 7.94 9.27 7.94 9.27 7.94M6.85 6.74C6.86 6.74 6.86 6.74 6.85 6.74M21.28 8.6C20.85 7.55 19.96 6.42 19.27 6.06C19.83 7.17 20.16 8.28 20.29 9.1L20.29 9.12C19.16 6.3 17.24 5.16 15.67 2.68C15.59 2.56 15.5 2.43 15.43 2.3C15.39 2.23 15.36 2.16 15.32 2.09C15.26 1.96 15.2 1.83 15.17 1.69C15.17 1.68 15.16 1.67 15.15 1.67H15.13L15.12 1.67L15.12 1.67L15.12 1.67C12.9 2.97 11.97 5.26 11.74 6.71C11.05 6.75 10.37 6.92 9.75 7.22C9.63 7.27 9.58 7.41 9.62 7.53C9.67 7.67 9.83 7.74 9.96 7.68C10.5 7.42 11.1 7.27 11.7 7.23L11.75 7.23C11.83 7.22 11.92 7.22 12 7.22C12.5 7.21 12.97 7.28 13.44 7.42L13.5 7.44C13.6 7.46 13.67 7.5 13.75 7.5C13.8 7.54 13.86 7.56 13.91 7.58L14.05 7.64C14.12 7.67 14.19 7.7 14.25 7.73C14.28 7.75 14.31 7.76 14.34 7.78C14.41 7.82 14.5 7.85 14.54 7.89C14.58 7.91 14.62 7.94 14.66 7.96C15.39 8.41 16 9.03 16.41 9.77C15.88 9.4 14.92 9.03 14 9.19C17.6 11 16.63 17.19 11.64 16.95C11.2 16.94 10.76 16.85 10.34 16.7C10.24 16.67 10.14 16.63 10.05 16.58C10 16.56 9.93 16.53 9.88 16.5C8.65 15.87 7.64 14.68 7.5 13.23C7.5 13.23 8 11.5 10.83 11.5C11.14 11.5 12 10.64 12.03 10.4C12.03 10.31 10.29 9.62 9.61 8.95C9.24 8.59 9.07 8.42 8.92 8.29C8.84 8.22 8.75 8.16 8.66 8.1C8.43 7.3 8.42 6.45 8.63 5.65C7.6 6.12 6.8 6.86 6.22 7.5H6.22C5.82 7 5.85 5.35 5.87 5C5.86 5 5.57 5.16 5.54 5.18C5.19 5.43 4.86 5.71 4.56 6C4.21 6.37 3.9 6.74 3.62 7.14C3 8.05 2.5 9.09 2.28 10.18C2.28 10.19 2.18 10.59 2.11 11.1L2.08 11.33C2.06 11.5 2.04 11.65 2 11.91L2 11.94L2 12.27L2 12.32C2 17.85 6.5 22.33 12 22.33C16.97 22.33 21.08 18.74 21.88 14C21.9 13.89 21.91 13.76 21.93 13.63C22.13 11.91 21.91 10.11 21.28 8.6Z" /></svg>,
safari: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,14.09 4.8,16 6.11,17.41L9.88,9.88L17.41,6.11C16,4.8 14.09,4 12,4M12,20A8,8 0 0,0 20,12C20,9.91 19.2,8 17.89,6.59L14.12,14.12L6.59,17.89C8,19.2 9.91,20 12,20M12,12L11.23,11.23L9.7,14.3L12.77,12.77L12,12M12,17.5H13V19H12V17.5M15.88,15.89L16.59,15.18L17.65,16.24L16.94,16.95L15.88,15.89M17.5,12V11H19V12H17.5M12,6.5H11V5H12V6.5M8.12,8.11L7.41,8.82L6.35,7.76L7.06,7.05L8.12,8.11M6.5,12V13H5V12H6.5Z" /></svg>,
}[browser]}
onClick={() => gtag('event', `download_extension_${browser}`, { 'event_category': 'home'})}
target="_blank"
rel="noreferrer noopener"
secondary
>{{
chrome: t('home:about.chrome_extension'),
firefox: t('home:about.firefox_extension'),
safari: t('home:about.safari_extension'),
}[browser]}</Button>
)}
<Button
href="https://play.google.com/store/apps/details?id=fit.crab"
icon={<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z" /></svg>}
onClick={() => gtag('event', 'download_android_app', { 'event_category': 'home' })}
target="_blank"
rel="noreferrer noopener"
secondary
>{t('home:about.android_app')}</Button>
</ButtonArea>
)}
<P><Trans i18nKey="home:about.content.p3">Created by <a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</Trans></P>
<P><Trans i18nKey="home:about.content.p4">The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">repository</a>. By using Crab Fit you agree to the <Link to="/privacy" rel="license">privacy policy</Link>.</Trans></P>
<P>{t('home:about.content.p6')}</P>
<P>{t('home:about.content.p5')}</P>
</StyledMain>
</AboutSection>
<Footer />
</>
)
}
export default Home

View file

@ -0,0 +1,206 @@
import { keyframes, styled } from 'goober'
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`
export const CreateForm = styled('form')`
margin: 0 0 60px;
`
export const TitleSmall = styled('span')`
display: block;
margin: 0;
font-size: 3rem;
text-align: center;
font-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
${props => !props.$altChars && `
font-family: sans-serif;
font-size: 2rem;
font-weight: 600;
line-height: 1.2em;
padding-top: .3em;
`}
`
export const TitleLarge = styled('h1')`
margin: 0;
font-size: 4rem;
text-align: center;
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 4px 0 var(--shadow);
line-height: 1em;
text-transform: uppercase;
@media (max-width: 350px) {
font-size: 3.5rem;
}
`
const jelly = keyframes`
from,to {
transform: scale(1,1);
}
25% {
transform: scale(.9,1.1);
}
50% {
transform: scale(1.1,.9);
}
75% {
transform: scale(.95,1.05);
}
`
export const Logo = styled('img')`
width: 80px;
transition: transform .15s;
animation: ${jelly} .5s 1 .05s;
user-select: none;
&:active {
animation: none;
transform: scale(.85);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
&:active {
transform: none;
}
}
`
export const Links = styled('nav')`
text-align: center;
margin: 20px 0;
`
export const AboutSection = styled('section')`
margin: 30px 0 0;
background-color: var(--surface);
padding: 20px 0;
& a {
color: var(--secondary);
}
`
export const P = styled('p')`
font-weight: 500;
line-height: 1.6em;
`
export const Stats = styled('div')`
display: flex;
justify-content: space-around;
align-items: flex-start;
flex-wrap: wrap;
`
export const Stat = styled('div')`
text-align: center;
padding: 0 6px;
min-width: 160px;
margin: 10px 0;
`
export const StatNumber = styled('span')`
display: block;
font-weight: 900;
color: var(--secondary);
font-size: 2em;
`
export const StatLabel = styled('span')`
display: block;
`
export const OfflineMessage = styled('div')`
text-align: center;
margin: 50px 0 20px;
`
export const ButtonArea = styled('div')`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
margin: 30px 0;
`
export const VideoWrapper = styled('div')`
margin: 0 auto;
position: relative;
padding-bottom: 56.4%;
width: 100%;
iframe {
position: absolute;
width: 100%;
height: 100%;
border-radius: 10px;
}
`
export const VideoLink = styled('a')`
display: block;
text-decoration: none;
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto;
transition: transform .15s;
&:hover, &:focus {
transform: translateY(-2px);
}
&:active {
transform: translateY(-1px);
}
img {
width: 100%;
display: block;
border-radius: 10px;
background-color: #CCC;
}
span {
color: #FFFFFF;
position: absolute;
top: 50%;
font-size: 1.5rem;
text-align: center;
width: 100%;
display: block;
transform: translateY(-50%);
text-shadow: 0 0 20px rgba(0,0,0,.8);
user-select: none;
&::before {
content: '';
display: block;
height: 2em;
width: 2em;
background: currentColor;
border-radius: 100%;
margin: 0 auto .4em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23F79E00' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-play'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'%3E%3C/polygon%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 1em;
box-shadow: 0 0 20px 0 rgba(0,0,0,.3);
}
}
`

View file

@ -0,0 +1,103 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button, Center, Footer, Logo } from '/src/components'
import { StyledMain, AboutSection, P } from '../Home/Home.styles'
import { Note, ButtonArea } from './Privacy.styles'
const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.'
const Privacy = () => {
const navigate = useNavigate()
const { t, i18n } = useTranslation(['common', 'privacy'])
const contentRef = useRef()
const [content, setContent] = useState('')
useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`
}, [t])
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef])
return <>
<StyledMain>
<Logo />
</StyledMain>
<StyledMain>
<h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && (
<p>
<a
href={`https://translate.google.com/?sl=en&tl=${i18n.language.substring(0, 2)}&text=${encodeURIComponent(`${translationDisclaimer}\n\n${content}`)}&op=translate`}
target="_blank"
rel="noreferrer noopener"
>{t('privacy:translate')}</a>
</p>
)}
<h3>Crab Fit</h3>
<div ref={contentRef}>
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P>
<P>This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.</P>
<P>If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.</P>
<h2>Information Collection and Use</h2>
<P>The Service uses third party services that may collect information used to identify you.</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P>
<P as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</P>
<h2>Log Data</h2>
<P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P>
<h2>Cookies</h2>
<P>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.</P>
<P>Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.</P>
<h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P>
<P as="ul">
<li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li>
</P>
<P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P>
<h2>Security</h2>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P>
<Note>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</Note>
<h2>Links to Other Sites</h2>
<P>The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.</P>
<h2>Children's Privacy</h2>
<P>The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please contact us using the details below so that this information can be removed.</P>
<h2>Changes to This Privacy Policy</h2>
<P>This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.</P>
<P>Last updated: 2021-06-16</P>
<h2>Contact Us</h2>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:contact@crab.fit">contact@crab.fit</a>.</P>
</div>
</StyledMain>
<ButtonArea>
<AboutSection>
<StyledMain>
<Center><Button onClick={() => navigate('/')}>{t('common:cta')}</Button></Center>
</StyledMain>
</AboutSection>
</ButtonArea>
<Footer />
</>
}
export default Privacy

View file

@ -0,0 +1,22 @@
import { styled } from 'goober'
export const Note = styled('p')`
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 10px;
padding: 12px 16px;
margin: 16px 0;
box-sizing: border-box;
font-weight: 500;
line-height: 1.6em;
& a {
color: var(--secondary);
}
`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`

View file

@ -0,0 +1,7 @@
import { lazy } from 'react'
export const Home = lazy(() => import('./Home/Home'))
export const Event = lazy(() => import('./Event/Event'))
export const Create = lazy(() => import('./Create/Create'))
export const Help = lazy(() => import('./Help/Help'))
export const Privacy = lazy(() => import('./Privacy/Privacy'))

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve">
<path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

20
frontend/src/res/logo.svg Normal file
View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>.cls-1,.cls-4{fill:#f79e00;}.cls-2,.cls-4{isolation:isolate;}.cls-3{fill:#f48600;}</style>
</defs>
<path class="cls-1" d="M181.11,311a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L211.66,98.73h32.46c0,79.47-108.36,32.47-136.43,50.75C73.65,171.64,181.11,311,181.11,311Z"/>
<path class="cls-1" d="M404,149.48C375.94,131.2,267.58,178.2,267.58,98.73H300L266.27,65h64.32a123,123,0,0,1,123,123h0a123,123,0,0,1-123,123S438.05,171.64,404,149.48Z"/>
<rect class="cls-1" x="266.27" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<rect class="cls-1" x="224.49" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<path class="cls-1" d="M190.55,229.11H321.11A108.35,108.35,0,0,1,429.47,337.47h0A108.36,108.36,0,0,1,321.11,445.83H190.55A108.37,108.37,0,0,1,82.19,337.47h0A108.36,108.36,0,0,1,190.55,229.11Z"/>
<g class="cls-2">
<path class="cls-3" d="M293.69,268.29a68.83,68.83,0,0,0-37.86,11.29,69.19,69.19,0,1,0,0,115.8,69.19,69.19,0,1,0,37.86-127.09Z"/>
</g>
<ellipse class="cls-4" cx="255.83" cy="337.48" rx="31.32" ry="57.9"/>
<path class="cls-1" d="M402.77,386.23c36,12.31,66,40.07,59.44,60.75C440,431.17,413.11,416.91,389,409.83Z"/>
<path class="cls-1" d="M420.05,345.89c37.37,7.15,71,30.45,67.35,51.85-24.24-12.55-52.82-22.92-77.67-26.57Z"/>
<path class="cls-1" d="M417.26,303.44c38.05.39,75.26,17.34,75.5,39-26.08-8-56.05-13.16-81.15-12.32Z"/>
<path class="cls-1" d="M109.23,386.23c-36,12.31-66,40.07-59.44,60.75C72,431.17,98.89,416.91,123,409.83Z"/>
<path class="cls-1" d="M92,345.89C54.58,353,21,376.34,24.6,397.74c24.24-12.55,52.82-22.92,77.67-26.57Z"/>
<path class="cls-1" d="M94.74,303.44c-38,.39-75.26,17.34-75.5,39,26.08-8,56.05-13.16,81.15-12.32Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 15.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="103.17322"
height="104.31332"
viewBox="0 0 103.17322 104.31332"
enable-background="new 0 0 190 50"
xml:space="preserve"
inkscape:version="0.48.2 r9819"
sodipodi:docname="Outlook_logo.svg"><metadata
id="metadata45"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs43" />
<path
d="m 64.566509,22.116383 v 20.404273 l 7.130526,4.489881 c 0.188058,0.05485 0.595516,0.05877 0.783574,0 L 103.16929,26.320259 c 0,-2.44867 -2.28412,-4.203876 -3.573094,-4.203876 H 64.566509 z"
id="path3"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<path
d="m 64.566509,50.13308 6.507584,4.470291 c 0.916782,0.673874 2.021622,0 2.021622,0 -1.100922,0.673874 30.077495,-20.035993 30.077495,-20.035993 v 37.501863 c 0,4.082422 -2.61322,5.794531 -5.551621,5.794531 H 64.562591 V 50.13308 z"
id="path5"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<g
id="g23"
transform="matrix(3.9178712,0,0,3.9178712,-13.481403,-41.384473)">
<path
d="m 11.321,20.958 c -0.566,0 -1.017,0.266 -1.35,0.797 -0.333,0.531 -0.5,1.234 -0.5,2.109 0,0.888 0.167,1.59 0.5,2.106 0.333,0.517 0.77,0.774 1.31,0.774 0.557,0 0.999,-0.251 1.325,-0.753 0.326,-0.502 0.49,-1.199 0.49,-2.09 0,-0.929 -0.158,-1.652 -0.475,-2.169 -0.317,-0.516 -0.75,-0.774 -1.3,-0.774 z"
id="path25"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
<path
d="m 3.441,13.563 v 20.375 l 15.5,3.25 V 10.563 l -15.5,3 z m 10.372,13.632 c -0.655,0.862 -1.509,1.294 -2.563,1.294 -1.027,0 -1.863,-0.418 -2.51,-1.253 C 8.094,26.4 7.77,25.312 7.77,23.97 c 0,-1.417 0.328,-2.563 0.985,-3.438 0.657,-0.875 1.527,-1.313 2.61,-1.313 1.023,0 1.851,0.418 2.482,1.256 0.632,0.838 0.948,1.942 0.948,3.313 10e-4,1.409 -0.327,2.545 -0.982,3.407 z"
id="path27"
inkscape:connector-curvature="0"
style="fill:#FFFFFF" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 526.77502 140.375"
height="140.375"
width="526.77502"
xml:space="preserve"
version="1.1"
id="svg2"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6" /><g
transform="matrix(1.25,0,0,-1.25,0,140.375)"
id="g10"><g
transform="scale(0.1,0.1)"
id="g12"><path
id="path14"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 505.703,1122.93 -327.781,0 c -22.434,0 -41.508,-16.3 -45.008,-38.45 L 0.34375,243.961 C -2.29297,227.383 10.5547,212.426 27.375,212.426 l 156.488,0 c 22.43,0 41.504,16.293 45.004,38.484 l 35.754,226.699 c 3.453,22.196 22.574,38.493 44.957,38.493 l 103.766,0 c 215.918,0 340.531,104.484 373.078,311.535 14.664,90.586 0.621,161.758 -41.797,211.603 -46.586,54.74 -129.215,83.69 -238.922,83.69 z M 543.52,815.941 C 525.594,698.324 435.727,698.324 348.832,698.324 l -49.461,0 34.699,219.656 c 2.063,13.278 13.563,23.055 26.985,23.055 l 22.668,0 c 59.191,0 115.031,0 143.882,-33.738 17.208,-20.133 22.481,-50.039 15.915,-91.356" /><path
id="path16"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1485.5,819.727 -156.96,0 c -13.37,0 -24.92,-9.778 -26.99,-23.055 l -6.94,-43.902 -10.98,15.914 c -33.98,49.32 -109.76,65.804 -185.39,65.804 -173.451,0 -321.599,-131.371 -350.451,-315.656 -15,-91.926 6.328,-179.828 58.473,-241.125 47.832,-56.363 116.273,-79.848 197.708,-79.848 139.76,0 217.26,89.86 217.26,89.86 l -7,-43.614 c -2.64,-16.679 10.21,-31.632 26.94,-31.632 l 141.38,0 c 22.48,0 41.46,16.297 45.01,38.484 l 84.83,537.234 c 2.69,16.536 -10.11,31.536 -26.89,31.536 z M 1266.71,514.23 c -15.14,-89.671 -86.32,-149.875 -177.09,-149.875 -45.58,0 -82.01,14.622 -105.401,42.325 -23.196,27.511 -32.016,66.668 -24.633,110.285 14.137,88.906 86.514,151.066 175.894,151.066 44.58,0 80.81,-14.808 104.68,-42.746 23.92,-28.23 33.4,-67.629 26.55,-111.055" /><path
id="path18"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2321.47,819.727 -157.73,0 c -15.05,0 -29.19,-7.477 -37.72,-19.989 L 1908.47,479.289 1816.26,787.23 c -5.8,19.27 -23.58,32.497 -43.71,32.497 l -155,0 c -18.84,0 -31.92,-18.403 -25.93,-36.137 L 1765.36,273.727 1602.02,43.1406 C 1589.17,24.9805 1602.11,0 1624.31,0 l 157.54,0 c 14.95,0 28.95,7.28906 37.43,19.5586 L 2343.9,776.828 c 12.56,18.121 -0.33,42.899 -22.43,42.899" /><path
id="path20"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2843.7,1122.93 -327.83,0 c -22.38,0 -41.46,-16.3 -44.96,-38.45 L 2338.34,243.961 c -2.63,-16.578 10.21,-31.535 26.94,-31.535 l 168.23,0 c 15.62,0 29,11.402 31.44,26.933 l 37.62,238.25 c 3.45,22.196 22.58,38.493 44.96,38.493 l 103.72,0 c 215.96,0 340.53,104.484 373.12,311.535 14.72,90.586 0.58,161.758 -41.84,211.603 -46.54,54.74 -129.12,83.69 -238.83,83.69 z m 37.82,-306.989 C 2863.64,698.324 2773.78,698.324 2686.83,698.324 l -49.41,0 34.75,219.656 c 2.06,13.278 13.46,23.055 26.93,23.055 l 22.67,0 c 59.15,0 115.03,0 143.88,-33.738 17.21,-20.133 22.43,-50.039 15.87,-91.356" /><path
id="path22"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3823.46,819.727 -156.87,0 c -13.47,0 -24.93,-9.778 -26.94,-23.055 l -6.95,-43.902 -11.02,15.914 c -33.98,49.32 -109.71,65.804 -185.34,65.804 -173.46,0 -321.55,-131.371 -350.41,-315.656 -14.95,-91.926 6.28,-179.828 58.43,-241.125 47.93,-56.363 116.27,-79.848 197.7,-79.848 139.76,0 217.26,89.86 217.26,89.86 l -7,-43.614 c -2.63,-16.679 10.21,-31.632 27.04,-31.632 l 141.34,0 c 22.38,0 41.46,16.297 44.96,38.484 l 84.88,537.234 c 2.58,16.536 -10.26,31.536 -27.08,31.536 z M 3604.66,514.23 c -15.05,-89.671 -86.32,-149.875 -177.09,-149.875 -45.49,0 -82.01,14.622 -105.4,42.325 -23.19,27.511 -31.92,66.668 -24.63,110.285 14.23,88.906 86.51,151.066 175.9,151.066 44.57,0 80.8,-14.808 104.67,-42.746 24.01,-28.23 33.5,-67.629 26.55,-111.055" /><path
id="path24"
style="fill:#F79E00;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 4008.51,1099.87 3873.97,243.961 c -2.63,-16.578 10.21,-31.535 26.94,-31.535 l 135.25,0 c 22.48,0 41.56,16.293 45.01,38.484 l 132.66,840.47 c 2.64,16.59 -10.2,31.59 -26.93,31.59 l -151.46,0 c -13.37,-0.04 -24.87,-9.83 -26.93,-23.1" /></g></g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,596 @@
[
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Timbuktu",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/ComodRivadavia",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Atka",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Ensenada",
"America/Fort_Nelson",
"America/Fort_Wayne",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Knox_IN",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montreal",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Acre",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Rosario",
"America/Santa_Isabel",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Shiprock",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Virgin",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/South_Pole",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Ashkhabad",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Colombo",
"Asia/Dacca",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Harbin",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kashgar",
"Asia/Kathmandu",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macao",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ujung_Pandang",
"Asia/Ulaanbaatar",
"Asia/Ulan_Bator",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Faroe",
"Atlantic/Jan_Mayen",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/ACT",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Canberra",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/LHI",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/North",
"Australia/NSW",
"Australia/Perth",
"Australia/Queensland",
"Australia/South",
"Australia/Sydney",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Brazil/Acre",
"Brazil/DeNoronha",
"Brazil/East",
"Brazil/West",
"Canada/Atlantic",
"Canada/Central",
"Canada/Eastern",
"Canada/Mountain",
"Canada/Newfoundland",
"Canada/Pacific",
"Canada/Saskatchewan",
"Canada/Yukon",
"CET",
"Chile/Continental",
"Chile/EasterIsland",
"CST6CDT",
"Cuba",
"EET",
"Egypt",
"Eire",
"EST",
"EST5EDT",
"Etc/GMT",
"Etc/GMT+0",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT-0",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Etc/GMT0",
"Etc/Greenwich",
"Etc/UCT",
"Etc/Universal",
"Etc/UTC",
"Etc/Zulu",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belfast",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Tiraspol",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"Factory",
"GB",
"GB-Eire",
"GMT",
"GMT+0",
"GMT-0",
"GMT0",
"Greenwich",
"Hongkong",
"HST",
"Iceland",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Iran",
"Israel",
"Jamaica",
"Japan",
"Kwajalein",
"Libya",
"MET",
"Mexico/BajaNorte",
"Mexico/BajaSur",
"Mexico/General",
"MST",
"MST7MDT",
"Navajo",
"NZ",
"NZ-CHAT",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Johnston",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Samoa",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis",
"Pacific/Yap",
"Poland",
"Portugal",
"PRC",
"PST8PDT",
"ROC",
"ROK",
"Singapore",
"Turkey",
"UCT",
"Universal",
"US/Alaska",
"US/Aleutian",
"US/Arizona",
"US/Central",
"US/East-Indiana",
"US/Eastern",
"US/Hawaii",
"US/Indiana-Starke",
"US/Michigan",
"US/Mountain",
"US/Pacific",
"US/Samoa",
"UTC",
"W-SU",
"WET",
"Zulu"
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,63 @@
const API_URL = process.env.NODE_ENV === 'production' ? 'https://api.crab.fit' : 'http://localhost:8080'
const handleError = error => {
if (error && error.status) {
console.error('[Error handler] res:', error)
}
return Promise.reject(error)
}
const api = {
get: async endpoint => {
try {
const response = await fetch(API_URL + endpoint)
if (!response.ok) {
throw response
}
const json = await response.json()
return Promise.resolve(json)
} catch (error) {
return handleError(error)
}
},
post: async (endpoint, data, options = {}) => {
try {
const response = await fetch(API_URL + endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
...options
})
if (!response.ok) {
throw response
}
const json = await response.json()
return Promise.resolve(json)
} catch (error) {
return handleError(error)
}
},
patch: async (endpoint, data, options = {}) => {
try {
const response = await fetch(API_URL + endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
...options
})
if (!response.ok) {
throw response
}
const json = await response.json()
return Promise.resolve(json)
} catch (error) {
return handleError(error)
}
},
}
export default api

View file

@ -0,0 +1,5 @@
export { default as useSettingsStore } from './settingsStore'
export { default as useRecentsStore } from './recentsStore'
export { default as useTWAStore } from './twaStore'
export { default as useLocaleUpdateStore } from './localeUpdateStore'
export { default as useTranslateStore } from './translateStore'

View file

@ -0,0 +1,8 @@
import create from 'zustand'
const useLocaleUpdateStore = create(set => ({
locale: 'en',
setLocale: locale => set({ locale }),
}))
export default useLocaleUpdateStore

View file

@ -0,0 +1,23 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
const useRecentsStore = create(persist(
set => ({
recents: [],
addRecent: event => set(state => {
const recents = state.recents.filter(e => e.id !== event.id)
recents.unshift(event)
recents.length = Math.min(recents.length, 5)
return { recents }
}),
removeRecent: id => set(state => {
const recents = state.recents.filter(e => e.id !== id)
return { recents }
}),
clearRecents: () => set({ recents: [] }),
}),
{ name: 'crabfit-recent' },
))
export default useRecentsStore

View file

@ -0,0 +1,21 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(persist(
set => ({
weekStart: 0,
timeFormat: '12h',
theme: 'System',
highlight: false,
colormap: 'crabfit',
setWeekStart: weekStart => set({ weekStart }),
setTimeFormat: timeFormat => set({ timeFormat }),
setTheme: theme => set({ theme }),
setHighlight: highlight => set({ highlight }),
setColormap: colormap => set({ colormap }),
}),
{ name: 'crabfit-settings' },
))
export default useSettingsStore

View file

@ -0,0 +1,23 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
import locales from '/src/i18n/locales'
const useTranslateStore = create(persist(
set => ({
navigatorLang: navigator.language,
navigatorSupported: Object.keys(locales).includes(navigator.language.substring(0, 2)),
translateDialogDismissed: false,
setDialogDismissed: value => set({ translateDialogDismissed: value }),
}),
{
name: 'crabfit-translate',
blacklist: [
'navigatorLang',
'navigatorSupported',
],
},
))
export default useTranslateStore

View file

@ -0,0 +1,8 @@
import create from 'zustand'
const useTWAStore = create(set => ({
TWA: undefined,
setTWA: TWA => set({ TWA }),
}))
export default useTWAStore

View file

@ -0,0 +1,37 @@
export const detect_browser = () => {
// Opera 8.0+
// eslint-disable-next-line no-undef
const isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0
// Firefox 1.0+
const isFirefox = typeof InstallTrigger !== 'undefined'
// Safari 3.0+ "[object HTMLElementConstructor]"
const isSafari = /constructor/i.test(window.HTMLElement) || (function (p) { return p.toString() === '[object SafariRemoteNotification]' })(!window['safari'] || (typeof safari !== 'undefined' && window['safari'].pushNotification))
// Internet Explorer 6-11
const isIE = /*@cc_on!@*/false || !!document.documentMode
// Edge 20+
const isEdge = !isIE && !!window.StyleMedia
// Chrome 1 - 79
const isChrome = !!window.chrome
// Edge (based on chromium) detection
// eslint-disable-next-line
const isEdgeChromium = isChrome && (navigator.userAgent.indexOf("Edg") != -1)
if (isEdgeChromium) return 'edge_chromium'
if (isChrome) return 'chrome'
if (isEdge) return 'edge'
if (isIE) return 'ie'
if (isSafari) return 'safari'
if (isFirefox) return 'firefox'
if (isOpera) return 'opera'
}
export const unhyphenate = s =>
s.split('-')
.map(w => w[0].toLocaleUpperCase() + w.substring(1).toLocaleLowerCase())
.join(' ')