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
|
|
@ -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')
|
||||
Loading…
Add table
Add a link
Reference in a new issue