Rename main folders and write sql backend adaptor
This commit is contained in:
parent
1d34f8e06d
commit
fdc58b428b
212 changed files with 3577 additions and 4775 deletions
62
frontend/src/App.jsx
Normal file
62
frontend/src/App.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
`};
|
||||
`
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
33
frontend/src/components/Button/Button.jsx
Normal file
33
frontend/src/components/Button/Button.jsx
Normal 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
|
||||
134
frontend/src/components/Button/Button.styles.js
Normal file
134
frontend/src/components/Button/Button.styles.js
Normal 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;
|
||||
}
|
||||
}
|
||||
`
|
||||
264
frontend/src/components/CalendarField/CalendarField.jsx
Normal file
264
frontend/src/components/CalendarField/CalendarField.jsx
Normal 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)
|
||||
}
|
||||
}}
|
||||
><</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)
|
||||
}
|
||||
}}
|
||||
>></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
|
||||
104
frontend/src/components/CalendarField/CalendarField.styles.js
Normal file
104
frontend/src/components/CalendarField/CalendarField.styles.js
Normal 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')};
|
||||
`}
|
||||
`
|
||||
9
frontend/src/components/Center/Center.js
Normal file
9
frontend/src/components/Center/Center.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
const Center = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
export default Center
|
||||
159
frontend/src/components/Donate/Donate.jsx
Normal file
159
frontend/src/components/Donate/Donate.jsx
Normal 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¤cy_code=AUD&amount=5"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener payment"
|
||||
id="donate_button"
|
||||
role="button"
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>{t('donate.button')}</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¤cy_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¤cy_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¤cy_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¤cy_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
|
||||
</Options>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default Donate
|
||||
64
frontend/src/components/Donate/Donate.styles.js
Normal file
64
frontend/src/components/Donate/Donate.styles.js
Normal 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;
|
||||
}
|
||||
`
|
||||
21
frontend/src/components/Egg/Egg.jsx
Normal file
21
frontend/src/components/Egg/Egg.jsx
Normal 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
|
||||
23
frontend/src/components/Egg/Egg.styles.js
Normal file
23
frontend/src/components/Egg/Egg.styles.js
Normal 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;
|
||||
`
|
||||
17
frontend/src/components/Error/Error.jsx
Normal file
17
frontend/src/components/Error/Error.jsx
Normal 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
|
||||
44
frontend/src/components/Error/Error.styles.js
Normal file
44
frontend/src/components/Error/Error.styles.js
Normal 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;
|
||||
`
|
||||
17
frontend/src/components/Footer/Footer.jsx
Normal file
17
frontend/src/components/Footer/Footer.jsx
Normal 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
|
||||
26
frontend/src/components/Footer/Footer.styles.js
Normal file
26
frontend/src/components/Footer/Footer.styles.js
Normal 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;
|
||||
}
|
||||
`
|
||||
167
frontend/src/components/GoogleCalendar/GoogleCalendar.jsx
Normal file
167
frontend/src/components/GoogleCalendar/GoogleCalendar.jsx
Normal 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
|
||||
122
frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js
Normal file
122
frontend/src/components/GoogleCalendar/GoogleCalendar.styles.js
Normal 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;
|
||||
`
|
||||
56
frontend/src/components/Legend/Legend.jsx
Normal file
56
frontend/src/components/Legend/Legend.jsx
Normal 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
|
||||
52
frontend/src/components/Legend/Legend.styles.js
Normal file
52
frontend/src/components/Legend/Legend.styles.js
Normal 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
|
||||
);
|
||||
`}
|
||||
`
|
||||
5
frontend/src/components/Loading/Loading.jsx
Normal file
5
frontend/src/components/Loading/Loading.jsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Wrapper, Loader } from './Loading.styles'
|
||||
|
||||
const Loading = () => <Wrapper><Loader /></Wrapper>
|
||||
|
||||
export default Loading
|
||||
35
frontend/src/components/Loading/Loading.styles.js
Normal file
35
frontend/src/components/Loading/Loading.styles.js
Normal 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...';
|
||||
}
|
||||
}
|
||||
`
|
||||
31
frontend/src/components/Logo/Logo.jsx
Normal file
31
frontend/src/components/Logo/Logo.jsx
Normal 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
|
||||
69
frontend/src/components/Logo/Logo.styles.js
Normal file
69
frontend/src/components/Logo/Logo.styles.js
Normal 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;
|
||||
}
|
||||
`
|
||||
228
frontend/src/components/OutlookCalendar/OutlookCalendar.jsx
Normal file
228
frontend/src/components/OutlookCalendar/OutlookCalendar.jsx
Normal 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
|
||||
34
frontend/src/components/Recents/Recents.jsx
Normal file
34
frontend/src/components/Recents/Recents.jsx
Normal 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
|
||||
42
frontend/src/components/Recents/Recents.styles.js
Normal file
42
frontend/src/components/Recents/Recents.styles.js
Normal 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;
|
||||
}
|
||||
}
|
||||
`
|
||||
44
frontend/src/components/SelectField/SelectField.jsx
Normal file
44
frontend/src/components/SelectField/SelectField.jsx
Normal 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
|
||||
61
frontend/src/components/SelectField/SelectField.styles.js
Normal file
61
frontend/src/components/SelectField/SelectField.styles.js
Normal 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;
|
||||
`}
|
||||
`
|
||||
179
frontend/src/components/Settings/Settings.jsx
Normal file
179
frontend/src/components/Settings/Settings.jsx
Normal 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
|
||||
88
frontend/src/components/Settings/Settings.styles.js
Normal file
88
frontend/src/components/Settings/Settings.styles.js
Normal 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;
|
||||
`
|
||||
24
frontend/src/components/TextField/TextField.jsx
Normal file
24
frontend/src/components/TextField/TextField.jsx
Normal 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
|
||||
47
frontend/src/components/TextField/TextField.styles.js
Normal file
47
frontend/src/components/TextField/TextField.styles.js
Normal 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);
|
||||
}
|
||||
`
|
||||
146
frontend/src/components/TimeRangeField/TimeRangeField.jsx
Normal file
146
frontend/src/components/TimeRangeField/TimeRangeField.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
43
frontend/src/components/ToggleField/ToggleField.jsx
Normal file
43
frontend/src/components/ToggleField/ToggleField.jsx
Normal 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
|
||||
75
frontend/src/components/ToggleField/ToggleField.styles.js
Normal file
75
frontend/src/components/ToggleField/ToggleField.styles.js
Normal 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;
|
||||
`
|
||||
32
frontend/src/components/TranslateDialog/TranslateDialog.jsx
Normal file
32
frontend/src/components/TranslateDialog/TranslateDialog.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
24
frontend/src/components/index.js
Normal file
24
frontend/src/components/index.js
Normal 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')
|
||||
28
frontend/src/i18n/index.js
Normal file
28
frontend/src/i18n/index.js
Normal 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
|
||||
83
frontend/src/i18n/locales.js
Normal file
83
frontend/src/i18n/locales.js
Normal 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
24
frontend/src/index.jsx
Normal 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>
|
||||
)
|
||||
242
frontend/src/pages/Create/Create.jsx
Normal file
242
frontend/src/pages/Create/Create.jsx
Normal 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
|
||||
60
frontend/src/pages/Create/Create.styles.js
Normal file
60
frontend/src/pages/Create/Create.styles.js
Normal 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);
|
||||
}
|
||||
`}
|
||||
`
|
||||
470
frontend/src/pages/Event/Event.jsx
Normal file
470
frontend/src/pages/Event/Event.jsx
Normal 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
|
||||
148
frontend/src/pages/Event/Event.styles.js
Normal file
148
frontend/src/pages/Event/Event.styles.js
Normal 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;
|
||||
`}
|
||||
`
|
||||
101
frontend/src/pages/Help/Help.jsx
Normal file
101
frontend/src/pages/Help/Help.jsx
Normal file
File diff suppressed because one or more lines are too long
116
frontend/src/pages/Help/Help.styles.js
Normal file
116
frontend/src/pages/Help/Help.styles.js
Normal 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;
|
||||
}
|
||||
`
|
||||
304
frontend/src/pages/Home/Home.jsx
Normal file
304
frontend/src/pages/Home/Home.jsx
Normal 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
|
||||
206
frontend/src/pages/Home/Home.styles.js
Normal file
206
frontend/src/pages/Home/Home.styles.js
Normal 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);
|
||||
}
|
||||
}
|
||||
`
|
||||
103
frontend/src/pages/Privacy/Privacy.jsx
Normal file
103
frontend/src/pages/Privacy/Privacy.jsx
Normal 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
|
||||
22
frontend/src/pages/Privacy/Privacy.styles.js
Normal file
22
frontend/src/pages/Privacy/Privacy.styles.js
Normal 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;
|
||||
}
|
||||
`
|
||||
7
frontend/src/pages/index.js
Normal file
7
frontend/src/pages/index.js
Normal 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'))
|
||||
9
frontend/src/res/google.svg
Normal file
9
frontend/src/res/google.svg
Normal 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
20
frontend/src/res/logo.svg
Normal 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 |
54
frontend/src/res/outlook.svg
Normal file
54
frontend/src/res/outlook.svg
Normal 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 |
39
frontend/src/res/paypal.svg
Normal file
39
frontend/src/res/paypal.svg
Normal 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 |
596
frontend/src/res/timezones.json
Normal file
596
frontend/src/res/timezones.json
Normal 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"
|
||||
]
|
||||
BIN
frontend/src/res/video_thumb.jpg
Normal file
BIN
frontend/src/res/video_thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
63
frontend/src/services/index.js
Normal file
63
frontend/src/services/index.js
Normal 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
|
||||
5
frontend/src/stores/index.js
Normal file
5
frontend/src/stores/index.js
Normal 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'
|
||||
8
frontend/src/stores/localeUpdateStore.js
Normal file
8
frontend/src/stores/localeUpdateStore.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import create from 'zustand'
|
||||
|
||||
const useLocaleUpdateStore = create(set => ({
|
||||
locale: 'en',
|
||||
setLocale: locale => set({ locale }),
|
||||
}))
|
||||
|
||||
export default useLocaleUpdateStore
|
||||
23
frontend/src/stores/recentsStore.js
Normal file
23
frontend/src/stores/recentsStore.js
Normal 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
|
||||
21
frontend/src/stores/settingsStore.js
Normal file
21
frontend/src/stores/settingsStore.js
Normal 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
|
||||
23
frontend/src/stores/translateStore.js
Normal file
23
frontend/src/stores/translateStore.js
Normal 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
|
||||
8
frontend/src/stores/twaStore.js
Normal file
8
frontend/src/stores/twaStore.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import create from 'zustand'
|
||||
|
||||
const useTWAStore = create(set => ({
|
||||
TWA: undefined,
|
||||
setTWA: TWA => set({ TWA }),
|
||||
}))
|
||||
|
||||
export default useTWAStore
|
||||
37
frontend/src/utils/index.js
Normal file
37
frontend/src/utils/index.js
Normal 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(' ')
|
||||
Loading…
Add table
Add a link
Reference in a new issue