diff --git a/apps/site/src/app/(home)/page.tsx b/apps/site/src/app/(home)/page.tsx index 6b731b05..10c7cf66 100644 --- a/apps/site/src/app/(home)/page.tsx +++ b/apps/site/src/app/(home)/page.tsx @@ -7,12 +7,14 @@ import FAQ from "./sections/FAQ"; import Clubs from "./sections/Clubs/Clubs"; import styles from "./page.module.scss"; +import Countdown from "./sections/Countdown"; const Home = () => { return (
+ diff --git a/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss b/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss new file mode 100644 index 00000000..9f033ed6 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss @@ -0,0 +1,133 @@ +@use "bootstrap-utils" as bootstrap; +@use "zothacks-theme" as theme; + +.paraboloid { + position: relative; + width: 100%; +} + +.countdownWrapper { + display: flex; + align-items: center; + flex-direction: column; + position: relative; +} + +.countdownText { + top: 45px; + left: 20px; + position: absolute; + min-width: 150px; + transform: translateX(-50%); + text-align: center; +} + +.countdownMaterial { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +@media only screen and (max-width: 400px) { + .clockPos { + margin-top: 45%; + } + .preCountdown { + margin-top: 50%; + } + .endText { + margin-top: 50%; + } +} + +@media only screen and (min-width: 401px) and (max-width: 600px) { + .clockPos { + margin-top: 30%; + } + .preCountdown { + margin-top: 40%; + } + .endText { + margin-top: 40%; + } +} + +@media only screen and (min-width: 601px) and (max-width: 1000px) { + .clockPos { + margin-top: 20%; + } + .preCountdown { + margin-top: 20%; + } + .endText { + margin-top: 20%; + } +} + +@media only screen and (min-width: 1001px) { + .preCountdown { + margin-top: 15%; + } + .endText { + margin-top: 10%; + } +} + +.clockPos { + width: 100%; + display: flex; + justify-content: center; +} + +.boat { + position: absolute; + transform: translate(-50%, 40%); +} + +.outerCircle { + background-color: theme.$white; + border: 5px solid #1a1840; + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + bottom: 0; + position: absolute; + bottom: -20px; +} + +.innerCircle { + background-color: #bd5a5a; + width: 30px; + height: 30px; + border-radius: 50%; +} + +.countdownTextTop { + position: absolute; + top: -170px; + width: 150px; + height: 170px; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + p { + margin: 0; + padding: 0; + } +} + +.progressContainer { + height: 200px; + width: 60%; +} diff --git a/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx b/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx new file mode 100644 index 00000000..45c25d82 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx @@ -0,0 +1,220 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; + +import useWindow from "./useWindow"; +import CountdownClock from "./CountdownClock"; + +import bg_map from "@/assets/images/maps/countdown.svg"; +import boat from "@/assets/icons/boat.svg"; + +import styles from "./Countdown.module.scss"; + +interface CountdownProps { + schedule: { + title: string; + location?: string | undefined; + virtual?: string | undefined; + startTime: Date; + endTime: Date; + organization?: string | undefined; + hosts?: string[] | undefined; + description: any; + }[]; +} + +const Countdown: React.FC = ({ schedule }) => { + const hackStartTime = new Date("2024-11-02T10:00:00"); // TBD, zothacks start time + const hackEndTime = new Date("2024-11-02T22:00:00"); // TBD, zothacks end time + + const [curTime, setCurTime] = useState(new Date()); + + useEffect(() => { + const i = setInterval(() => { + setCurTime(new Date()); + }, 1000); + + return () => clearInterval(i); + }, []); + + const ended = schedule.filter((el) => el.endTime >= curTime); + + const before = + ended.length > 0 + ? ended[0] + : { + title: "That's a wrap~", + location: "", + startTime: new Date(0), + endTime: new Date(0), + }; + + const after = + ended.length > 1 + ? ended[1] + : { + title: "That's a wrap~", + location: "", + startTime: new Date(0), + endTime: new Date(0), + }; + + const percentageCrossed = + before.endTime.getTime() > 0 + ? curTime.getTime() < before.startTime.getTime() + ? 0 + : 100 - + ((before.endTime.getTime() - curTime.getTime()) / + (before.endTime.getTime() - before.startTime.getTime())) * + 100 + : 100; + + const [w] = useWindow(); + + const totalLines = Math.floor(w / 66) > 7 ? Math.floor(w / 66) : 7; + + const totals = Array(totalLines + 1).fill(0); + + function returnPosition(num: number) { + // this is the parabola -(0.2x - 1.5)^2 + 2.25 + let step = (20 / totalLines) * num; + let prop_y = (-((0.15 * step - 1.5) * (0.15 * step - 1.5)) + 2.25) / 2.25; + return [`${step * 5}%`, `${prop_y * 100}%`]; + } + + function returnRotation(num: number) { + // this is the derivative of the aforementioned parabola turned into degrees of rotation + let step = (15 / totalLines) * num; + return Math.atan(-(2 * step - 15) / 25); + } + + if (curTime > hackEndTime) { + return ( +
+ background map for countdown +
+

Hacking has ended!

+
+
+ ); + } + + return ( +
+ background map for countdown + {w > 0 && ( +
+ {curTime >= hackStartTime ? ( +
+
+ {totals.map((_, i) => ( +
percentageCrossed / 100 ? "#DB9F42" : "#78cae3"}`, + width: "18px", + height: "5px", + borderRadius: "6px", + transform: `rotate(${-((returnRotation(i) * 180) / Math.PI)}deg)`, + }} + >
+ ))} +
+
+ {before.location && w <= 800 ? ( +

{before.location}

+ ) : null} + {before.startTime.getTime() && w <= 800 ? ( +

{`${before.startTime.getHours() % 12 == 0 ? 12 : before.startTime.getHours() % 12}${before.startTime.getHours() == 11 ? " am" : ""}-${before.endTime.getHours() % 12 == 0 ? 12 : before.endTime.getHours() % 12} ${before.endTime.getHours() < 12 ? "am" : "pm"}`}

+ ) : null} +
+
+
+
{before.title}
+ {before.location && w > 800 ? ( +

{before.location}

+ ) : null} + {before.startTime.getTime() && w > 800 ? ( +

{`${before.startTime.getHours() % 12 == 0 ? 12 : before.startTime.getHours() % 12}${before.startTime.getHours() == 11 ? " am" : ""}-${before.endTime.getHours() % 12 == 0 ? 12 : before.endTime.getHours() % 12} ${before.endTime.getHours() < 12 ? "am" : "pm"}`}

+ ) : null} +
+
+ +
+
+ {after.location && w <= 800 ? ( +

{after.location}

+ ) : null} + {after.startTime.getTime() && w <= 800 ? ( +

{`${after.startTime.getHours() % 12 == 0 ? 12 : after.startTime.getHours() % 12}${after.startTime.getHours() == 11 ? " am" : ""}-${after.endTime.getHours() % 12 == 0 ? 12 : after.endTime.getHours() % 12} ${after.endTime.getHours() < 12 ? "am" : "pm"}`}

+ ) : null} +
+
+
+
{after.title}
+ {after.location && w > 800 ?

{after.location}

: null} + {after.startTime.getTime() && w > 800 ? ( +

{`${after.startTime.getHours() % 12 == 0 ? 12 : after.startTime.getHours() % 12}${after.startTime.getHours() == 11 ? " am" : ""}-${after.endTime.getHours() % 12 == 0 ? 12 : after.endTime.getHours() % 12} ${after.endTime.getHours() < 12 ? "am" : "pm"}`}

+ ) : null} +
+
+
+ boat +
+
+
+ hackStartTime ? hackEndTime : hackStartTime + } + isHackingStarted={curTime >= hackStartTime} + /> +
+
+ ) : ( +
+ hackStartTime ? hackEndTime : hackStartTime + } + isHackingStarted={curTime >= hackStartTime} + /> +
+ )} +
+ )} +
+ ); +}; + +export default Countdown; diff --git a/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss new file mode 100644 index 00000000..ae02935a --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss @@ -0,0 +1,76 @@ +@use "bootstrap-utils" as bootstrap; + +.countdown { + margin: 0; + opacity: 0; + transition: opacity 225ms cubic-bezier(0.32, 0, 0.67, 0); + + span { + text-align: center; + + @media screen and (max-width: 1000px) { + &.time { + gap: 10px 2rem; + } + } + + @media screen and (min-width: 1001px) { + &.time { + gap: 10px 3rem; + } + } + + &.time { + display: flex; + flex-wrap: wrap; + @include bootstrap.font-size(6rem); + line-height: 100%; + justify-content: center; + min-width: 260px; + + .timePortion { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 110px; + } + + .timeBlock { + display: flex; + gap: 10px; + } + + .timeText { + font-size: 22px; + width: 100%; + text-align: center; + } + + .number { + font-weight: 600; + display: inline-block; + text-align: center; + background: #efc588; + font-size: 60px; + padding: 5px; + min-width: 50px; + border-radius: 7px; + min-height: 30px; + } + + .colon { + opacity: 0.75; + } + } + + &.caption { + display: block; + @include bootstrap.font-size(2.5rem); + font-weight: bold; + } + } +} + +.loaded { + opacity: 1; +} diff --git a/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx new file mode 100644 index 00000000..6f09bd70 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx @@ -0,0 +1,118 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +import clsx from "clsx"; +import styles from "./CountdownClock.module.scss"; + +interface CountdownProps { + countdownTo: Date; + isHackingStarted: boolean; +} + +const CountdownClock: React.FC = ({ + countdownTo, + isHackingStarted, +}) => { + const [remainingSeconds, setRemainingSeconds] = useState(NaN); + + useEffect(() => { + setRemainingSeconds( + Math.max(0, (countdownTo.valueOf() - new Date().valueOf()) / 1000), + ); + const interval = setInterval(() => { + setRemainingSeconds((r) => Math.max(0, r - 1)); + }, 1000); + + return () => clearInterval(interval); + }, [countdownTo]); + + return ( +
+ + {!isHackingStarted && ( + + + {Math.floor(remainingSeconds / (60 * 60 * 24)) + .toString() + .padStart(2, "0") + .split("") + .map((el, i) => { + return ( + + {el} + + ); + })} + + Days + + )} + + + {isHackingStarted ? ( + <> + {Math.floor(remainingSeconds / (60 * 60)) + .toString() + .split("") + .map((el, i) => { + return ( + + {el} + + ); + })} + + ) : ( + <> + + {`${Math.floor((remainingSeconds / 3600) % 24) < 10 ? "0" : Math.floor(Math.floor((remainingSeconds / 3600) % 24) / 10)}`} + + + {`${(Math.floor((remainingSeconds / 3600) % 24) % 10).toString()}`} + + + )} + + Hours + + + + + {`${Math.floor((remainingSeconds / 60) % 60) < 10 ? "0" : Math.floor(((remainingSeconds / 60) % 60) / 10)}`} + + + {`${Math.floor((remainingSeconds / 60) % 60) % 10}`} + + + Minutes + + {isHackingStarted && ( + + + + {`${Math.floor(remainingSeconds % 60) < 10 ? "0" : Math.floor((remainingSeconds % 60) / 10)}`} + + + {`${Math.floor(remainingSeconds % 60) % 10}`} + + + Seconds + + )} + + + {isHackingStarted && !isNaN(remainingSeconds) + ? "Until Hacking Ends" + : "Until Hacking Begins"} + +
+ ); +}; + +export default CountdownClock; diff --git a/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx b/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx new file mode 100644 index 00000000..532b4b8e --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx @@ -0,0 +1,18 @@ +import Countdown from "./Countdown"; +import { getSchedule } from "./getSchedule"; + +export default async function CountdownSchedule() { + const schedule = (await getSchedule()).flat().map((el) => { + return { + title: el.title, + startTime: el.startTime, + endTime: el.endTime, + location: el.location, + virtual: el.virtual, + organization: el.organization, + hosts: el.hosts, + description: el.description, + }; + }); + return ; +} diff --git a/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts b/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts new file mode 100644 index 00000000..52a11c98 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { cache } from "react"; +import { client } from "@/lib/sanity/client"; +import { SanityDocument } from "@/lib/sanity/types"; +import { formatInTimeZone } from "date-fns-tz"; + +const Events = z.array( + SanityDocument.extend({ + _type: z.literal("event"), + title: z.string(), + location: z.string().optional(), + virtual: z.string().url().optional(), + startTime: z + .string() + .datetime() + .transform((time) => new Date(time)), + endTime: z + .string() + .datetime() + .transform((time) => new Date(time)), + organization: z.string().optional(), + hosts: z.array(z.string()).optional(), + description: z.array( + z.object({ + _key: z.string(), + markDefs: z.array( + z.object({ + _type: z.string(), + href: z.optional(z.string()), + _key: z.string(), + }), + ), + children: z.array( + z.object({ + text: z.string(), + _key: z.string(), + _type: z.literal("span"), + marks: z.array(z.string()), + }), + ), + _type: z.literal("block"), + style: z.literal("normal"), + }), + ), + }), +); + +export const getSchedule = cache(async () => { + const events = Events.parse( + await client.fetch("*[_type == 'event'] | order(startTime asc)"), + ); + const eventsByDay = new Map>(); + + events.forEach((event) => { + const key = formatInTimeZone( + new Date(event.startTime), + "America/Los_Angeles", + "MM/dd/yyyy", + ); + eventsByDay.set(key, [...(eventsByDay.get(key) ?? []), event]); + }); + + return Array.from(eventsByDay.values()); +}); diff --git a/apps/site/src/app/(home)/sections/Countdown/index.ts b/apps/site/src/app/(home)/sections/Countdown/index.ts new file mode 100644 index 00000000..a380a4b8 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/index.ts @@ -0,0 +1 @@ +export { default } from "./Scheduling"; diff --git a/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx b/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx new file mode 100644 index 00000000..d67fa2d9 --- /dev/null +++ b/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export default function useWindow() { + const [size, setSize] = useState([0, 0]); + useEffect(() => { + function setWindowSize() { + setSize([window.innerWidth, window.innerHeight]); + } + setWindowSize(); + window.addEventListener("resize", setWindowSize); + return () => window.removeEventListener("resize", setWindowSize); + }, []); + return size; +} diff --git a/apps/site/src/assets/icons/boat.svg b/apps/site/src/assets/icons/boat.svg new file mode 100644 index 00000000..5e52dd32 --- /dev/null +++ b/apps/site/src/assets/icons/boat.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/site/src/assets/images/maps/countdown.svg b/apps/site/src/assets/images/maps/countdown.svg new file mode 100644 index 00000000..7aa12673 --- /dev/null +++ b/apps/site/src/assets/images/maps/countdown.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + +