mirror of
https://github.com/mvvasilev/findtheti.me.git
synced 2025-04-19 13:39:52 +03:00
Fully operational
This commit is contained in:
parent
988431d004
commit
fe035c3dc3
22 changed files with 798 additions and 281 deletions
|
@ -6,7 +6,7 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7.3", features = ["macros"] }
|
axum = { version = "0.7.3", features = ["macros", "tokio"] }
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
chrono = { version = "0.4.31", features = ["serde"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>findtheti.me</title>
|
||||||
|
<link rel="icon" type="image/png" href="/src/assets/calendar-color-icon.png"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "findtheti.me",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
@ -21,6 +21,7 @@
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-github-corner": "^2.5.0",
|
"react-github-corner": "^2.5.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-router-dom": "^6.21.1"
|
"react-router-dom": "^6.21.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import './App.css'
|
||||||
import RootLayout from './pages/RootLayout'
|
import RootLayout from './pages/RootLayout'
|
||||||
import NewEventPage from './pages/NewEventPage'
|
import NewEventPage from './pages/NewEventPage'
|
||||||
import ExistingEventPage from './pages/ExistingEventPage'
|
import ExistingEventPage from './pages/ExistingEventPage'
|
||||||
|
import ThankYouPage from './pages/ThankYouPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -10,6 +11,7 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<NewEventPage />} />
|
<Route path="/" element={<NewEventPage />} />
|
||||||
<Route path="/:eventId" element={<ExistingEventPage />} />
|
<Route path="/:eventId" element={<ExistingEventPage />} />
|
||||||
|
<Route path="/thank-you" element={<ThankYouPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
)
|
)
|
||||||
|
|
BIN
frontend/src/assets/calendar-color-icon.png
Normal file
BIN
frontend/src/assets/calendar-color-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
|
@ -1,28 +1,19 @@
|
||||||
import { Box, Card, Divider, Stack, Typography } from "@mui/material";
|
import { Box, Stack } from "@mui/material";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import { MouseEvent, useState } from "react";
|
||||||
import { MouseEvent, useEffect, useState } from "react";
|
|
||||||
import * as utc from 'dayjs/plugin/utc';
|
|
||||||
import * as timezone from 'dayjs/plugin/timezone';
|
|
||||||
import * as localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
||||||
import utils from "../utils";
|
|
||||||
import { EventTypes } from "../types/Event";
|
|
||||||
import "./css/AvailabilityPicker.css";
|
import "./css/AvailabilityPicker.css";
|
||||||
import classNames from 'classnames';
|
import { AvailabilityDay, OthersDay, OthersDays } from "../types/Availabilities";
|
||||||
// import { alpha } from '@material-ui/core/styles/colorManipulator';
|
import utils from "../utils";
|
||||||
|
import AvailabilityPickerDay from "./AvailabilityPickerDay";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
|
||||||
dayjs.extend(utc)
|
dayjs.extend(utc)
|
||||||
dayjs.extend(timezone)
|
dayjs.extend(timezone)
|
||||||
dayjs.extend(localizedFormat)
|
dayjs.extend(localizedFormat)
|
||||||
|
|
||||||
type AvailabilityTime = {
|
const HALFHOUR_DISPLAY_HEIGHT: number = 15;
|
||||||
fromTime: Dayjs,
|
|
||||||
toTime: Dayjs
|
|
||||||
}
|
|
||||||
|
|
||||||
type AvailabilityDay = {
|
|
||||||
forDate: Dayjs,
|
|
||||||
availableTimes: AvailabilityTime[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type GhostPreviewProps = {
|
type GhostPreviewProps = {
|
||||||
top: number,
|
top: number,
|
||||||
|
@ -31,173 +22,19 @@ type GhostPreviewProps = {
|
||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const HALFHOUR_DISPLAY_HEIGHT: number = 15;
|
|
||||||
|
|
||||||
const Hour = (props: {
|
|
||||||
dateTime: Dayjs,
|
|
||||||
isFullHourSelected: boolean,
|
|
||||||
isHalfHourSelected: boolean,
|
|
||||||
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => void,
|
|
||||||
onMouseClickOnHalfhour: (time: Dayjs) => void
|
|
||||||
}) => {
|
|
||||||
let isEvenHour = props.dateTime.hour() % 2 == 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
className={classNames("full-hour", { "selected-availability": props.isFullHourSelected })}
|
|
||||||
height={HALFHOUR_DISPLAY_HEIGHT}
|
|
||||||
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
|
|
||||||
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime)}
|
|
||||||
>
|
|
||||||
<Typography className={"noselect time-text"}>
|
|
||||||
{ utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) }
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
className={classNames("half-hour", { "selected-availability": props.isHalfHourSelected })}
|
|
||||||
height={HALFHOUR_DISPLAY_HEIGHT}
|
|
||||||
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
|
|
||||||
onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"))}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelectedAvailability = (day: AvailabilityDay, time: Dayjs): boolean => {
|
|
||||||
return day.availableTimes.some(t => t.fromTime.unix() <= time.unix() && time.unix() <= t.toTime.unix());
|
|
||||||
}
|
|
||||||
|
|
||||||
const Day = (props: {
|
|
||||||
day: AvailabilityDay,
|
|
||||||
eventType: String,
|
|
||||||
onMouseEnterHalfhour: (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void,
|
|
||||||
onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs) => void
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
const generateHours = (): JSX.Element[] => {
|
|
||||||
let hours: JSX.Element[] = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < 24; i++) {
|
|
||||||
let time = props.day.forDate.set("hour", i);
|
|
||||||
hours.push(
|
|
||||||
<Hour
|
|
||||||
key={time.unix()}
|
|
||||||
dateTime={time}
|
|
||||||
isFullHourSelected={isSelectedAvailability(props.day, time)}
|
|
||||||
isHalfHourSelected={isSelectedAvailability(props.day, time.set("minutes", 30))}
|
|
||||||
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
|
|
||||||
props.onMouseEnterHalfhour(e, time);
|
|
||||||
}}
|
|
||||||
onMouseClickOnHalfhour={(time: dayjs.Dayjs): void => {
|
|
||||||
props.onMouseClickHalfhour(props.day, time);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hours;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
|
|
||||||
key={props.day.forDate.format()}
|
|
||||||
className={"day-card"}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{ width: "100%" }}
|
|
||||||
padding={1}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
(props.eventType === EventTypes.WEEK) &&
|
|
||||||
<Typography>
|
|
||||||
{ props.day.forDate.format("dddd") }
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
(props.eventType === EventTypes.DAY) &&
|
|
||||||
<Typography>
|
|
||||||
Any Day
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
|
|
||||||
<Typography>
|
|
||||||
{ props.day.forDate.format("LL") }
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider></Divider>
|
|
||||||
|
|
||||||
{generateHours()}
|
|
||||||
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const AvailabilityPicker = (props: {
|
const AvailabilityPicker = (props: {
|
||||||
fromDate: Dayjs,
|
days: AvailabilityDay[],
|
||||||
toDate: Dayjs,
|
setDays: (days: AvailabilityDay[]) => void,
|
||||||
|
othersAvailabilities: OthersDays[],
|
||||||
eventType: String,
|
eventType: String,
|
||||||
availabilityDurationInMinutes: number
|
availabilityDurationInMinutes: number,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const [days, setDays] = useState<AvailabilityDay[]>([]);
|
|
||||||
const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
|
const [ghostPreviewProps, setGhostPreviewProps] = useState<GhostPreviewProps | null>();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let localTimezone = dayjs.tz.guess();
|
|
||||||
|
|
||||||
let localFromDate = props.fromDate.tz(localTimezone);
|
|
||||||
let localToDate = props.toDate.tz(localTimezone);
|
|
||||||
|
|
||||||
switch (props.eventType) {
|
|
||||||
case EventTypes.SPECIFIC_DATE: {
|
|
||||||
createAvailabilitiesBasedOnInitialDate(localFromDate, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case EventTypes.DATE_RANGE: {
|
|
||||||
createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case EventTypes.DAY: {
|
|
||||||
createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case EventTypes.WEEK: {
|
|
||||||
createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [props]);
|
|
||||||
|
|
||||||
const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => {
|
|
||||||
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => {
|
|
||||||
let availabilities: AvailabilityDay[] = [];
|
|
||||||
|
|
||||||
for (var i: number = 0; i < numberOfDays; i++) {
|
|
||||||
let availability: AvailabilityDay = {
|
|
||||||
forDate: date.add(i, "day").startOf("day"),
|
|
||||||
availableTimes: []
|
|
||||||
}
|
|
||||||
|
|
||||||
availabilities.push(availability);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDays(availabilities);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayGhostPeriod = (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => {
|
const displayGhostPeriod = (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => {
|
||||||
let timeInMinutes = (time.hour() * 60) + time.minute();
|
let timeInMinutes = (time.hour() * 60.0) + time.minute();
|
||||||
let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60;
|
let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24.0 * 60.0;
|
||||||
|
|
||||||
if (timeLeftInDayLessThanDuration) {
|
if (timeLeftInDayLessThanDuration) {
|
||||||
return;
|
return;
|
||||||
|
@ -215,16 +52,34 @@ const AvailabilityPicker = (props: {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
left: e.target?.offsetLeft - scrollLeft,
|
left: e.target?.offsetLeft - scrollLeft,
|
||||||
width: element.width,
|
width: element.width,
|
||||||
height: (props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT
|
height: (props.availabilityDurationInMinutes/60.0) * 2 * HALFHOUR_DISPLAY_HEIGHT
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAvailability = (day: AvailabilityDay, time: Dayjs) => {
|
const deleteAvailability = (day: AvailabilityDay, time: Dayjs) => {
|
||||||
|
let existingTime = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, time, t.toTime));
|
||||||
|
|
||||||
|
if (existingTime >= 0) {
|
||||||
|
console.log(`delete ${existingTime} from`, day)
|
||||||
|
|
||||||
|
day.availableTimes.splice(existingTime, 1);
|
||||||
|
|
||||||
|
props.setDays([...props.days]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeAvailability = (day: AvailabilityDay, time: Dayjs, isDelete: boolean) => {
|
||||||
|
if (isDelete) {
|
||||||
|
deleteAvailability(day, time);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let fromTime = time;
|
let fromTime = time;
|
||||||
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
|
let toTime = time.add(props.availabilityDurationInMinutes, "minutes");
|
||||||
|
|
||||||
let existingTimeContainingFrom = day.availableTimes.findIndex(t => (t.fromTime.isBefore(fromTime) || t.fromTime.isSame(fromTime)) && (t.toTime.isAfter(fromTime) || t.toTime.isSame(fromTime)));
|
let existingTimeContainingFrom = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, fromTime, t.toTime));
|
||||||
let existingTimeContainingTo = day.availableTimes.findIndex(t => (t.fromTime.isBefore(toTime) || t.fromTime.isSame(toTime)) && (t.toTime.isAfter(toTime) || t.toTime.isSame(toTime)));
|
let existingTimeContainingTo = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, toTime, t.toTime));
|
||||||
|
|
||||||
// the newly created availability crosses another single one. Both have the same from and to. Do nothing.
|
// the newly created availability crosses another single one. Both have the same from and to. Do nothing.
|
||||||
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) {
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) {
|
||||||
|
@ -234,16 +89,18 @@ const AvailabilityPicker = (props: {
|
||||||
// the newly created availability crosses 2 existing ones. Combine all of them into a single one.
|
// the newly created availability crosses 2 existing ones. Combine all of them into a single one.
|
||||||
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo) {
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo) {
|
||||||
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
||||||
let newTo = day.availableTimes[existingTimeContainingFrom].toTime;
|
let newTo = day.availableTimes[existingTimeContainingTo].toTime;
|
||||||
|
|
||||||
day.availableTimes.splice(existingTimeContainingFrom);
|
day.availableTimes.splice(existingTimeContainingFrom, 1);
|
||||||
day.availableTimes.splice(existingTimeContainingTo);
|
day.availableTimes.splice(existingTimeContainingTo, 1);
|
||||||
|
|
||||||
day.availableTimes.push({
|
day.availableTimes.push({
|
||||||
fromTime: newFrom,
|
fromTime: newFrom,
|
||||||
toTime: newTo
|
toTime: newTo
|
||||||
});
|
});
|
||||||
|
|
||||||
|
props.setDays([...props.days]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -251,27 +108,31 @@ const AvailabilityPicker = (props: {
|
||||||
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) {
|
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) {
|
||||||
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime;
|
||||||
|
|
||||||
day.availableTimes.splice(existingTimeContainingFrom);
|
day.availableTimes.splice(existingTimeContainingFrom, 1);
|
||||||
|
|
||||||
day.availableTimes.push({
|
day.availableTimes.push({
|
||||||
fromTime: newFrom,
|
fromTime: newFrom,
|
||||||
toTime: toTime
|
toTime: toTime
|
||||||
});
|
});
|
||||||
|
|
||||||
|
props.setDays([...props.days]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The newly created availability to is within an existing one. Combine the 2 into one.
|
// The newly created availability to is within an existing one. Combine the 2 into one.
|
||||||
if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) {
|
if (existingTimeContainingFrom < 0 && existingTimeContainingTo >= 0) {
|
||||||
let newTo = day.availableTimes[existingTimeContainingFrom].toTime;
|
let newTo = day.availableTimes[existingTimeContainingTo].toTime;
|
||||||
|
|
||||||
day.availableTimes.splice(existingTimeContainingFrom);
|
day.availableTimes.splice(existingTimeContainingTo, 1);
|
||||||
|
|
||||||
day.availableTimes.push({
|
day.availableTimes.push({
|
||||||
fromTime: fromTime,
|
fromTime: fromTime,
|
||||||
toTime: newTo
|
toTime: newTo
|
||||||
});
|
});
|
||||||
|
|
||||||
|
props.setDays([...props.days]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -280,12 +141,13 @@ const AvailabilityPicker = (props: {
|
||||||
toTime: toTime
|
toTime: toTime
|
||||||
});
|
});
|
||||||
|
|
||||||
setDays([...days])
|
props.setDays([...props.days]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={"availability-parent-box"}
|
className={"availability-parent-box"}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
id="availability-picker"
|
id="availability-picker"
|
||||||
|
@ -296,16 +158,23 @@ const AvailabilityPicker = (props: {
|
||||||
onScroll={(_) => setGhostPreviewProps(null)}
|
onScroll={(_) => setGhostPreviewProps(null)}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
days.map(day =>
|
props.days.map(day =>
|
||||||
<Day
|
<AvailabilityPickerDay
|
||||||
key={day.forDate.unix()}
|
key={day.forDate.unix()}
|
||||||
day={day}
|
day={day}
|
||||||
eventType={props.eventType}
|
eventType={props.eventType}
|
||||||
|
halfHourDisplayHeight={HALFHOUR_DISPLAY_HEIGHT}
|
||||||
|
othersAvailabilityDay={props.othersAvailabilities.map(a => {
|
||||||
|
return {
|
||||||
|
userName: a.userName,
|
||||||
|
availableTimes: a.days.find(d => d.forDate.unix() === day.forDate.unix())?.availableTimes ?? []
|
||||||
|
} as OthersDay;
|
||||||
|
})}
|
||||||
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
|
onMouseEnterHalfhour={(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => {
|
||||||
displayGhostPeriod(e, time);
|
displayGhostPeriod(e, time);
|
||||||
}}
|
}}
|
||||||
onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs) => {
|
onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => {
|
||||||
createAvailability(day, time);
|
changeAvailability(day, time, isDelete);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
93
frontend/src/components/AvailabilityPickerDay.tsx
Normal file
93
frontend/src/components/AvailabilityPickerDay.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import { AvailabilityDay, OthersDay } from "../types/Availabilities";
|
||||||
|
import utils from "../utils";
|
||||||
|
import { Box, Card, Divider, Typography } from "@mui/material";
|
||||||
|
import { EventTypes } from "../types/Event";
|
||||||
|
import AvailabilityPickerHour from "./AvailabilityPickerHour";
|
||||||
|
import "./css/AvailabilityPicker.css";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
dayjs.extend(localizedFormat)
|
||||||
|
|
||||||
|
const AvailabilityPickerDay = (props: {
|
||||||
|
day: AvailabilityDay,
|
||||||
|
eventType: String,
|
||||||
|
halfHourDisplayHeight: number,
|
||||||
|
othersAvailabilityDay: OthersDay[],
|
||||||
|
onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs) => void,
|
||||||
|
onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => void
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const generateHours = (): JSX.Element[] => {
|
||||||
|
let hours: JSX.Element[] = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < 24; i++) {
|
||||||
|
let fullHourTime = props.day.forDate.set("hour", i);
|
||||||
|
let halfHourTime = fullHourTime.add(30, "minutes");
|
||||||
|
|
||||||
|
hours.push(
|
||||||
|
<AvailabilityPickerHour
|
||||||
|
key={fullHourTime.unix()}
|
||||||
|
dateTime={fullHourTime}
|
||||||
|
halfHourDisplayHeight={props.halfHourDisplayHeight}
|
||||||
|
namesMarkedFullHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, fullHourTime, t.toTime))).map(d => d.userName)}
|
||||||
|
namesMarkedHalfHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, halfHourTime, t.toTime))).map(d => d.userName)}
|
||||||
|
isFullHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, fullHourTime, a.toTime))}
|
||||||
|
isHalfHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, halfHourTime, a.toTime))}
|
||||||
|
onMouseEnterHalfhour={(e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: dayjs.Dayjs): void => {
|
||||||
|
props.onMouseEnterHalfhour(e, time);
|
||||||
|
}}
|
||||||
|
onMouseClickOnHalfhour={(time: dayjs.Dayjs, isDelete: boolean): void => {
|
||||||
|
props.onMouseClickHalfhour(props.day, time, isDelete);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
|
||||||
|
key={props.day.forDate.format()}
|
||||||
|
className={"day-card"}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
(props.eventType === EventTypes.WEEK) &&
|
||||||
|
<Typography>
|
||||||
|
{ props.day.forDate.format("dddd") }
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
(props.eventType === EventTypes.DAY) &&
|
||||||
|
<Typography>
|
||||||
|
Any Day
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
(props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) &&
|
||||||
|
<Typography>
|
||||||
|
{ props.day.forDate.format("LL") }
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider></Divider>
|
||||||
|
|
||||||
|
{generateHours()}
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvailabilityPickerDay;
|
86
frontend/src/components/AvailabilityPickerHour.tsx
Normal file
86
frontend/src/components/AvailabilityPickerHour.tsx
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import utils from "../utils";
|
||||||
|
import "./css/AvailabilityPicker.css";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import DisableableTooltip from "./DisableableTooltip";
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
dayjs.extend(localizedFormat)
|
||||||
|
|
||||||
|
const AvailabilityPickerHour = (props: {
|
||||||
|
dateTime: Dayjs,
|
||||||
|
isFullHourSelected: boolean,
|
||||||
|
isHalfHourSelected: boolean,
|
||||||
|
halfHourDisplayHeight: number,
|
||||||
|
namesMarkedFullHourAsAvailable: String[],
|
||||||
|
namesMarkedHalfHourAsAvailable: String[],
|
||||||
|
onMouseEnterHalfhour: (e: React.MouseEvent<HTMLDivElement, globalThis.MouseEvent>, time: Dayjs) => void,
|
||||||
|
onMouseClickOnHalfhour: (time: Dayjs, isDelete: boolean) => void
|
||||||
|
}) => {
|
||||||
|
const generateTooltipText = (names: String[]): String => {
|
||||||
|
return `${names.length} ${names.length > 1 ? "people have" : "person has"} marked this time as available: ${names.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
//className={classNames({ "hour-light": isEvenHour, "hour-dark": !isEvenHour })}
|
||||||
|
className={"hour-light"}
|
||||||
|
>
|
||||||
|
<DisableableTooltip
|
||||||
|
disabled={props.namesMarkedFullHourAsAvailable.length < 1}
|
||||||
|
title={generateTooltipText(props.namesMarkedFullHourAsAvailable)}
|
||||||
|
placement="top"
|
||||||
|
disableInteractive
|
||||||
|
followCursor={true}
|
||||||
|
arrow
|
||||||
|
enterDelay={500}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className={classNames("full-hour", `hour-available-${props.namesMarkedFullHourAsAvailable.length}`, { "selected-availability": props.isFullHourSelected })}
|
||||||
|
height={props.halfHourDisplayHeight}
|
||||||
|
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.button !== 0 && e.button !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onMouseClickOnHalfhour(props.dateTime, e.button === 2);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography className={"noselect time-text"}>
|
||||||
|
{ utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) }
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DisableableTooltip>
|
||||||
|
<DisableableTooltip
|
||||||
|
disabled={props.namesMarkedHalfHourAsAvailable.length < 1}
|
||||||
|
title={generateTooltipText(props.namesMarkedHalfHourAsAvailable)}
|
||||||
|
placement="top"
|
||||||
|
disableInteractive
|
||||||
|
followCursor={true}
|
||||||
|
arrow
|
||||||
|
enterDelay={500}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className={classNames("half-hour", `hour-available-${props.namesMarkedHalfHourAsAvailable.length}`, { "selected-availability": props.isHalfHourSelected })}
|
||||||
|
height={props.halfHourDisplayHeight}
|
||||||
|
onMouseEnter={(e) => props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.button !== 0 && e.button !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"), e.button === 2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DisableableTooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvailabilityPickerHour;
|
28
frontend/src/components/DisableableTooltip.tsx
Normal file
28
frontend/src/components/DisableableTooltip.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { Tooltip, TooltipProps } from '@mui/material'
|
||||||
|
|
||||||
|
const DisableableTooltip = ({
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...tooltipProps
|
||||||
|
}: TooltipProps & { disabled: boolean }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) setOpen(false)
|
||||||
|
}, [disabled])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
{...tooltipProps}
|
||||||
|
open={open}
|
||||||
|
onOpen={() => !disabled && setOpen(true)}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DisableableTooltip;
|
|
@ -7,7 +7,7 @@ export default function Footer() {
|
||||||
direction="column"
|
direction="column"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
<Typography align="center">
|
<Typography align="center" fontSize={"0.75em"}>
|
||||||
Created by <a href="https://mvvasilev.dev">mvvasilev</a> | <a href="https://github.com/mvvasilev/findtheti.me">Github</a>
|
Created by <a href="https://mvvasilev.dev">mvvasilev</a> | <a href="https://github.com/mvvasilev/findtheti.me">Github</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -1,24 +1,74 @@
|
||||||
:root {
|
:root {
|
||||||
--hover-color: #004455;
|
--hover-color: #555555;
|
||||||
--hour-light-color: #003344;
|
--hour-light-color: #333333;
|
||||||
--hour-dark-color: #002233;
|
--hour-dark-color: #222222;
|
||||||
--hour-border-bolor: #777;
|
--hour-border-bolor: #777;
|
||||||
--hour-text-color: #ddd;
|
--hour-text-color: #ddd;
|
||||||
--active-color: #223300;
|
--active-color: #223300;
|
||||||
--currently-selected-color: #112200;
|
--currently-selected-color: #336622;
|
||||||
|
--currently-selected-color-hover: #338822;
|
||||||
--halfhour-border-color: #333;
|
--halfhour-border-color: #333;
|
||||||
|
--halfhour-border-color-light: #777;
|
||||||
|
--ghost-box-color: #118811;
|
||||||
|
--time-text-color: #ddd;
|
||||||
|
--hour-marked-available: #330033;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
div[class*=" hour-available-"]{
|
||||||
|
background-color: #bb00bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-0 {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-1 {
|
||||||
|
background-color: #330033;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-2 {
|
||||||
|
background-color: #440044;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-3 {
|
||||||
|
background-color: #550055;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-4 {
|
||||||
|
background-color: #660066;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-5 {
|
||||||
|
background-color: #770077;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-6 {
|
||||||
|
background-color: #880088;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-7 {
|
||||||
|
background-color: #990099;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-8 {
|
||||||
|
background-color: #aa00aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hour-available-9 {
|
||||||
|
background-color: #bb00bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.hour-light {
|
div.hour-light {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: solid 1px;
|
border-top: solid 1px;
|
||||||
border-color: var(--hour-border-bolor);
|
border-color: var(--hour-border-bolor);
|
||||||
background-color: var(--hour-light-color);
|
background-color: var(--hour-light-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.hour-dark {
|
div.hour-dark {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: solid 1px;
|
border-top: solid 1px;
|
||||||
border-color: var(--hour-border-bolor);
|
border-color: var(--hour-border-bolor);
|
||||||
background-color: var(--hour-dark-color);
|
background-color: var(--hour-dark-color);
|
||||||
}
|
}
|
||||||
|
@ -26,7 +76,7 @@ div.hour-dark {
|
||||||
div.full-hour {
|
div.full-hour {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: dashed 1px;
|
border-bottom: dashed 1px;
|
||||||
border-color: var(--halfhour-border-color)
|
border-color: var(--halfhour-border-color-light)
|
||||||
}
|
}
|
||||||
|
|
||||||
div.half-hour {
|
div.half-hour {
|
||||||
|
@ -45,12 +95,16 @@ div.full-hour:active, div.half-hour:active {
|
||||||
background-color: var(--active-color);
|
background-color: var(--active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.selected-availability:hover {
|
||||||
|
background-color: var(--currently-selected-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
div.ghost-box {
|
div.ghost-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: rgba(0, 255, 0, 0.1);
|
background-color: rgba(0, 255, 0, 0.2);
|
||||||
border: solid 1px;
|
border: solid 1px;
|
||||||
border-color: #227722;
|
border-color: var(--ghost-box-color);
|
||||||
border-radius: 1;
|
border-radius: 4%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -59,7 +113,7 @@ div.ghost-box {
|
||||||
p.time-text {
|
p.time-text {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.65em;
|
font-size: 0.65em;
|
||||||
color: #ddd;
|
color: var(--time-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.day-card {
|
div.day-card {
|
||||||
|
|
|
@ -1,31 +1,180 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Event, createEvent } from '../types/Event';
|
import { Event, EventTypes, createEvent } from '../types/Event';
|
||||||
import Grid from '@mui/material/Unstable_Grid2'
|
import Grid from '@mui/material/Unstable_Grid2'
|
||||||
import { Button, TextField, Typography } from "@mui/material";
|
import { Button, TextField, Typography } from "@mui/material";
|
||||||
import AvailabilityPicker from "../components/AvailabilityPicker";
|
import AvailabilityPicker from "../components/AvailabilityPicker";
|
||||||
import dayjs from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import utils from "../utils";
|
import utils from "../utils";
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import { AvailabilityDay, AvailabilityTime, OthersDays } from "../types/Availabilities";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
dayjs.extend(localizedFormat)
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
export default function ExistingEventPage() {
|
export default function ExistingEventPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
let { eventId } = useParams();
|
let { eventId } = useParams();
|
||||||
|
|
||||||
|
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||||
const [event, setEvent] = useState<Event>(createEvent());
|
const [event, setEvent] = useState<Event>(createEvent());
|
||||||
|
const [days, setDays] = useState<AvailabilityDay[]>([]);
|
||||||
|
const [othersDays, setOthersDays] = useState<OthersDays[]>([]);
|
||||||
|
const [userName, setUserName] = useState<String | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/events/${eventId}`)
|
utils.showSpinner();
|
||||||
.then(resp => resp.json())
|
|
||||||
.then(resp => setEvent({
|
Promise.all([
|
||||||
name: resp.result?.name,
|
utils.performRequest(`/api/events/${eventId}`)
|
||||||
description: resp.result?.description,
|
.then(result => setEvent({
|
||||||
fromDate: dayjs.utc(resp.result?.from_date),
|
name: result?.name,
|
||||||
toDate: dayjs.utc(resp.result?.to_date),
|
description: result?.description,
|
||||||
eventType: resp.result?.event_type,
|
fromDate: dayjs.utc(result?.from_date),
|
||||||
snowflakeId: resp.result?.snowflake_id,
|
toDate: dayjs.utc(result?.to_date),
|
||||||
duration: resp.result?.duration
|
eventType: result?.event_type,
|
||||||
}));
|
snowflakeId: result?.snowflake_id,
|
||||||
|
duration: result?.duration
|
||||||
|
}))
|
||||||
|
.catch(e => toast.error(e)),
|
||||||
|
|
||||||
|
utils.performRequest(`/api/events/${eventId}/availabilities`)
|
||||||
|
.then((result: [{ id: number, from_date: string, to_date: string, user_name: string }]) => {
|
||||||
|
let othersDays: OthersDays[] = [];
|
||||||
|
let localTimezone = dayjs.tz.guess();
|
||||||
|
|
||||||
|
for (const availability of result) {
|
||||||
|
let fromDate = dayjs(availability.from_date).tz(localTimezone);
|
||||||
|
let toDate = dayjs(availability.to_date).tz(localTimezone);
|
||||||
|
|
||||||
|
var userTimes = othersDays.find(d => d.userName === availability.user_name);
|
||||||
|
|
||||||
|
if (!userTimes) {
|
||||||
|
userTimes = { userName: availability.user_name, days: []} as OthersDays;
|
||||||
|
|
||||||
|
othersDays.push(userTimes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let availabilityDay = fromDate.hour(0).minute(0).second(0).millisecond(0);
|
||||||
|
|
||||||
|
var day = userTimes.days.find(d => d.forDate.unix() === availabilityDay.unix());
|
||||||
|
|
||||||
|
if (!day) {
|
||||||
|
day = {
|
||||||
|
forDate: availabilityDay,
|
||||||
|
availableTimes: []
|
||||||
|
} as AvailabilityDay;
|
||||||
|
|
||||||
|
userTimes.days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
day.availableTimes.push({
|
||||||
|
fromTime: fromDate,
|
||||||
|
toTime: toDate
|
||||||
|
} as AvailabilityTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
setOthersDays(othersDays);
|
||||||
|
})
|
||||||
|
.catch(e => toast.error(e))
|
||||||
|
])
|
||||||
|
.finally(() => utils.hideSpinner());;
|
||||||
|
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `findtheti.me - ${event.name}`;
|
||||||
|
}, [event])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (event.fromDate === null || event.toDate === null || event.eventType === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localTimezone = dayjs.tz.guess();
|
||||||
|
|
||||||
|
let localFromDate = event.fromDate.tz(localTimezone);
|
||||||
|
let localToDate = event.toDate.tz(localTimezone);
|
||||||
|
|
||||||
|
switch (event.eventType) {
|
||||||
|
case EventTypes.SPECIFIC_DATE: {
|
||||||
|
createAvailabilitiesBasedOnInitialDate(localFromDate, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EventTypes.DATE_RANGE: {
|
||||||
|
createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EventTypes.DAY: {
|
||||||
|
createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EventTypes.WEEK: {
|
||||||
|
createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
var valid = !utils.isNullOrUndefined(userName) && userName !== "";
|
||||||
|
|
||||||
|
valid &&= days.some(day => day.availableTimes.length > 0);
|
||||||
|
|
||||||
|
setCanSubmit(valid);
|
||||||
|
}, [userName, days]);
|
||||||
|
|
||||||
|
const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => {
|
||||||
|
createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => {
|
||||||
|
let availabilities: AvailabilityDay[] = [];
|
||||||
|
|
||||||
|
for (var i: number = 0; i < numberOfDays; i++) {
|
||||||
|
let availability: AvailabilityDay = {
|
||||||
|
forDate: date.add(i, "day").startOf("day"),
|
||||||
|
availableTimes: []
|
||||||
|
}
|
||||||
|
|
||||||
|
availabilities.push(availability);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDays(availabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAvailabilities = () => {
|
||||||
|
utils.showSpinner();
|
||||||
|
|
||||||
|
let body = {
|
||||||
|
user_name: userName,
|
||||||
|
availabilities: days.flatMap(day => day.availableTimes.map(a => {
|
||||||
|
return {
|
||||||
|
from_date: a.fromTime.utc(),
|
||||||
|
to_date: a.toTime.utc()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
utils.performRequest(`/api/events/${eventId}/availabilities`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(_ => navigate("/thank-you"))
|
||||||
|
.catch(e => toast.error(e))
|
||||||
|
.finally(() => utils.hideSpinner());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container sx={{ p: 2 }} spacing={1}>
|
<Grid container sx={{ p: 2 }} spacing={1}>
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
|
@ -49,29 +198,41 @@ export default function ExistingEventPage() {
|
||||||
{
|
{
|
||||||
(event.fromDate !== null && event.toDate !== null && event.eventType !== null) &&
|
(event.fromDate !== null && event.toDate !== null && event.eventType !== null) &&
|
||||||
<AvailabilityPicker
|
<AvailabilityPicker
|
||||||
fromDate={event.fromDate}
|
days={days}
|
||||||
toDate={event.toDate}
|
setDays={(days) => setDays(days)}
|
||||||
|
othersAvailabilities={othersDays}
|
||||||
eventType={event.eventType}
|
eventType={event.eventType}
|
||||||
availabilityDurationInMinutes={event.duration}
|
availabilityDurationInMinutes={event.duration}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
<Typography pt={1} fontSize={"0.65em"}>
|
||||||
|
Left-click to select when you're available, right-click to remove the highlighted hours.
|
||||||
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={0} md={3}></Grid>
|
<Grid xs={0} md={3}></Grid>
|
||||||
<Grid xs={12} md={6} container spacing={1}>
|
<Grid xs={12} md={6} container spacing={1}>
|
||||||
<Grid xs={12} sm={9}>
|
<Grid xs={12} sm={9}>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ width: "100%" }}
|
sx={{ width: "100%" }}
|
||||||
// TODO
|
value={userName || ""}
|
||||||
// value={event.description}
|
onChange={(e) => {
|
||||||
// onChange={(e) => {
|
if (e.target.value?.length > 100) {
|
||||||
// event.description = e.target.value;
|
e.preventDefault();
|
||||||
// setEvent({...event});
|
return;
|
||||||
// }}
|
}
|
||||||
|
|
||||||
|
setUserName(e.target.value);
|
||||||
|
}}
|
||||||
label="Your Name"
|
label="Your Name"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12} sm={3}>
|
<Grid xs={12} sm={3}>
|
||||||
<Button sx={{ width: "100%", height: "100%" }} variant="contained">
|
<Button
|
||||||
|
sx={{ width: "100%", height: "100%" }}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={(_) => submitAvailabilities()}
|
||||||
|
>
|
||||||
<Typography>Submit</Typography>
|
<Typography>Submit</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { Alert, Button, MenuItem, Select, Slider, TextField, Typography } from '@mui/material';
|
import { Alert, Button, MenuItem, Select, Slider, TextField, Typography } from '@mui/material';
|
||||||
import Grid from '@mui/material/Unstable_Grid2'
|
import Grid from '@mui/material/Unstable_Grid2'
|
||||||
import { DateTimePicker } from '@mui/x-date-pickers';
|
import { DatePicker } from '@mui/x-date-pickers';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Event, EventTypes, createEvent } from '../types/Event';
|
import { Event, EventTypes, createEvent } from '../types/Event';
|
||||||
import utils from '../utils';
|
import utils from '../utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export default function NewEventPage() {
|
export default function NewEventPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
@ -17,26 +19,42 @@ export default function NewEventPage() {
|
||||||
}, [event])
|
}, [event])
|
||||||
|
|
||||||
function validateEvent(): void {
|
function validateEvent(): void {
|
||||||
console.log(event);
|
|
||||||
var valid: boolean = true;
|
var valid: boolean = true;
|
||||||
|
|
||||||
valid &&= event.name && event.name !== "";
|
let today = dayjs().hour(0).minute(0).second(0).millisecond(0);
|
||||||
valid &&= event.eventType !== EventTypes.UNKNOWN || event.eventType !== null;
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.name) && event.name !== "";
|
||||||
|
|
||||||
|
valid &&= event.eventType !== EventTypes.UNKNOWN && event.eventType !== null;
|
||||||
|
|
||||||
if (event.eventType === EventTypes.DATE_RANGE) {
|
if (event.eventType === EventTypes.DATE_RANGE) {
|
||||||
valid &&= event.fromDate !== null;
|
|
||||||
valid &&= event.toDate !== null;
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate);
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.toDate) && event.toDate!.unix() > today.unix();
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && event.fromDate!.unix() >= today.unix();
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.unix() > event.fromDate!.unix();
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.diff(event.fromDate!, "days") >= 1;
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.diff(event.fromDate!, "days") <= 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.eventType === EventTypes.SPECIFIC_DATE) {
|
if (event.eventType === EventTypes.SPECIFIC_DATE) {
|
||||||
valid &&= event.fromDate !== null;
|
valid &&= !utils.isNullOrUndefined(event.fromDate);
|
||||||
|
|
||||||
|
valid &&= !utils.isNullOrUndefined(event.fromDate) && event.fromDate!.unix() >= today.unix();
|
||||||
}
|
}
|
||||||
|
|
||||||
setEventValid(valid);
|
setEventValid(valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEvent() {
|
function saveEvent() {
|
||||||
fetch("/api/events", {
|
utils.showSpinner();
|
||||||
|
|
||||||
|
utils.performRequest("/api/events", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
@ -50,10 +68,13 @@ export default function NewEventPage() {
|
||||||
duration: event.duration
|
duration: event.duration
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(resp => resp.json())
|
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
navigate(resp.result.snowflake_id)
|
navigate(resp.snowflake_id)
|
||||||
})
|
})
|
||||||
|
.catch(err => {
|
||||||
|
toast.error(err)
|
||||||
|
})
|
||||||
|
.finally(() => utils.hideSpinner());
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -146,8 +167,9 @@ export default function NewEventPage() {
|
||||||
{
|
{
|
||||||
event.eventType == EventTypes.SPECIFIC_DATE &&
|
event.eventType == EventTypes.SPECIFIC_DATE &&
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
sx={{ width: "100%" }}
|
sx={{ width: "100%" }}
|
||||||
|
minDate={dayjs()}
|
||||||
value={event.fromDate}
|
value={event.fromDate}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
event.fromDate = value ?? null;
|
event.fromDate = value ?? null;
|
||||||
|
@ -160,8 +182,9 @@ export default function NewEventPage() {
|
||||||
{
|
{
|
||||||
event.eventType == EventTypes.DATE_RANGE &&
|
event.eventType == EventTypes.DATE_RANGE &&
|
||||||
<Grid xs={12} sm={6}>
|
<Grid xs={12} sm={6}>
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
sx={{ width: "100%" }}
|
sx={{ width: "100%" }}
|
||||||
|
minDate={dayjs()}
|
||||||
value={event.fromDate}
|
value={event.fromDate}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
event.fromDate = value ?? null;
|
event.fromDate = value ?? null;
|
||||||
|
@ -174,8 +197,10 @@ export default function NewEventPage() {
|
||||||
{
|
{
|
||||||
event.eventType == EventTypes.DATE_RANGE &&
|
event.eventType == EventTypes.DATE_RANGE &&
|
||||||
<Grid xs={12} sm={6}>
|
<Grid xs={12} sm={6}>
|
||||||
<DateTimePicker
|
<DatePicker
|
||||||
sx={{ width: "100%" }}
|
sx={{ width: "100%" }}
|
||||||
|
minDate={event.fromDate?.add(1, "day") ?? dayjs().add(1, "day")}
|
||||||
|
maxDate={event.fromDate?.add(14, "day") ?? dayjs().add(14, "day")}
|
||||||
value={event.toDate}
|
value={event.toDate}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
event.toDate = value ?? null;
|
event.toDate = value ?? null;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ThemeProvider } from "@emotion/react";
|
import { ThemeProvider } from "@emotion/react";
|
||||||
import { CssBaseline, Divider, Paper, createTheme } from "@mui/material";
|
import { Backdrop, CircularProgress, CssBaseline, Divider, Paper, createTheme } from "@mui/material";
|
||||||
import Grid from '@mui/material/Unstable_Grid2'
|
import Grid from '@mui/material/Unstable_Grid2'
|
||||||
import GithubCorner from "react-github-corner";
|
import GithubCorner from "react-github-corner";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
@ -7,7 +7,10 @@ import Footer from "../components/Footer";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import * as utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import utils from "../utils";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
@ -17,11 +20,39 @@ const theme = createTheme({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout(props: { children: React.ReactNode }) {
|
const RootLayout = (props: { children: React.ReactNode }) => {
|
||||||
|
|
||||||
|
const [spinner, showSpinner] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("onSpinnerStatusChange", () => {
|
||||||
|
showSpinner(utils.isSpinnerShown());
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline/>
|
<CssBaseline/>
|
||||||
|
|
||||||
|
{
|
||||||
|
<Backdrop
|
||||||
|
sx={{
|
||||||
|
color: '#fff',
|
||||||
|
zIndex: 2147483647
|
||||||
|
}}
|
||||||
|
open={spinner}
|
||||||
|
>
|
||||||
|
<CircularProgress
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2147483647,
|
||||||
|
top: "50%",
|
||||||
|
left: "50%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Backdrop>
|
||||||
|
}
|
||||||
|
|
||||||
<GithubCorner
|
<GithubCorner
|
||||||
href={"https://github.com/mvvasilev/findtheti.me"}
|
href={"https://github.com/mvvasilev/findtheti.me"}
|
||||||
bannerColor="#FD6C6C"
|
bannerColor="#FD6C6C"
|
||||||
|
@ -67,6 +98,23 @@ export default function RootLayout(props: { children: React.ReactNode }) {
|
||||||
</Grid>
|
</Grid>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<Toaster
|
||||||
|
toastOptions={{
|
||||||
|
success: {
|
||||||
|
style: {
|
||||||
|
background: '#dad7cd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
style: {
|
||||||
|
background: '#ff8fab',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default RootLayout;
|
23
frontend/src/pages/ThankYouPage.tsx
Normal file
23
frontend/src/pages/ThankYouPage.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import Grid from '@mui/material/Unstable_Grid2'
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ThankYouPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container sx={{ p: 2 }} spacing={1}>
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Typography variant={"h2"}>Thank You!</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Typography>Your response has been recorded. Check in with the event organizer(s) for the exact date and time!</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Typography>To view the available times of all attendees, feel free to <a href="#" onClick={() => navigate(-1)}>navigate back to the event page</a>.</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThankYouPage;
|
21
frontend/src/types/Availabilities.tsx
Normal file
21
frontend/src/types/Availabilities.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Dayjs } from "dayjs"
|
||||||
|
|
||||||
|
export type AvailabilityTime = {
|
||||||
|
fromTime: Dayjs,
|
||||||
|
toTime: Dayjs
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailabilityDay = {
|
||||||
|
forDate: Dayjs,
|
||||||
|
availableTimes: AvailabilityTime[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OthersDays = {
|
||||||
|
userName: String,
|
||||||
|
days: AvailabilityDay[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OthersDay = {
|
||||||
|
userName: String,
|
||||||
|
availableTimes: AvailabilityTime[]
|
||||||
|
}
|
|
@ -1,9 +1,43 @@
|
||||||
import dayjs from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import * as duration from 'dayjs/plugin/duration';
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
dayjs.extend(duration)
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
dayjs.extend(localizedFormat)
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
const utils = {
|
const utils = {
|
||||||
|
performRequest: (url: string | URL | Request, options?: RequestInit | undefined): Promise<any> => {
|
||||||
|
return fetch(url, options).then(async resp => {
|
||||||
|
if (!resp.ok) {
|
||||||
|
let errorTextResult = await resp.text();
|
||||||
|
|
||||||
|
var errorMsg = errorTextResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let jsonResult: any = JSON.parse(errorTextResult);
|
||||||
|
|
||||||
|
errorMsg = jsonResult?.error?.message;
|
||||||
|
} catch(err) {
|
||||||
|
errorMsg = errorTextResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw errorMsg;
|
||||||
|
} else {
|
||||||
|
let successTextResult = await resp.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let jsonResult = JSON.parse(successTextResult);
|
||||||
|
|
||||||
|
return jsonResult?.result;
|
||||||
|
} catch(err) {
|
||||||
|
return successTextResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
toHoursAndMinutes: (totalMinutes: number): { hours: number, minutes: number } => {
|
toHoursAndMinutes: (totalMinutes: number): { hours: number, minutes: number } => {
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
|
@ -24,6 +58,26 @@ const utils = {
|
||||||
},
|
},
|
||||||
formatTimeFromHourOfDay: (hourOfDay: number, minutes: number): String => {
|
formatTimeFromHourOfDay: (hourOfDay: number, minutes: number): String => {
|
||||||
return dayjs.duration({ hours: hourOfDay, minutes: minutes }).format('HH:mm');
|
return dayjs.duration({ hours: hourOfDay, minutes: minutes }).format('HH:mm');
|
||||||
|
},
|
||||||
|
dayjsIsBetweenUnixExclusive: (lowerBound: Dayjs, time: Dayjs, upperBound: Dayjs): boolean => {
|
||||||
|
return lowerBound.unix() <= time.unix() && time.unix() < upperBound.unix()
|
||||||
|
},
|
||||||
|
dayjsIsBetweenUnixInclusive: (lowerBound: Dayjs, time: Dayjs, upperBound: Dayjs): boolean => {
|
||||||
|
return lowerBound.unix() <= time.unix() && time.unix() <= upperBound.unix()
|
||||||
|
},
|
||||||
|
isSpinnerShown: (): boolean => {
|
||||||
|
return localStorage.getItem("SpinnerShowing") === "true";
|
||||||
|
},
|
||||||
|
showSpinner: (): void => {
|
||||||
|
localStorage.setItem("SpinnerShowing", "true");
|
||||||
|
window.dispatchEvent(new Event("onSpinnerStatusChange"));
|
||||||
|
},
|
||||||
|
hideSpinner: (): void => {
|
||||||
|
localStorage.removeItem("SpinnerShowing");
|
||||||
|
window.dispatchEvent(new Event("onSpinnerStatusChange"));
|
||||||
|
},
|
||||||
|
isNullOrUndefined: (thing: any): boolean => {
|
||||||
|
return thing === null || thing === undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|
|
@ -1487,6 +1487,11 @@ globby@^11.1.0:
|
||||||
merge2 "^1.4.1"
|
merge2 "^1.4.1"
|
||||||
slash "^3.0.0"
|
slash "^3.0.0"
|
||||||
|
|
||||||
|
goober@^2.1.10:
|
||||||
|
version "2.1.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c"
|
||||||
|
integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==
|
||||||
|
|
||||||
graphemer@^1.4.0:
|
graphemer@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
||||||
|
@ -1864,6 +1869,13 @@ react-github-corner@^2.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/react-github-corner/-/react-github-corner-2.5.0.tgz#e350d0c69f69c075bc0f1d2a6f1df6ee91da31f2"
|
resolved "https://registry.yarnpkg.com/react-github-corner/-/react-github-corner-2.5.0.tgz#e350d0c69f69c075bc0f1d2a6f1df6ee91da31f2"
|
||||||
integrity sha512-ofds9l6n61LJc6ML+jSE6W9ZSQvATcMR9evnHPXua1oDYj289HKODnVqFUB/g2a4ieBjDHw416iHP3MjqnU76Q==
|
integrity sha512-ofds9l6n61LJc6ML+jSE6W9ZSQvATcMR9evnHPXua1oDYj289HKODnVqFUB/g2a4ieBjDHw416iHP3MjqnU76Q==
|
||||||
|
|
||||||
|
react-hot-toast@^2.4.1:
|
||||||
|
version "2.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"
|
||||||
|
integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==
|
||||||
|
dependencies:
|
||||||
|
goober "^2.1.10"
|
||||||
|
|
||||||
react-is@^16.13.1, react-is@^16.7.0:
|
react-is@^16.13.1, react-is@^16.7.0:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||||
|
|
24
src/api.rs
24
src/api.rs
|
@ -31,18 +31,26 @@ pub(crate) async fn routes() -> Result<Router, ApplicationError> {
|
||||||
.with_state(AppState::new().await?))
|
.with_state(AppState::new().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ok<T: Serialize, E: Error>(r: Result<T, E>) -> UniversalResponseDto<T> {
|
pub(crate) fn ok<T: Serialize>(r: Result<T, ApplicationError>) -> UniversalResponseDto<T> {
|
||||||
match r {
|
match r {
|
||||||
Ok(res) => UniversalResponseDto {
|
Ok(res) => UniversalResponseDto {
|
||||||
status: StatusCode::OK,
|
status: StatusCode::OK,
|
||||||
result: Some(res),
|
result: Some(res),
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
Err(err) => internal_server_error(err),
|
Err(err) => error(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn internal_server_error<T: Serialize, E: Error>(e: E) -> UniversalResponseDto<T> {
|
pub(crate) fn error<T: Serialize>(e: ApplicationError) -> UniversalResponseDto<T> {
|
||||||
|
UniversalResponseDto {
|
||||||
|
status: e.status,
|
||||||
|
result: None,
|
||||||
|
error: Some(ErrorDto { message: format!("{}", e)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn internal_server_error<T: Serialize>(e: ApplicationError) -> UniversalResponseDto<T> {
|
||||||
UniversalResponseDto {
|
UniversalResponseDto {
|
||||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
result: None,
|
result: None,
|
||||||
|
@ -104,14 +112,15 @@ impl AppState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug)]
|
||||||
pub struct ApplicationError {
|
pub struct ApplicationError {
|
||||||
|
status: StatusCode,
|
||||||
msg: String,
|
msg: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationError {
|
impl ApplicationError {
|
||||||
pub fn new(msg: String) -> Self {
|
pub fn new(msg: String, status: StatusCode) -> Self {
|
||||||
Self { msg }
|
Self { msg, status }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,6 +128,7 @@ impl From<sqlx::Error> for ApplicationError {
|
||||||
fn from(value: sqlx::Error) -> Self {
|
fn from(value: sqlx::Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
msg: value.to_string(),
|
msg: value.to_string(),
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -127,6 +137,7 @@ impl From<MigrateError> for ApplicationError {
|
||||||
fn from(value: MigrateError) -> Self {
|
fn from(value: MigrateError) -> Self {
|
||||||
Self {
|
Self {
|
||||||
msg: value.to_string(),
|
msg: value.to_string(),
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,6 +146,7 @@ impl From<VarError> for ApplicationError {
|
||||||
fn from(value: VarError) -> Self {
|
fn from(value: VarError) -> Self {
|
||||||
Self {
|
Self {
|
||||||
msg: value.to_string(),
|
msg: value.to_string(),
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State, ConnectInfo},
|
||||||
Json,
|
Json, http::StatusCode, Extension,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
|
@ -13,7 +15,7 @@ use crate::{
|
||||||
entity::{
|
entity::{
|
||||||
availability::Availability,
|
availability::Availability,
|
||||||
event::{Event, EventType},
|
event::{Event, EventType},
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -41,7 +43,6 @@ pub struct EventDto {
|
||||||
pub struct CreateAvailabilitiesDto {
|
pub struct CreateAvailabilitiesDto {
|
||||||
availabilities: Vec<CreateAvailabilityDto>,
|
availabilities: Vec<CreateAvailabilityDto>,
|
||||||
user_email: Option<String>,
|
user_email: Option<String>,
|
||||||
user_ip: String,
|
|
||||||
user_name: String,
|
user_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +68,7 @@ pub async fn create_event(
|
||||||
|
|
||||||
let mut conn = match app_state.db_pool.acquire().await {
|
let mut conn = match app_state.db_pool.acquire().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return api::internal_server_error(e),
|
Err(e) => return api::internal_server_error(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = conn
|
let res = conn
|
||||||
|
@ -82,9 +83,31 @@ pub async fn create_event(
|
||||||
let event_type: EventType = dto.event_type.into();
|
let event_type: EventType = dto.event_type.into();
|
||||||
|
|
||||||
if matches!(event_type, EventType::Unknown) {
|
if matches!(event_type, EventType::Unknown) {
|
||||||
return Err(ApplicationError::new(
|
return Err(ApplicationError::new("Unknown event type, invalid variant.".to_string(), StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
"Unknown event type, invalid variant.".to_string(),
|
}
|
||||||
));
|
|
||||||
|
if matches!(event_type, EventType::SpecificDate) && dto.from_date.is_none() {
|
||||||
|
return Err(ApplicationError::new("SpecificDate event type supplied, but missing from_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(event_type, EventType::DateRange) {
|
||||||
|
match (dto.from_date, dto.to_date) {
|
||||||
|
(Some(from), Some(to)) => {
|
||||||
|
if from >= to {
|
||||||
|
return Err(ApplicationError::new("Supplied from_date is later than or equal to to_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to - from).num_days() < 1 {
|
||||||
|
return Err(ApplicationError::new("Difference between from_date and to_date is less than 1 day".to_string(), StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to - from).num_days() > 14 {
|
||||||
|
return Err(ApplicationError::new("Difference between from_date and to_date is greater than 14 days ( current supported maximum )".to_string(), StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return Err(ApplicationError::new("DateRange event type supplied, but missing either from_date or to_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_id = db::insert_event_and_fetch_id(
|
let event_id = db::insert_event_and_fetch_id(
|
||||||
|
@ -117,7 +140,7 @@ pub async fn create_event(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
api::ok::<EventDto, ApplicationError>(res)
|
api::ok::<EventDto>(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_event(
|
pub async fn fetch_event(
|
||||||
|
@ -126,7 +149,7 @@ pub async fn fetch_event(
|
||||||
) -> UniversalResponseDto<EventDto> {
|
) -> UniversalResponseDto<EventDto> {
|
||||||
let mut conn = match app_state.db_pool.acquire().await {
|
let mut conn = match app_state.db_pool.acquire().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return api::internal_server_error(e),
|
Err(e) => return api::internal_server_error(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = conn
|
let res = conn
|
||||||
|
@ -147,35 +170,39 @@ pub async fn fetch_event(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
api::ok::<EventDto, ApplicationError>(res)
|
api::ok::<EventDto>(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_availabilities(
|
pub async fn create_availabilities(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
Path(event_snowflake_id): Path<String>,
|
Path(event_snowflake_id): Path<String>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
Json(dto): Json<CreateAvailabilitiesDto>,
|
Json(dto): Json<CreateAvailabilitiesDto>,
|
||||||
) -> UniversalResponseDto<()> {
|
) -> UniversalResponseDto<()> {
|
||||||
let mut conn = match app_state.db_pool.acquire().await {
|
let mut conn = match app_state.db_pool.acquire().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return api::internal_server_error(e),
|
Err(e) => return api::internal_server_error(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = conn
|
let res = conn
|
||||||
.transaction(|txn| {
|
.transaction(|txn| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
let user_ip = format!("{}", addr.ip());
|
||||||
|
|
||||||
let event = db::fetch_event_by_snowflake_id(txn, event_snowflake_id).await?;
|
let event = db::fetch_event_by_snowflake_id(txn, event_snowflake_id).await?;
|
||||||
|
|
||||||
let current_availabilities = db::fetch_event_availabilities(txn, event.id).await?;
|
let current_availabilities = db::fetch_event_availabilities(txn, event.id).await?;
|
||||||
|
|
||||||
let already_submitted = current_availabilities.iter().any(|a| {
|
let already_submitted = current_availabilities.iter().any(|a| {
|
||||||
(dto.user_email.is_none() && a.user_email == dto.user_email)
|
(dto.user_email.is_some() && a.user_email.is_some() && a.user_email == dto.user_email)
|
||||||
|| a.user_ip == dto.user_ip
|
|| a.user_ip == user_ip
|
||||||
|| a.user_name == dto.user_name
|
|| a.user_name == dto.user_name
|
||||||
});
|
});
|
||||||
|
|
||||||
if already_submitted {
|
if already_submitted {
|
||||||
return Err(ApplicationError::new(
|
return Err(ApplicationError::new(
|
||||||
"Availability already submitted".to_string(),
|
"Availability already submitted".to_string(),
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,7 +217,7 @@ pub async fn create_availabilities(
|
||||||
from_date: a.from_date.naive_utc(),
|
from_date: a.from_date.naive_utc(),
|
||||||
to_date: a.to_date.naive_utc(),
|
to_date: a.to_date.naive_utc(),
|
||||||
user_email: dto.user_email.clone(),
|
user_email: dto.user_email.clone(),
|
||||||
user_ip: dto.user_ip.clone(),
|
user_ip: user_ip.clone(),
|
||||||
user_name: dto.user_name.clone(),
|
user_name: dto.user_name.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -202,7 +229,7 @@ pub async fn create_availabilities(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
api::ok::<(), ApplicationError>(res)
|
api::ok::<()>(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_availabilities(
|
pub async fn fetch_availabilities(
|
||||||
|
@ -211,7 +238,7 @@ pub async fn fetch_availabilities(
|
||||||
) -> UniversalResponseDto<Vec<AvailabilityDto>> {
|
) -> UniversalResponseDto<Vec<AvailabilityDto>> {
|
||||||
let mut conn = match app_state.db_pool.acquire().await {
|
let mut conn = match app_state.db_pool.acquire().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return api::internal_server_error(e),
|
Err(e) => return api::internal_server_error(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = conn
|
let res = conn
|
||||||
|
@ -234,5 +261,5 @@ pub async fn fetch_availabilities(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
api::ok::<Vec<AvailabilityDto>, ApplicationError>(res)
|
api::ok::<Vec<AvailabilityDto>>(res)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ async fn main() {
|
||||||
|
|
||||||
let listener = TcpListener::bind(addr).await.unwrap();
|
let listener = TcpListener::bind(addr).await.unwrap();
|
||||||
|
|
||||||
axum::serve(listener, routes.into_make_service())
|
axum::serve(listener, routes.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
Loading…
Add table
Reference in a new issue