diff --git a/frontend/package.json b/frontend/package.json index 01adcad..2a2ea8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,8 +13,11 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/material": "^5.15.3", + "@mui/x-date-pickers": "^6.18.7", + "dayjs": "^1.11.10", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-github-corner": "^2.5.0", "react-router-dom": "^6.21.1" }, "devDependencies": { diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..6b208be 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,15 @@ +@import url('https://fonts.googleapis.com/css2?family=Bungee+Spice&display=swap'); + +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ +} + #root { max-width: 1280px; margin: 0 auto; diff --git a/frontend/src/components/AvailabilityPicker.tsx b/frontend/src/components/AvailabilityPicker.tsx new file mode 100644 index 0000000..0b1554f --- /dev/null +++ b/frontend/src/components/AvailabilityPicker.tsx @@ -0,0 +1,308 @@ +import { Box, Card, Divider, Stack, Typography } from "@mui/material"; +import dayjs, { Dayjs } from "dayjs"; +import { 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"; + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(localizedFormat) + +type AvailabilityTime = { + fromTime: Dayjs, + toTime: Dayjs +} + +type AvailabilityDay = { + forDate: Dayjs, + availableTimes: AvailabilityTime[] +} + +const HALFHOUR_DISPLAY_HEIGHT: number = 15; + +const DAY_DISPLAY_WIDTH: String = "150px"; + +export default function AvailabilityPicker(props: { + fromDate: Dayjs, + toDate: Dayjs, + eventType: String, + availabilityDurationInMinutes: number +}) { + const [days, setDays] = useState([]); + const [selectingAvailabilityForDay, setAvailabilityDayBeingSelectedFor] = useState(null); + const [currentAvailabilityTime, setAvailabilityTime] = useState(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]); + + useEffect(() => { + console.log(days) + }, [days]) + + function createAvailabilitiesBasedOnUnspecifiedInitialDate(numberOfDays: number, tz: string) { + createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays); + } + + function 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); + } + + function clearAvailabilityTimeSelection() { + setAvailabilityDayBeingSelectedFor(null); + setAvailabilityTime(null); + } + + function beginAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay, startTime: Dayjs) { + setAvailabilityDayBeingSelectedFor(day); + setAvailabilityTime({ + fromTime: startTime, + toTime: startTime + }); + } + + function finishAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay) { + if (currentAvailabilityTime === null) { + return; + } + + day.availableTimes.push(currentAvailabilityTime); + setDays([...days]) + + clearAvailabilityTimeSelection(); + } + + function addTimeToAvailabilityTimeSelection(e: React.MouseEvent, day: AvailabilityDay, time: Dayjs) { + if (e.buttons !== 1) { + return; + } + + if (currentAvailabilityTime === null) { + return; + } + + if (currentAvailabilityTime !== null && selectingAvailabilityForDay !== null && Math.abs(selectingAvailabilityForDay.forDate.diff(time, "day")) >= 1) { + clearAvailabilityTimeSelection(); + return; + } + + let currentFrom = currentAvailabilityTime.fromTime; + let currentTo = currentAvailabilityTime.toTime; + + if (time.isBefore(currentFrom)) { + setAvailabilityTime({ + fromTime: time, + toTime: currentTo + }) + + return; + } + + if (time.isAfter(currentTo)) { + setAvailabilityTime({ + fromTime: currentFrom, + toTime: time + }) + + return; + } + } + + function currentAvailabilityTimeSelectionIncludes(time: Dayjs): boolean { + if (currentAvailabilityTime === null) { + return false; + } + + if ((time.isAfter(currentAvailabilityTime.fromTime) && time.isBefore(currentAvailabilityTime.toTime)) || (time.isSame(currentAvailabilityTime.toTime) || time.isSame(currentAvailabilityTime.fromTime))) { + return true; + } + + return false; + } + + function isTimeIncludedInAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { + return day.availableTimes.some(t => t.fromTime.isBefore(time) && t.toTime.isAfter(time)); + } + + function isTimeBeginningOfAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { + return day.availableTimes.some(t => t.fromTime.isSame(time)); + } + + function isTimeEndingOfAnyAvailabilityPeriod(day: AvailabilityDay, time: Dayjs): boolean { + return day.availableTimes.some(t => t.toTime.isSame(time)); + } + + function generateDay(day: AvailabilityDay) { + + const HOVER_COLOR: String = "#004455"; + const HOUR_LIGHT_COLOR: String = "#002233"; + const HOUR_DARK_COLOR: String = "#003344"; + const HOUR_BORDER_COLOR: String = "#777"; + const ACTIVE_COLOR: String = "#223300"; + const CURRENTLY_SELECTED_COLOR: String = "#112200"; + const HOUR_TEXT_COLOR: String = "#ddd"; + const HALFHOUR_BORDER_COLOR: String = "#333"; + + let hours = [...Array(24)].map((_, i) => { + let time = day.forDate.set("hour", i).set("minute", 0).set("second", 0); + + return ( + + beginAvailabilityTimeSelection(e, day, time)} + onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)} + onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time)} + > + + { utils.formatTimeFromHourOfDay(i, 0) } + + + beginAvailabilityTimeSelection(e, day, time.set("minute", 30))} + onMouseUp={(e) => finishAvailabilityTimeSelection(e, day)} + onMouseOver={(e) => addTimeToAvailabilityTimeSelection(e, day, time.set("minute", 30))} + > + + + + ); + }) + + return ( + + clearAvailabilityTimeSelection()} + > + + { + (props.eventType === EventTypes.WEEK) && + + { day.forDate.format("dddd") } + + } + { + (props.eventType === EventTypes.DAY) && + + Any Day + + } + { + (props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) && + + { day.forDate.format("LL") } + + } + + + {hours} + + + ); + } + + return ( + + { + days.map(a => generateDay(a)) + } + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..47e7dff --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,15 @@ +import { Stack, Typography } from '@mui/material'; + +export default function Footer() { + return ( + + + Created by mvvasilev | Github + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..135217a --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,30 @@ +import { Stack, Typography, useTheme } from '@mui/material'; + +export default function Header() { + const theme = useTheme(); + + return ( + + + + findtheti.me + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/ExistingEventPage.tsx b/frontend/src/pages/ExistingEventPage.tsx index 89193b0..26310a2 100644 --- a/frontend/src/pages/ExistingEventPage.tsx +++ b/frontend/src/pages/ExistingEventPage.tsx @@ -1,11 +1,82 @@ +import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; +import { Event, createEvent } from '../types/Event'; +import Grid from '@mui/material/Unstable_Grid2' +import { Button, TextField, Typography } from "@mui/material"; +import AvailabilityPicker from "../components/AvailabilityPicker"; +import dayjs from "dayjs"; +import utils from "../utils"; export default function ExistingEventPage() { - let { pasteId } = useParams(); + let { eventId } = useParams(); - console.log(pasteId); + const [event, setEvent] = useState(createEvent()); + + useEffect(() => { + fetch(`/api/events/${eventId}`) + .then(resp => resp.json()) + .then(resp => setEvent({ + name: resp.result?.name, + description: resp.result?.description, + fromDate: dayjs.utc(resp.result?.from_date), + toDate: dayjs.utc(resp.result?.to_date), + eventType: resp.result?.event_type, + snowflakeId: resp.result?.snowflake_id, + duration: resp.result?.duration + })); + }, [eventId]); return ( -
+ + + You've been invited to... + + + { event.name } + + { + (event.description !== null) && + + { event.description } + + } + + + This event lasts for { utils.formatMinutesAsHoursMinutes(event.duration) }. When will you be available to attend? + + + + { + (event.fromDate !== null && event.toDate !== null && event.eventType !== null) && + + } + + + + + { + // event.description = e.target.value; + // setEvent({...event}); + // }} + label="Your Name" + /> + + + + + + + ); } \ No newline at end of file diff --git a/frontend/src/pages/NewEventPage.tsx b/frontend/src/pages/NewEventPage.tsx index 1ff88ea..3c4dd6c 100644 --- a/frontend/src/pages/NewEventPage.tsx +++ b/frontend/src/pages/NewEventPage.tsx @@ -1,5 +1,210 @@ +import { Alert, Button, MenuItem, Select, Slider, TextField, Typography } from '@mui/material'; +import Grid from '@mui/material/Unstable_Grid2' +import { DateTimePicker } from '@mui/x-date-pickers'; +import { useEffect, useState } from 'react'; +import { useNavigate } from "react-router-dom" +import { Event, EventTypes, createEvent } from '../types/Event'; +import utils from '../utils'; + export default function NewEventPage() { + const navigate = useNavigate(); + + const [event, setEvent] = useState(createEvent()); + const [isEventValid, setEventValid] = useState(false); + + useEffect(() => { + validateEvent(); + }, [event]) + + function validateEvent(): void { + console.log(event); + var valid: boolean = true; + + valid &&= event.name && event.name !== ""; + valid &&= event.eventType !== EventTypes.UNKNOWN || event.eventType !== null; + + if (event.eventType === EventTypes.DATE_RANGE) { + valid &&= event.fromDate !== null; + valid &&= event.toDate !== null; + } + + if (event.eventType === EventTypes.SPECIFIC_DATE) { + valid &&= event.fromDate !== null; + } + + setEventValid(valid); + } + + function saveEvent() { + fetch("/api/events", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + from_date: event.fromDate?.utc().format(), + to_date: event.toDate?.utc().format(), + name: event.name, + description: event.description, + event_type: event.eventType, + duration: event.duration + }) + }) + .then(resp => resp.json()) + .then(resp => { + navigate(resp.result.snowflake_id) + }) + } + return ( -
+ + +

Create New Event

+
+ + + + { + event.name = e.target.value; + setEvent({...event}); + }} + label="I'm organizing a(n)..." + /> + + + { + event.description = e.target.value; + setEvent({...event}); + }} + label="More details... ( Optional )" + /> + + + + Duration + + utils.formatMinutesAsHoursMinutes(val)} + marks={ + [ + { + value: 30, + label: "30m" + }, + { + value: 120, + label: "2h" + }, + { + value: 240, + label: "4h" + }, + { + value: 360, + label: "6h" + }, + { + value: 480, + label: "8h" + } + ] + } + min={30} + max={480} + value={event.duration} + onChange={(_, val) => { + event.duration = val as number; + setEvent({...event}); + }} + /> + + + + + { + event.eventType == EventTypes.SPECIFIC_DATE && + + { + event.fromDate = value ?? null; + setEvent({...event}); + }} + label="When" + /> + + } + { + event.eventType == EventTypes.DATE_RANGE && + + { + event.fromDate = value ?? null; + setEvent({...event}); + }} + label="From" + /> + + } + { + event.eventType == EventTypes.DATE_RANGE && + + { + event.toDate = value ?? null; + setEvent({...event}); + }} + label="To" + /> + + } + { + (event.eventType == EventTypes.DAY || event.eventType == EventTypes.WEEK || event.eventType == EventTypes.MONTH) && + + + Selecting the Day type will allow attendees to select their availability during an unspecified {event.eventType} + + + } + + + + + +
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/RootLayout.tsx b/frontend/src/pages/RootLayout.tsx index 708e414..01e6a7c 100644 --- a/frontend/src/pages/RootLayout.tsx +++ b/frontend/src/pages/RootLayout.tsx @@ -1,5 +1,15 @@ import { ThemeProvider } from "@emotion/react"; -import { Box, CssBaseline, createTheme } from "@mui/material"; +import { CssBaseline, Divider, Paper, createTheme } from "@mui/material"; +import Grid from '@mui/material/Unstable_Grid2' +import GithubCorner from "react-github-corner"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; +import { LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import dayjs from "dayjs"; +import * as utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); const theme = createTheme({ palette: { @@ -12,21 +22,51 @@ export default function RootLayout(props: { children: React.ReactNode }) { - + + - {props.children} - + + + +
+ + + + + + {props.children} + + + + + +