tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/components/ui/timeline.tsx b/src/components/ui/timeline.tsx
new file mode 100644
index 0000000..cca3cf5
--- /dev/null
+++ b/src/components/ui/timeline.tsx
@@ -0,0 +1,207 @@
+import { cn } from "@/lib/utils";
+import { Play } from "lucide-react";
+import React, { useEffect, useRef, useState } from "react";
+
+interface TimelineItemProps
+ extends Omit, "onClick"> {
+ time: string;
+ oldTime?: string;
+ title: string;
+ description?: string;
+ state?: "upcoming" | "ongoing" | "ended";
+ clickable?: boolean;
+ onClick?: () => void;
+ onReschedule?: (newTime: string) => void;
+}
+
+export const TimelineItem = React.forwardRef(
+ (
+ {
+ time,
+ oldTime,
+ title,
+ description,
+ state = "upcoming",
+ className,
+ clickable,
+ onClick,
+ onReschedule,
+ ...props
+ },
+ ref,
+ ) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editTime, setEditTime] = useState(time);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [isEditing]);
+
+ useEffect(() => {
+ setEditTime(time);
+ }, [time]);
+
+ const handleSave = () => {
+ const trimmedTime = editTime.trim();
+
+ if (trimmedTime !== "" && trimmedTime !== time) {
+ onReschedule?.(trimmedTime);
+ }
+
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setEditTime(time);
+ setIsEditing(false);
+ };
+
+ return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: only organizers can click so accessibility is not a concern
+ {
+ if (!isEditing && onClick) {
+ onClick();
+ }
+ }
+ : undefined
+ }
+ onKeyDown={
+ clickable
+ ? (e) => {
+ if (
+ !isEditing &&
+ onClick &&
+ (e.key === "Enter" || e.key === " ")
+ ) {
+ e.preventDefault();
+ onClick();
+ }
+ }
+ : undefined
+ }
+ role={clickable ? "button" : undefined}
+ tabIndex={clickable ? 0 : undefined}
+ {...props}
+ >
+
+
+
+
+
+
+
+
+ {isEditing ? (
+ setEditTime(e.target.value)}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") {
+ handleSave();
+ } else if (e.key === "Escape") {
+ handleCancel();
+ }
+ }}
+ className="bg-transparent border-b border-primary focus:outline-none w-20 mr-auto"
+ />
+ ) : (
+ <>
+ {oldTime && (
+
+ {oldTime}
+
+ )}
+ {time}
+ >
+ )}
+ {clickable && !isEditing && (
+
+ )}
+ {isEditing && (
+
+
+
+
+ )}
+
+ {title}
+ {description && (
+ {description}
+ )}
+
+
+ );
+ },
+);
+TimelineItem.displayName = "TimelineItem";
+
+export function Timeline({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..715bf76
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/globals.css b/src/globals.css
new file mode 100644
index 0000000..809c59d
--- /dev/null
+++ b/src/globals.css
@@ -0,0 +1,160 @@
+@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Splash&display=swap');
+
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+@import "tw-animate-css";
+
+
+@font-face {
+ font-family: Syne Mono;
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("assets/fonts/Syne-Mono.ttf") format("truetype");
+}
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --font-geist: 'Geist', sans-serif;
+ --font-playfair: 'Playfair Display', serif;
+ --font-synemono: 'Syne Mono', monospace;
+ --font-splash: 'Splash', cursive;
+
+ --animate-caret-blink-reverse: caret-blink-reverse 0.75s ease-out infinite;
+}
+
+@keyframes caret-blink-reverse {
+ 0%,70%,100% { opacity: 0; }
+ 20%,50% { opacity: 1; }
+}
+
+:root {
+ --radius: 0.65rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-geist);
+ }
+
+ * {
+ @apply border-border;
+ }
+
+ .firebase-emulator-warning {
+ display: none !important;
+ }
+
+ /* html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ } */
+}
+
diff --git a/src/hooks/useAuthListener.ts b/src/hooks/useAuthListener.ts
new file mode 100644
index 0000000..49cc4d3
--- /dev/null
+++ b/src/hooks/useAuthListener.ts
@@ -0,0 +1,54 @@
+/**
+ * A component that uses this hook will listen for authentication state changes
+ * and update the user state in Jotai atoms accordingly.
+ *
+ * (it is used globally in App.tsx so that all components have access to auth state)
+ */
+
+import { auth } from "@/lib/firebase-config";
+import { onAuthStateChanged } from "firebase/auth";
+import { useEffect } from "react";
+import { useSetAtom } from "jotai";
+import { userAtom, firebaseUserAtom } from "@/atoms/user";
+import { firestoreService } from "@/services/firestore.service";
+
+export function useAuthListener() {
+ const setUser = useSetAtom(userAtom);
+ const setFirebaseUser = useSetAtom(firebaseUserAtom);
+
+ useEffect(() => {
+ let unsubscribeUser: (() => void) | undefined;
+
+ const unsubscribeAuth = onAuthStateChanged(auth, (firebaseUser) => {
+ setFirebaseUser(firebaseUser);
+
+ // unsubscribe from previous user listener if exists
+ if (unsubscribeUser) {
+ unsubscribeUser();
+ unsubscribeUser = undefined;
+ }
+
+ if (firebaseUser) {
+ // if logged in, listen to user document changes
+ unsubscribeUser = firestoreService.onUserSnapshot(
+ firebaseUser.uid,
+ (user) => {
+ setUser(user);
+ if (!user) {
+ console.error("User document not found in Firestore");
+ }
+ },
+ );
+ } else {
+ setUser(null);
+ }
+ });
+
+ return () => {
+ unsubscribeAuth();
+ if (unsubscribeUser) {
+ unsubscribeUser();
+ }
+ };
+ }, [setUser, setFirebaseUser]);
+}
diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..8d848aa
--- /dev/null
+++ b/src/hooks/useMediaQuery.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState } from "react";
+
+const breakpoints = {
+ sm: "640px",
+ md: "768px",
+ lg: "1024px",
+ xl: "1280px",
+ "2xl": "1536px",
+} as const;
+
+type Breakpoint = keyof typeof breakpoints;
+
+export function useMediaQuery(query: string) {
+ const [value, setValue] = useState(false);
+
+ useEffect(() => {
+ function onChange(event: MediaQueryListEvent) {
+ setValue(event.matches);
+ }
+
+ const result = matchMedia(query);
+ result.addEventListener("change", onChange);
+ setValue(result.matches);
+
+ return () => result.removeEventListener("change", onChange);
+ }, [query]);
+
+ return value;
+}
+
+export function useBreakpoint(breakpoint: Breakpoint) {
+ return useMediaQuery(`(min-width: ${breakpoints[breakpoint]})`);
+}
diff --git a/src/lib/firebase-config.ts b/src/lib/firebase-config.ts
new file mode 100644
index 0000000..bfd98df
--- /dev/null
+++ b/src/lib/firebase-config.ts
@@ -0,0 +1,32 @@
+// Import the functions you need from the SDKs you need
+import { initializeApp } from "firebase/app";
+import { connectFirestoreEmulator, getFirestore } from "firebase/firestore";
+import { connectFunctionsEmulator, getFunctions } from "firebase/functions";
+import { connectAuthEmulator, getAuth } from "firebase/auth";
+import { connectStorageEmulator, getStorage } from "firebase/storage";
+
+const firebaseConfig = {
+ apiKey: "AIzaSyDw7jjs3fAbi6pxiOO4_omWefLTAjf72lw",
+ authDomain: "hackncsu-today.firebaseapp.com",
+ projectId: "hackncsu-today",
+ storageBucket: "hackncsu-today.firebasestorage.app",
+ messagingSenderId: "638064491024",
+ appId: "1:638064491024:web:f32d36e7170d5a39e9a895",
+ measurementId: "G-MMCWYB3KX0",
+};
+
+// Initialize Firebase
+const app = initializeApp(firebaseConfig);
+export const firestore = getFirestore(app);
+export const functions = getFunctions(app);
+export const auth = getAuth(app);
+export const storage = getStorage(app);
+
+if (import.meta.env.DEV) {
+ console.log("Firebase initialized in development mode");
+
+ connectAuthEmulator(auth, "http://localhost:9099");
+ connectFirestoreEmulator(firestore, "localhost", 5500);
+ connectFunctionsEmulator(functions, "localhost", 5001);
+ connectStorageEmulator(storage, "localhost", 9199);
+}
\ No newline at end of file
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..d3019fe
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './globals.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx
new file mode 100644
index 0000000..fc5e8e6
--- /dev/null
+++ b/src/pages/Auth.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
+import { authService } from "@/services/auth.service";
+
+export default function Auth() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const [errorCode, setErrorCode] = useState("");
+
+ useEffect(() => {
+ const token = searchParams.get("token");
+ const error = searchParams.get("error");
+
+ if (token) {
+ authService.login(token)
+ .then(() => {
+ navigate("/");
+ })
+ .catch((err) => {
+ console.error("Auth error:", err);
+ setErrorCode("auth_error");
+ });
+ } else if (error) {
+ console.error("Authentication failed");
+ setErrorCode(error);
+ } else {
+ setErrorCode("no_token");
+ }
+ }, [navigate, searchParams]);
+
+ function getErrorMessage(code: string) {
+ switch (code) {
+ case "invalid_token":
+ return "Invalid authorization token. Please try logging in again.";
+ case "participant_not_found":
+ return "This Discord account is not associated with a registered participant. Let a staff member know if you think this is a mistake.";
+ case "not_checked_in":
+ return "You're a participant but it seems you haven't checked in yet! Please check in at the registration desk or let a staff member know if you think this is a mistake.";
+ case "missing_info":
+ return "Participant information is incomplete. Please contact a staff member.";
+ case "auth_error":
+ return "An unexpected error occurred while logging in. Please try again.";
+ case "no_token":
+ return "No authentication token provided.";
+ default:
+ return "An unknown error occurred.";
+ }
+ }
+
+ return (
+
+
+ {errorCode ? "Authentication Failed" : "One moment please"}
+
+ {errorCode ? (
+
+ {getErrorMessage(errorCode)}
+
+
+
+ ) : (
+
+ If you are not redirected automatically, please click{" "}
+
+ here
+
+ .
+
+ )}
+
+ );
+}
diff --git a/src/pages/Home/Main/AnnouncementsView/AnnouncementCard.tsx b/src/pages/Home/Main/AnnouncementsView/AnnouncementCard.tsx
new file mode 100644
index 0000000..56e3547
--- /dev/null
+++ b/src/pages/Home/Main/AnnouncementsView/AnnouncementCard.tsx
@@ -0,0 +1,38 @@
+import { cn } from "@/lib/utils";
+
+interface AnnouncementCardProps {
+ content: string;
+ timestamp: string;
+ highlight?: boolean;
+}
+
+export default function AnnouncementCard({
+ content,
+ timestamp,
+ highlight: recent,
+}: AnnouncementCardProps) {
+ return (
+
+
+ {content}
+
+
+ {timestamp}
+
+
+ );
+}
diff --git a/src/pages/Home/Main/AnnouncementsView/index.tsx b/src/pages/Home/Main/AnnouncementsView/index.tsx
new file mode 100644
index 0000000..3806cff
--- /dev/null
+++ b/src/pages/Home/Main/AnnouncementsView/index.tsx
@@ -0,0 +1,141 @@
+import { Button } from "@/components/ui/button";
+import FeedItem from "../FeedItem";
+import { Maximize2 } from "lucide-react";
+import AnnouncementCard from "./AnnouncementCard";
+import {
+ announcementsAtom,
+ deleteAnnouncementAtom,
+} from "@/atoms/event/announcements";
+import { useAtomValue, useSetAtom } from "jotai";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ differenceInCalendarDays,
+ differenceInMinutes,
+ format,
+ isToday,
+} from "date-fns";
+import { useEffect, useState } from "react";
+import { isOrganizerAtom } from "@/atoms/user";
+
+function formatAnnouncementTime(dateStr: string) {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMins = differenceInMinutes(now, date);
+
+ if (diffMins < 1) return "now";
+ if (diffMins < 10) {
+ return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
+ }
+
+ if (isToday(date)) {
+ return format(date, "h:mm a");
+ }
+
+ const diffDays = differenceInCalendarDays(now, date);
+ if (diffDays === 1) return "yesterday";
+ return `${diffDays} days ago`;
+}
+
+export default function AnnouncementsView() {
+ const isOrganizer = useAtomValue(isOrganizerAtom);
+
+ const announcements = useAtomValue(announcementsAtom);
+ const deleteAnnouncement = useSetAtom(deleteAnnouncementAtom);
+ const [, setTick] = useState(0);
+
+ useEffect(() => {
+ // update times every minute
+ const interval = setInterval(() => {
+ setTick((t) => t + 1);
+ }, 60000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+ {announcements.slice(0, 2).map((announcement, index) => (
+
+ ))}
+
+ {(announcements.length > 2 ||
+ (announcements.length > 0 && isOrganizer)) && (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Home/Main/FeedItem.tsx b/src/pages/Home/Main/FeedItem.tsx
new file mode 100644
index 0000000..c246cb0
--- /dev/null
+++ b/src/pages/Home/Main/FeedItem.tsx
@@ -0,0 +1,23 @@
+interface FeedItemProps {
+ title: string;
+ description?: string | null;
+ children?: React.ReactNode;
+}
+
+export default function FeedItem({
+ title,
+ description,
+ children,
+}: FeedItemProps) {
+ return (
+
+ {title}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/pages/Home/Main/OrganizerView/HackingDatesPicker.tsx b/src/pages/Home/Main/OrganizerView/HackingDatesPicker.tsx
new file mode 100644
index 0000000..5a93a9e
--- /dev/null
+++ b/src/pages/Home/Main/OrganizerView/HackingDatesPicker.tsx
@@ -0,0 +1,113 @@
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ChevronDownIcon } from "lucide-react";
+import { useId, useState } from "react";
+
+interface HackingDatesPickerProps {
+ endDate: Date | undefined;
+ onEndDateChange: (date: Date | undefined) => void;
+}
+
+export default function HackingDatesPicker({
+ endDate,
+ onEndDateChange,
+}: HackingDatesPickerProps) {
+ const [endOpen, setEndOpen] = useState(false);
+
+ const hackingEndDateId = useId();
+ const hackingEndTimeId = useId();
+
+ const handleDateSelect = (
+ date: Date | undefined,
+ currentDate: Date | undefined,
+ onChange: (date: Date | undefined) => void,
+ ) => {
+ if (!date) {
+ onChange(undefined);
+ return;
+ }
+
+ const newDate = new Date(date);
+ if (currentDate) {
+ newDate.setHours(currentDate.getHours());
+ newDate.setMinutes(currentDate.getMinutes());
+ newDate.setSeconds(currentDate.getSeconds());
+ } else {
+ newDate.setHours(11, 0, 0); // Default to 11:00 AM
+ }
+ onChange(newDate);
+ };
+
+ const handleTimeChange = (
+ e: React.ChangeEvent,
+ currentDate: Date | undefined,
+ onChange: (date: Date | undefined) => void,
+ ) => {
+ if (!currentDate) return;
+ const [hours, minutes, seconds] = e.target.value.split(":").map(Number);
+ const newDate = new Date(currentDate);
+ newDate.setHours(hours);
+ newDate.setMinutes(minutes);
+ newDate.setSeconds(seconds || 0);
+ onChange(newDate);
+ };
+
+ const formatTime = (date: Date | undefined) => {
+ if (!date) return "11:00:00";
+ return date.toLocaleTimeString("en-GB", { hour12: false });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ handleDateSelect(date, endDate, onEndDateChange);
+ setEndOpen(false);
+ }}
+ />
+
+
+
+ handleTimeChange(e, endDate, onEndDateChange)}
+ className="w-min bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
+ />
+
+
+
+ );
+}
diff --git a/src/pages/Home/Main/OrganizerView/ResourceEditor.tsx b/src/pages/Home/Main/OrganizerView/ResourceEditor.tsx
new file mode 100644
index 0000000..9d04b26
--- /dev/null
+++ b/src/pages/Home/Main/OrganizerView/ResourceEditor.tsx
@@ -0,0 +1,244 @@
+import {
+ addResourceAtom,
+ deleteResourceAtom,
+ resourcesAtom,
+ setResourceAtom,
+} from "@/atoms/event/resources";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import type { Resource } from "@/types/event";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Edit2, Plus, Trash2 } from "lucide-react";
+import { useId, useState } from "react";
+
+function ResourceForm({
+ initialResource,
+ onSave,
+ onCancel,
+}: {
+ initialResource?: Resource;
+ onSave: (resource: Resource) => void;
+ onCancel: () => void;
+}) {
+ const [type, setType] = useState<"link" | "text">(
+ initialResource?.type ?? "text",
+ );
+ const [label, setLabel] = useState(initialResource?.label ?? "");
+ const [url, setUrl] = useState(
+ initialResource?.type === "link" ? initialResource.url : "",
+ );
+ const [content, setContent] = useState(
+ initialResource?.type === "text" ? initialResource.content : "",
+ );
+ const [hidden, setHidden] = useState(initialResource?.hidden ?? false);
+
+ const checkboxID = useId();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (type === "link") {
+ onSave({ type: "link", label, url, hidden });
+ } else {
+ onSave({ type: "text", label, content, hidden });
+ }
+ };
+
+ const isTracks = initialResource?.label === "Tracks";
+
+ return (
+
+ );
+}
+
+export default function ResourceEditor() {
+ const resources = useAtomValue(resourcesAtom);
+ const addResource = useSetAtom(addResourceAtom);
+ const deleteResource = useSetAtom(deleteResourceAtom);
+ const setResource = useSetAtom(setResourceAtom);
+
+ const [isAddOpen, setIsAddOpen] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+
+ return (
+
+
+ Resources
+
+
+
+
+ {resources.map((resource, index) => (
+
+
+
+ {resource.label}
+ {resource.hidden && (
+
+ (Hidden)
+
+ )}
+
+
+ {resource.type === "link" ? resource.url : resource.content}
+
+
+
+
+
+
+
+
+ ))}
+ {resources.length === 0 && (
+
+ No resources added yet.
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Home/Main/OrganizerView/TrackEditor.tsx b/src/pages/Home/Main/OrganizerView/TrackEditor.tsx
new file mode 100644
index 0000000..e8ca3dc
--- /dev/null
+++ b/src/pages/Home/Main/OrganizerView/TrackEditor.tsx
@@ -0,0 +1,173 @@
+import {
+ addTrackAtom,
+ deleteTrackAtom,
+ setTrackAtom,
+ tracksAtom,
+} from "@/atoms/event/tracks";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import type { Track } from "@/types/event";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Edit2, Plus, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+function TrackForm({
+ initialTrack,
+ onSave,
+ onCancel,
+}: {
+ initialTrack?: Track;
+ onSave: (track: Track) => void;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState(initialTrack?.name ?? "");
+ const [description, setDescription] = useState(
+ initialTrack?.description ?? "",
+ );
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave({ name, description });
+ };
+
+ return (
+
+ );
+}
+
+export function TrackEditor() {
+ const tracks = useAtomValue(tracksAtom);
+ const addTrack = useSetAtom(addTrackAtom);
+ const deleteTrack = useSetAtom(deleteTrackAtom);
+ const setTrack = useSetAtom(setTrackAtom);
+
+ const [isAddOpen, setIsAddOpen] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+
+ return (
+
+
+ Tracks
+
+
+
+
+ {tracks.map((track, index) => (
+
+
+ {track.name}
+ {track.description && (
+
+ {track.description}
+
+ )}
+
+
+
+
+
+
+
+ ))}
+ {tracks.length === 0 && (
+
+ No tracks configured
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Home/Main/OrganizerView/index.tsx b/src/pages/Home/Main/OrganizerView/index.tsx
new file mode 100644
index 0000000..c476a70
--- /dev/null
+++ b/src/pages/Home/Main/OrganizerView/index.tsx
@@ -0,0 +1,260 @@
+import { Button } from "@/components/ui/button";
+import { ButtonGroup } from "@/components/ui/button-group";
+import { Input } from "@/components/ui/input";
+import { EventConfigSchema } from "@/types/event";
+import { SendIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import FeedItem from "../FeedItem";
+import HackingDatesPicker from "./HackingDatesPicker";
+import { Label } from "@/components/ui/label";
+import { useAtomValue, useSetAtom } from "jotai";
+import { eventConfigAtom, updateEventConfigAtom } from "@/atoms/event/config";
+import { useBreakpoint } from "@/hooks/useMediaQuery";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import ResourceEditor from "./ResourceEditor";
+import { TrackEditor } from "./TrackEditor";
+import { useNavigate } from "react-router-dom";
+
+export default function OrganizerView() {
+ const navigate = useNavigate();
+
+ const config = useAtomValue(eventConfigAtom);
+ const updateConfig = useSetAtom(updateEventConfigAtom);
+
+ const isDesktop = useBreakpoint("lg");
+
+ const [announcementText, setAnnouncementText] = useState("");
+
+ useEffect(() => {
+ if (config === null) {
+ // set config if none exists
+ const startTime = new Date();
+ startTime.setHours(11, 0, 0);
+ const endTime = new Date(startTime);
+ endTime.setDate(startTime.getDate() + 1);
+
+ const defaultConfig = EventConfigSchema.parse({
+ tracks: [
+ { name: "Track 1", description: "Sample elite ball track" },
+ { name: "Track 2", description: "Sample great ball track" },
+ { name: "Track 3", description: "Sample poke ball track" },
+ ],
+ hackingState: "setup",
+ hackingEndTime: endTime.toISOString(),
+ resources: [
+ {
+ type: "text",
+ label: "Rules",
+ content: `- Rule 1
+- Rule 2
+- Rule 3
+- etc.
+ `,
+ hidden: false,
+ },
+ {
+ type: "text",
+ label: "Tracks",
+ content:
+ "The Tracks resource is auto-generated based on the tracks you configure for the event.",
+ hidden: true,
+ },
+ {
+ type: "text",
+ label: "FAQs",
+ content:
+ "Event FAQs go here. You can use markdown to bold, italicize, and add links and even images!",
+ hidden: false,
+ },
+ {
+ type: "text",
+ label: "Judging Criteria",
+ content:
+ "Judging criteria go here. You can use markdown to bold, italicize, and add links and even images!",
+ hidden: false,
+ },
+ {
+ type: "text",
+ label: "Prizes",
+ content:
+ "Prizes information goes here. You can use markdown to bold, italicize, and add links and even images!",
+ hidden: false,
+ },
+ {
+ type: "text",
+ label: "Catering Menu",
+ content:
+ "",
+ hidden: false,
+ },
+ {
+ type: "link",
+ label: "Opening Slides",
+ url: "https://example.com",
+ hidden: true,
+ },
+ {
+ type: "link",
+ label: "Discord",
+ url: "https://example.com",
+ hidden: false,
+ },
+ ],
+ });
+
+ updateConfig(defaultConfig);
+
+ console.log("No event config found, initializing default config");
+ }
+ }, [config, updateConfig]);
+
+ const handlePostAnnouncement = (e: React.FormEvent) => {
+ // should also handle sending to discord webhook
+
+ e.preventDefault();
+ if (!config || !announcementText.trim()) return;
+
+ const newAnnouncement = {
+ content: announcementText,
+ timestamp: new Date().toISOString(),
+ };
+
+ updateConfig({
+ announcements: [newAnnouncement, ...config.announcements],
+ });
+ setAnnouncementText("");
+ };
+
+ if (!config) return null;
+
+ return (
+
+
+ Announcements
+
+
+
+ Management
+
+
+
+
+
+
+
+ Configuration (saved automatically)
+
+
+
+ updateConfig({
+ hackingEndTime: date?.toISOString(),
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Starting soon: tells participants that hacking will start soon.
+
+ Countdown: counts down to the start of hacking or to the end of
+ hacking, depending on the current time.
+
+ Judging: indicates that hacking has ended and judging is in progress.
+
+ Ended: thanks participants for joining
+
+
+
+ );
+}
diff --git a/src/pages/Home/Main/ResumeUploadView/index.tsx b/src/pages/Home/Main/ResumeUploadView/index.tsx
new file mode 100644
index 0000000..d814dff
--- /dev/null
+++ b/src/pages/Home/Main/ResumeUploadView/index.tsx
@@ -0,0 +1,167 @@
+import { userAtom } from "@/atoms/user";
+import { cn } from "@/lib/utils";
+import { storageService } from "@/services/storage.service";
+import { useAtomValue } from "jotai";
+import { AlertCircle, CheckCircle, FileText, Loader2 } from "lucide-react";
+import { useCallback, useState } from "react";
+import { useDropzone } from "react-dropzone";
+import FeedItem from "../FeedItem";
+
+export default function ResumeUploadView() {
+ const user = useAtomValue(userAtom);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadStatus, setUploadStatus] = useState<
+ "idle" | "success" | "error"
+ >("idle");
+ const [message, setMessage] = useState("");
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (!user) return;
+ if (acceptedFiles.length === 0) return;
+
+ const file = acceptedFiles[0];
+ setIsUploading(true);
+ setUploadStatus("idle");
+ setMessage("");
+
+ try {
+ await storageService.uploadResume(user.id, file);
+ setUploadStatus("success");
+ setMessage(`Uploaded: ${file.name}`);
+ } catch (error) {
+ console.error(error);
+ setUploadStatus("error");
+ setMessage("Failed to upload resume. Please try again.");
+ } finally {
+ setIsUploading(false);
+ }
+ },
+ [user],
+ );
+
+ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
+ onDrop,
+ accept: {
+ "application/pdf": [".pdf"],
+ },
+ maxSize: 5 * 1024 * 1024, // 5MB
+ multiple: false,
+ noClick: true,
+ onDropRejected: (fileRejections) => {
+ setUploadStatus("error");
+ const error = fileRejections[0]?.errors[0];
+ if (error?.code === "file-invalid-type") {
+ setMessage("Only PDF files are allowed.");
+ } else if (error?.code === "file-too-large") {
+ setMessage("File size must be less than 5MB.");
+ } else {
+ setMessage(error?.message || "Invalid file.");
+ }
+ },
+ });
+
+ const handleDelete = useCallback(async () => {
+ if (!user) return;
+
+ await storageService.deleteResume(user.id);
+
+ setUploadStatus("idle");
+ setMessage("Resume deleted.");
+ }, [user]);
+
+ if (!user || user.role !== "participant") return null;
+
+ return (
+
+
+
+
+
+ {isUploading ? (
+
+ ) : uploadStatus === "success" ? (
+
+ ) : uploadStatus === "error" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isUploading
+ ? "Uploading..."
+ : isDragActive
+ ? "Drop your resume here"
+ : user.resumeURL
+ ? "Thanks for submitting! Drag and drop again to update."
+ : "Just drag and drop your resume here"}
+
+
+ {message ? (
+
+ {message}
+
+ ) : (
+
+ 5 MB max file size
+
+ )}
+
+ {!isUploading && (
+
+ )}
+
+ {user.resumeURL && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/Home/Main/TeamView/ApprovedView.tsx b/src/pages/Home/Main/TeamView/ApprovedView.tsx
new file mode 100644
index 0000000..bc7c2ee
--- /dev/null
+++ b/src/pages/Home/Main/TeamView/ApprovedView.tsx
@@ -0,0 +1,134 @@
+import { firestoreService } from "@/services/firestore.service";
+import type { Team } from "@/types/team";
+import type { UserData } from "@/types/user";
+import { useEffect, useState } from "react";
+
+interface ApprovedViewProps {
+ team: Team;
+}
+
+// lowkey kinda vibecoded this one but it's okay it looks decent
+export default function ApprovedView({ team }: ApprovedViewProps) {
+ const [members, setMembers] = useState([]);
+
+ useEffect(() => {
+ const fetchMembers = async () => {
+ const memberPromises = team.memberIds.map((id) =>
+ firestoreService.fetchUser(id),
+ );
+ const fetchedMembers = await Promise.all(memberPromises);
+ setMembers(fetchedMembers.filter((m): m is UserData => m !== null));
+ };
+ fetchMembers();
+ }, [team.memberIds]);
+
+ return (
+
+
+ {/* Ticket Container */}
+
+ {/* Decorative "Jagged" / Perforated Sides */}
+
+
+
+
+ {/* Top Section: Team Name and Track */}
+
+
+ {team.name}
+
+
+ STARRING: {team.track}
+
+
+
+ {/* Divider */}
+
+
+ {/* Middle Section: Members */}
+
+ {/* Status Stamp */}
+
+ {team.status}
+
+
+
+ Admit{" "}
+ {{
+ 1: "One",
+ 2: "Two",
+ 3: "Three",
+ 4: "Four",
+ }[team.memberIds.length] || team.memberIds.length}
+
+
+ {members.map((member, index) => (
+
+ {member.role === "participant"
+ ? `${member.firstName} ${member.lastName}`
+ : member.username}
+ {member.id === team.creatorId && (
+
+ LEADER
+
+ )}
+ {index < members.length - 1 && ", "}
+
+ ))}
+
+
+
+ {/* Divider */}
+
+
+ {/* Bottom Section: Status & "Barcode" */}
+
+
+
+ FEBRUARY 14TH
+
+
+ ID: {team.id}
+
+
+
+
+ {team.id.slice(0, 4).toUpperCase()}
+
+ {/* Fake Barcode */}
+
+ {[...Array(15)].map((_, i) => (
+ 0.5 ? "2px" : "4px",
+ height: "100%",
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Home/Main/TeamView/UnapprovedView.tsx b/src/pages/Home/Main/TeamView/UnapprovedView.tsx
new file mode 100644
index 0000000..eeb49a1
--- /dev/null
+++ b/src/pages/Home/Main/TeamView/UnapprovedView.tsx
@@ -0,0 +1,15 @@
+import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
+import { BadgeCheckIcon } from "lucide-react";
+
+export default function UnapprovedView() {
+ return (
+ -
+
+
+
+
+ Thank you for submitting! Please visit an organizer with your team to complete the verification process.
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Home/Main/TeamView/UnregisteredView.tsx b/src/pages/Home/Main/TeamView/UnregisteredView.tsx
new file mode 100644
index 0000000..f3a4c10
--- /dev/null
+++ b/src/pages/Home/Main/TeamView/UnregisteredView.tsx
@@ -0,0 +1,339 @@
+import { useAtomValue } from "jotai";
+import { tracksAtom } from "@/atoms/event/tracks";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ArrowRight, PlusIcon, Trash2, User } from "lucide-react";
+import { useState, useEffect } from "react";
+import { functionsService } from "@/services/functions.service";
+import {
+ PartialParticipantSchema,
+ type PartialParticipant,
+} from "@/types/user";
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemGroup,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle,
+} from "@/components/ui/item";
+import React from "react";
+import { userAtom } from "@/atoms/user";
+
+export default function UnregisteredView() {
+ const tracks = useAtomValue(tracksAtom);
+ const user = useAtomValue(userAtom);
+
+ const [invitedMembers, setInvitedMembers] = useState (
+ [],
+ );
+
+ const members = user
+ ? [PartialParticipantSchema.parse(user), ...invitedMembers]
+ : invitedMembers;
+
+ const [selectedTrack, setSelectedTrack] = useState("");
+ const [teamName, setTeamName] = useState("");
+ const [mentoringHelp, setMentoringHelp] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ // search state
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const filteredSearchResults = searchResults.filter(
+ (u) => !members.some((m) => m.id === u.id),
+ );
+
+ useEffect(() => {
+ const searchUsers = async () => {
+ if (query.length < 2) {
+ setSearchResults([]);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const users = await functionsService.searchUsers(query);
+ setSearchResults(users.filter((u) => u.id !== user?.id));
+ } catch (error) {
+ console.error("Search failed", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const timeoutId = setTimeout(searchUsers, 300);
+ return () => clearTimeout(timeoutId);
+ }, [query, user]);
+
+ const addMember = (newMember: PartialParticipant) => {
+ if (members.length >= 4) return;
+ if (members.find((m) => m.id === newMember.id)) return;
+ setInvitedMembers([...invitedMembers, newMember]);
+ setOpen(false);
+ setQuery("");
+ };
+
+ const removeMember = (userId: string) => {
+ setInvitedMembers(invitedMembers.filter((m) => m.id !== userId));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (members.length < 2) return;
+
+ setIsSubmitting(true);
+ setSubmitError(null);
+
+ try {
+ await functionsService.registerTeam({
+ name: teamName,
+ track: selectedTrack,
+ mentoringHelp,
+ members: members.map((m) => m.id),
+ });
+ } catch (error: any) {
+ console.error("Registration failed", error);
+ setSubmitError(error.message || "Failed to register team");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/pages/Home/Main/TeamView/index.tsx b/src/pages/Home/Main/TeamView/index.tsx
new file mode 100644
index 0000000..c33b5f9
--- /dev/null
+++ b/src/pages/Home/Main/TeamView/index.tsx
@@ -0,0 +1,35 @@
+import FeedItem from "../FeedItem";
+import UnregisteredView from "./UnregisteredView";
+import { teamAtom } from "@/atoms/team";
+import { useAtomValue } from "jotai";
+import UnapprovedView from "./UnapprovedView";
+import ApprovedView from "./ApprovedView";
+
+export default function TeamView() {
+ const team = useAtomValue(teamAtom);
+
+ function buildView() {
+ if (team) {
+ if (team.status === "unverified") {
+ return ;
+ } else {
+ return ;
+ }
+ } else {
+ return ;
+ }
+ }
+
+ return (
+
+ {buildView()}
+
+ );
+}
diff --git a/src/pages/Home/Main/index.tsx b/src/pages/Home/Main/index.tsx
new file mode 100644
index 0000000..f6c690e
--- /dev/null
+++ b/src/pages/Home/Main/index.tsx
@@ -0,0 +1,36 @@
+import { useAtomValue } from "jotai";
+import FeedItem from "./FeedItem";
+import { isOrganizerAtom } from "@/atoms/user";
+import OrganizerView from "./OrganizerView";
+import TeamView from "./TeamView";
+import AnnouncementsView from "./AnnouncementsView";
+import ResumeUploadView from "./ResumeUploadView";
+
+export default function Main() {
+ const isOrganizer = useAtomValue(isOrganizerAtom);
+
+ return (
+
+ {isOrganizer && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ {!isOrganizer && (
+
+ )}
+
+ {!isOrganizer && }
+
+ {!isOrganizer && }
+
+ );
+}
diff --git a/src/pages/Home/Nav/Countdown.tsx b/src/pages/Home/Nav/Countdown.tsx
new file mode 100644
index 0000000..f4fa1bc
--- /dev/null
+++ b/src/pages/Home/Nav/Countdown.tsx
@@ -0,0 +1,21 @@
+import {
+ countdownMessageAtom,
+ countdownStringAtom,
+} from "@/atoms/event/countdown";
+import { hackingStateAtom } from "@/atoms/event/state";
+import { useAtomValue } from "jotai";
+
+export default function Countdown() {
+ const hackingState = useAtomValue(hackingStateAtom);
+ const countdownFormatted = useAtomValue(countdownStringAtom);
+ const countdownMessage = useAtomValue(countdownMessageAtom);
+
+ return (
+
+ {countdownMessage}
+ {hackingState === "countdown" && (
+ {countdownFormatted}
+ )}
+
+ );
+}
diff --git a/src/pages/Home/Nav/NavList.tsx b/src/pages/Home/Nav/NavList.tsx
new file mode 100644
index 0000000..b36fb65
--- /dev/null
+++ b/src/pages/Home/Nav/NavList.tsx
@@ -0,0 +1,77 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import NavSection from "./NavSection";
+import {
+ visibleLinkResourcesAtom,
+ visibleTextResourcesAtom,
+} from "@/atoms/event/resources";
+import { authService } from "@/services/auth.service";
+import { debugSwitchUserRoleAtom, userAtom } from "@/atoms/user";
+import { firestoreService } from "@/services/firestore.service";
+
+export default function NavList() {
+ const textResources = useAtomValue(visibleTextResourcesAtom);
+ const linkResources = useAtomValue(visibleLinkResourcesAtom);
+
+ const user = useAtomValue(userAtom);
+ const debugSwitchRole = useSetAtom(debugSwitchUserRoleAtom);
+
+ const systemItems = [
+ ...(import.meta.env.DEV && user?.role !== "organizer"
+ ? [
+ {
+ label: "(dev) View as Organizer",
+ onClick: () => debugSwitchRole("organizer"),
+ },
+ ]
+ : []),
+ ...(import.meta.env.DEV && user?.role !== "participant"
+ ? [
+ {
+ label: "(dev) View as Participant",
+ onClick: () => debugSwitchRole("participant"),
+ },
+ ]
+ : []),
+ ...(import.meta.env.DEV
+ ? [
+ {
+ label: "(dev) Create Sample Users",
+ onClick: () => firestoreService.debugCreateSampleParticipants(),
+ },
+ ]
+ : []),
+ {
+ label: "Log out",
+ onClick: () => authService.logout(),
+ },
+ {
+ label: "About",
+ onClick: () => alert("HackNC 2024 - Powered by NC State University"),
+ },
+ ];
+
+ return (
+
+ ({
+ type: "resource",
+ resource,
+ }))}
+ />
+
+ ({
+ type: "resource",
+ resource,
+ }))}
+ />
+
+ ({ type: "function", ...item }))}
+ />
+
+ );
+}
diff --git a/src/pages/Home/Nav/NavSection.tsx b/src/pages/Home/Nav/NavSection.tsx
new file mode 100644
index 0000000..cce243f
--- /dev/null
+++ b/src/pages/Home/Nav/NavSection.tsx
@@ -0,0 +1,129 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { Resource } from "@/types/event";
+import { useState } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+export type FunctionNavItem = {
+ type: "function";
+ label: string;
+ onClick: () => void;
+};
+
+export type ResourceNavItem = {
+ type: "resource";
+ resource: Resource;
+};
+
+export type NavItem = ResourceNavItem | FunctionNavItem;
+
+export interface NavSectionProps {
+ title?: string;
+ items?: NavItem[];
+}
+
+// THIS ENTIRE THING NEEDS TO BE REDONE IT IS SO UGLY
+
+export default function NavSection({
+ title = "Resources",
+ items = [],
+}: NavSectionProps) {
+ const [selectedResource, setSelectedResource] = useState(
+ null,
+ );
+
+ return (
+
+ {title}
+
+
+ {items.map((item) => {
+ const key =
+ item.type === "function" ? item.label : item.resource.label;
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+function NavItemView({
+ item,
+ onSelectResource,
+}: {
+ item: NavItem;
+ onSelectResource: (r: Resource) => void;
+}) {
+ const linkStyles =
+ "hover:text-primary transition-colors underline-offset-4 hover:underline text-left";
+
+ if (item.type === "function") {
+ return (
+
+
+
+ );
+ }
+
+ const { resource } = item;
+
+ if (resource.hidden) return null;
+
+ if (resource.type === "link") {
+ return (
+
+
+ {resource.label}
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/pages/Home/Nav/index.tsx b/src/pages/Home/Nav/index.tsx
new file mode 100644
index 0000000..0d03a35
--- /dev/null
+++ b/src/pages/Home/Nav/index.tsx
@@ -0,0 +1,54 @@
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import Countdown from "./Countdown";
+import { MenuIcon } from "lucide-react";
+import NavList from "./NavList";
+
+export default function Nav() {
+ const sidebarFooter = (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/pages/Home/Schedule.tsx b/src/pages/Home/Schedule.tsx
new file mode 100644
index 0000000..dec4ed1
--- /dev/null
+++ b/src/pages/Home/Schedule.tsx
@@ -0,0 +1,154 @@
+import {
+ rescheduleItemAtom,
+ schedulesAtom,
+ setCurrentItemAtom,
+} from "@/atoms/event/schedule";
+import { isOrganizerAtom } from "@/atoms/user";
+import { Timeline, TimelineItem } from "@/components/ui/timeline";
+import { functionsService } from "@/services/functions.service";
+import { useAtomValue, useSetAtom } from "jotai";
+import { useCallback, useEffect, useRef } from "react";
+
+export default function Schedule() {
+ const scheduleData = useAtomValue(schedulesAtom);
+ const isOrganizer = useAtomValue(isOrganizerAtom);
+ const setCurrentItem = useSetAtom(setCurrentItemAtom);
+ const rescheduleItem = useSetAtom(rescheduleItemAtom);
+
+ const containerRef = useRef(null);
+ const currentItemRef = useRef(null);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: necessary to only run when scheduleData changes
+ useEffect(() => {
+ // this use effect is ai generated btw
+
+ if (currentItemRef.current && containerRef.current) {
+ // Check if screen is lg (1024px) or larger
+ if (window.matchMedia("(min-width: 1024px)").matches) {
+ const container = containerRef.current;
+ const item = currentItemRef.current;
+
+ const containerRect = container.getBoundingClientRect();
+ const itemRect = item.getBoundingClientRect();
+
+ // Calculate the position of the item relative to the container's current scroll position
+ const relativeTop = itemRect.top - containerRect.top;
+ const currentScrollTop = container.scrollTop;
+
+ // Scroll so item is in the middle
+ container.scrollTo({
+ top:
+ currentScrollTop +
+ relativeTop -
+ container.clientHeight / 2 +
+ item.clientHeight / 2,
+ behavior: "smooth",
+ });
+ }
+ }
+ }, [scheduleData]);
+
+ const handleItemClick = useCallback(
+ (dayIdx: number, itemIdx: number) => {
+ if (isOrganizer) {
+ setCurrentItem(dayIdx, itemIdx);
+ }
+ },
+ [isOrganizer, setCurrentItem],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
new file mode 100644
index 0000000..35a086c
--- /dev/null
+++ b/src/pages/Home/index.tsx
@@ -0,0 +1,24 @@
+import Schedule from "./Schedule";
+import Nav from "./Nav";
+import Main from "./Main";
+
+export default function Home() {
+ return (
+
+
+ Hack_NCState Today
+
+ Welcome to Hack_NCState 2026! Your real-time event dashboard is here.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..c3c6c0f
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,97 @@
+import { Button } from "@/components/ui/button";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { authService } from "@/services/auth.service";
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useAtomValue } from "jotai";
+import { userAtom } from "@/atoms/user";
+
+export default function Login() {
+ const navigation = useNavigate();
+ const user = useAtomValue(userAtom);
+ const loading = user === undefined;
+
+ const handleDiscordLogin = () => {
+ authService.startOAuth();
+ };
+
+ useEffect(() => {
+ if (!loading && user) {
+ navigation("/", { replace: true });
+ }
+ }, [user, loading, navigation]);
+
+ return (
+
+
+
+ Hack_NCState Today
+
+ Hack_NCState Today - Login
+
+
+
+
+ Welcome to Hack_NCState 2026! Please check in, then log in with the
+ Discord account you used at registration.
+
+
+ Thank you for joining us this weekend. We hope you have a great time!
+
+
+
+
+
+ Team Hack_NCState
+
+
+
+ );
+}
diff --git a/src/pages/TeamManager.tsx b/src/pages/TeamManager.tsx
new file mode 100644
index 0000000..52b80a5
--- /dev/null
+++ b/src/pages/TeamManager.tsx
@@ -0,0 +1,176 @@
+import { Button } from "@/components/ui/button";
+import {
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+ Table,
+} from "@/components/ui/table";
+import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
+import { firestoreService } from "@/services/firestore.service";
+import type { Team } from "@/types/team";
+import type { UserData } from "@/types/user";
+import { TooltipTrigger } from "@radix-ui/react-tooltip";
+import { useEffect, useState, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+
+function TeamMembersList({ team }: { team: Team }) {
+ const [members, setMembers] = useState([]);
+
+ useEffect(() => {
+ const fetchMembers = async () => {
+ const memberPromises = team.memberIds.map((id) =>
+ firestoreService.fetchUser(id),
+ );
+ const fetchedMembers = await Promise.all(memberPromises);
+ setMembers(fetchedMembers.filter((m): m is UserData => m !== null));
+ };
+ fetchMembers();
+ }, [team.memberIds]);
+
+ return (
+
+ {members.map((member, index) => {
+ const isCreator = member.id === team.creatorId;
+ const name =
+ member.role === "participant"
+ ? `${member.firstName} ${member.lastName}`
+ : member.username;
+ return (
+
+
+
+ {isCreator ? {name} : name}
+ {index < members.length - 1 ? ", " : ""}
+
+
+
+ {member.username}
+ {isCreator ? " (creator)" : ""}
+
+
+ );
+ })}
+
+ );
+}
+
+export default function TeamManager() {
+ const navigate = useNavigate();
+
+ const [teams, setTeams] = useState([]);
+
+ const fetchTeams = useCallback(async () => {
+ const teams = await firestoreService.fetchAllTeams();
+ setTeams(teams);
+ }, []);
+
+ useEffect(() => {
+ fetchTeams();
+ }, [fetchTeams]);
+
+ function rowBuilder(team: Team) {
+ const isUnverified = team.status === "unverified";
+ return (
+
+ {team.name}
+ {team.track}
+
+
+
+
+ {team.mentoringHelp}
+
+ {team.status}
+
+ {team.status === "unverified" && (
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Name
+ Track
+ Members
+ Mentoring Help
+ Status
+ Actions
+
+
+
+
+ {[...teams]
+ .sort((a, b) => {
+ if (a.status === "unverified" && b.status !== "unverified")
+ return -1;
+ if (a.status !== "unverified" && b.status === "unverified")
+ return 1;
+ return 0;
+ })
+ .map((team) => rowBuilder(team))}
+
+
+
+
+ );
+}
diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts
new file mode 100644
index 0000000..9728841
--- /dev/null
+++ b/src/services/auth.service.ts
@@ -0,0 +1,27 @@
+import { auth } from "@/lib/firebase-config";
+import { signInWithCustomToken } from "firebase/auth";
+
+const clientId = "1371413608394653736";
+const baseUrl = "https://discord.com/api/oauth2/authorize";
+const redirectUri = encodeURIComponent(
+ import.meta.env.DEV
+ ? "http://127.0.0.1:5001/hackncsu-today/us-central1/oauth_callback"
+ : "https://us-central1-hackncsu-today.cloudfunctions.net/oauth_callback",
+);
+
+export const authService = {
+ startOAuth: () => {
+ window.location.href = `${baseUrl}?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=identify`;
+ },
+
+ login: async (token: string) => {
+ const credential = await signInWithCustomToken(auth, token);
+ return credential;
+ },
+
+ logout: async () => auth.signOut(),
+
+ isAuthenticated: () => auth.currentUser !== null,
+
+ getCurrentUser: () => auth.currentUser,
+};
diff --git a/src/services/firestore.service.ts b/src/services/firestore.service.ts
new file mode 100644
index 0000000..5a64c10
--- /dev/null
+++ b/src/services/firestore.service.ts
@@ -0,0 +1,187 @@
+import { firestore } from "@/lib/firebase-config";
+import { EventConfigSchema, type EventConfig } from "@/types/event";
+import { TeamSchema, type Team } from "@/types/team";
+import { UserSchema, type UserData } from "@/types/user";
+import {
+ collection,
+ deleteDoc,
+ doc,
+ getDoc,
+ getDocs,
+ onSnapshot,
+ setDoc,
+ updateDoc,
+} from "firebase/firestore";
+
+const collections = {
+ users: "users",
+ event: "event",
+ teams: "teams",
+};
+
+export const firestoreService = {
+ // this will fail if the user is a participant
+ // and they try accessing other user's data
+ fetchUser: async (userId: string) => {
+ const userDocRef = doc(firestore, collections.users, userId);
+ const userSnapshot = await getDoc(userDocRef);
+
+ if (userSnapshot.exists()) {
+ return UserSchema.parse(userSnapshot.data());
+ }
+
+ return null;
+ },
+
+ onUserSnapshot: (
+ userId: string,
+ callback: (data: UserData | null) => void,
+ ) => {
+ const userDocRef = doc(firestore, collections.users, userId);
+ return onSnapshot(userDocRef, (snapshot) => {
+ if (snapshot.exists()) {
+ const data = UserSchema.parse(snapshot.data());
+ callback(data);
+ } else {
+ callback(null);
+ }
+ });
+ },
+
+ updateUser: async (userId: string, data: Partial) => {
+ const userDocRef = doc(firestore, collections.users, userId);
+ await updateDoc(userDocRef, data);
+ },
+
+ fetchTeam: async (teamId: string) => {
+ const teamDocRef = doc(firestore, collections.teams, teamId);
+ const teamSnapshot = await getDoc(teamDocRef);
+
+ if (teamSnapshot.exists()) {
+ return TeamSchema.parse(teamSnapshot.data());
+ }
+ return null;
+ },
+
+ fetchAllTeams: async () => {
+ const teamsCollectionRef = collection(firestore, collections.teams);
+ const docs = await getDocs(teamsCollectionRef);
+
+ return docs.docs.map((doc) => TeamSchema.parse(doc.data()));
+ },
+
+ deleteTeam: async (teamId: string) => {
+ const team = await firestoreService.fetchTeam(teamId);
+
+ const teamDocRef = doc(firestore, collections.teams, teamId);
+
+ await deleteDoc(teamDocRef);
+
+ if (team) {
+ await firestoreService.updateUser(team.creatorId, { teamId: null });
+
+ if (team.status === "approved") {
+ // remove teamId from all members if approved (hence all members are associated, not just the creator)
+ for (const memberId of team.memberIds) {
+ await firestoreService.updateUser(memberId, { teamId: null });
+ }
+ }
+ }
+ },
+
+ updateTeam: async (teamId: string, data: Partial) => {
+ const teamDocRef = doc(firestore, collections.teams, teamId);
+ await updateDoc(teamDocRef, data);
+ },
+
+ onTeamSnapshot: (teamId: string, callback: (data: Team | null) => void) => {
+ const teamDocRef = doc(firestore, collections.teams, teamId);
+ return onSnapshot(teamDocRef, (snapshot) => {
+ if (snapshot.exists()) {
+ const data = TeamSchema.parse(snapshot.data());
+ callback(data);
+ } else {
+ callback(null);
+ }
+ });
+ },
+
+ onEventConfigSnapshot: (callback: (config: EventConfig | null) => void) => {
+ const eventDocRef = doc(firestore, collections.event, "main");
+
+ return onSnapshot(eventDocRef, (snapshot) => {
+ if (snapshot.exists()) {
+ const data = EventConfigSchema.parse(snapshot.data());
+ callback(data);
+ } else {
+ callback(null);
+ }
+ });
+ },
+
+ fetchEventConfig: async () => {
+ const eventDocRef = doc(firestore, collections.event, "main");
+ const eventSnapshot = await getDoc(eventDocRef);
+
+ if (eventSnapshot.exists()) {
+ return EventConfigSchema.parse(eventSnapshot.data());
+ }
+
+ return null;
+ },
+
+ updateEventConfig: async (data: Partial) => {
+ const eventDocRef = doc(firestore, collections.event, "main");
+ await setDoc(eventDocRef, data, { merge: true });
+ },
+
+ clearEventConfig: async () => {
+ const eventDocRef = doc(firestore, collections.event, "main");
+ await deleteDoc(eventDocRef);
+ },
+
+ // this only works in debug mode. useful for testing different user roles
+ debugSetUserType: async (
+ userId: string,
+ type: "organizer" | "participant",
+ ) => {
+ if (import.meta.env.DEV) {
+ const userDocRef = doc(firestore, collections.users, userId);
+ const userSnapshot = await getDoc(userDocRef);
+
+ if (userSnapshot.exists()) {
+ const updatedData = {
+ role: type,
+ };
+ await updateDoc(userDocRef, updatedData);
+ }
+ }
+ },
+ // for creating example users in debug mode
+ debugCreateSampleParticipants: async () => {
+ if (import.meta.env.DEV) {
+ const sampleUsers = Array.from({ length: 10 }).map((_, i) => ({
+ id: `sample-user-${i}`,
+ username: `sampleuser${i}`,
+ role: "participant" as const,
+ email: `sampleuser${i}@example.com`,
+ firstName: `Sample`,
+ lastName: `User ${i}`,
+ phone: "123-456-7890",
+ shirtSize: "M",
+ dietaryRestrictions: "None",
+ rfidUUID: `rfid-${i}`,
+ attendedEvents: [],
+ hadFirstLunch: false,
+ hadSecondLunch: false,
+ hadBreakfast: false,
+ hadDinner: false,
+ }));
+
+ for (const user of sampleUsers) {
+ const userDocRef = doc(firestore, collections.users, user.id);
+ await setDoc(userDocRef, user);
+ }
+ }
+ },
+};
diff --git a/src/services/functions.service.ts b/src/services/functions.service.ts
new file mode 100644
index 0000000..c2bd4a9
--- /dev/null
+++ b/src/services/functions.service.ts
@@ -0,0 +1,34 @@
+import { httpsCallable } from "firebase/functions";
+import { functions as fn } from "@/lib/firebase-config";
+import z from "zod";
+import { PartialParticipantSchema } from "@/types/user";
+
+const functions = {
+ searchUsers: "search_users",
+ registerTeam: "submit_team_registration",
+ loadSchedule: "load_schedule", // this one is organizer only
+};
+
+const SearchUsersResponseSchema = z.array(PartialParticipantSchema);
+
+export const functionsService = {
+ searchUsers: async (query: string) => {
+ const func = httpsCallable(fn, functions.searchUsers);
+
+ const result = await func({ query });
+ return SearchUsersResponseSchema.parse(result.data);
+ },
+ registerTeam: async (teamData: {
+ name: string;
+ track: string;
+ mentoringHelp: string;
+ members: string[];
+ }) => {
+ const func = httpsCallable(fn, functions.registerTeam);
+ await func(teamData);
+ },
+ loadSchedule: async () => {
+ const func = httpsCallable(fn, functions.loadSchedule);
+ await func();
+ }
+};
diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts
new file mode 100644
index 0000000..96e9f8d
--- /dev/null
+++ b/src/services/storage.service.ts
@@ -0,0 +1,36 @@
+import { storage } from "@/lib/firebase-config";
+import { deleteObject, getDownloadURL, ref, uploadBytes } from "firebase/storage";
+import { firestoreService } from "./firestore.service";
+
+export const storageService = {
+ uploadResume: async (userId: string, file: File) => {
+ if (file.type !== "application/pdf") {
+ throw new Error("Only PDF files are allowed.");
+ }
+
+ // we shall replace any existing resume
+ const storageRef = ref(storage, `resumes/${userId}/resume.pdf`);
+
+ const snapshot = await uploadBytes(storageRef, file, {
+ contentType: file.type,
+ });
+
+ const url = await getDownloadURL(snapshot.ref);
+
+ await firestoreService.updateUser(userId, {
+ resumeURL: url,
+ });
+
+ return url;
+ },
+
+ deleteResume: async (userId: string) => {
+ const storageRef = ref(storage, `resumes/${userId}/resume.pdf`);
+
+ await deleteObject(storageRef);
+
+ await firestoreService.updateUser(userId, {
+ resumeURL: null,
+ });
+ },
+};
diff --git a/src/types/event.ts b/src/types/event.ts
new file mode 100644
index 0000000..c556605
--- /dev/null
+++ b/src/types/event.ts
@@ -0,0 +1,64 @@
+/**
+ * Event configuration types (things like schedules, announcements, resources, etc.)
+ */
+
+import { z } from "zod";
+
+export const ScheduleItemSchema = z.object({
+ title: z.string(),
+ description: z.string().optional(),
+ time: z.string(),
+ oldTime: z.string().optional(),
+ state: z.enum(["upcoming", "ongoing", "ended"]),
+});
+
+export const ScheduleSchema = z.object({
+ title: z.string(),
+ items: z.array(ScheduleItemSchema),
+});
+
+export const AnnouncementSchema = z.object({
+ content: z.string(),
+ timestamp: z.iso.datetime(),
+});
+
+export const BaseResourceSchema = z.object({
+ label: z.string(),
+ hidden: z.boolean().default(false),
+});
+
+export const LinkResourceSchema = BaseResourceSchema.extend({
+ type: z.literal("link"),
+ url: z.url(),
+});
+
+export const TextResourceSchema = BaseResourceSchema.extend({
+ type: z.literal("text"),
+ content: z.string(),
+});
+
+export const ResourceSchema = z.discriminatedUnion("type", [
+ LinkResourceSchema,
+ TextResourceSchema,
+]);
+
+export const TrackSchema = z.object({
+ name: z.string(),
+ description: z.string().optional(),
+});
+
+export const EventConfigSchema = z.object({
+ tracks: z.array(TrackSchema).default([]),
+ hackingState: z.enum(["setup", "countdown", "judging", "ended"]),
+ hackingEndTime: z.iso.datetime(),
+ schedules: z.array(ScheduleSchema).default([]),
+ announcements: z.array(AnnouncementSchema).default([]),
+ resources: z.array(ResourceSchema).default([]),
+});
+
+export type Track = z.infer;
+export type ScheduleItem = z.infer;
+export type Schedule = z.infer;
+export type Announcement = z.infer;
+export type Resource = z.infer;
+export type EventConfig = z.infer;
diff --git a/src/types/team.ts b/src/types/team.ts
new file mode 100644
index 0000000..6a2dd4a
--- /dev/null
+++ b/src/types/team.ts
@@ -0,0 +1,13 @@
+import z from "zod";
+
+export const TeamSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ track: z.string(),
+ creatorId: z.string(),
+ mentoringHelp: z.string(),
+ memberIds: z.array(z.string()),
+ status: z.enum(["unverified", "approved", "rejected"]),
+});
+
+export type Team = z.infer;
\ No newline at end of file
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 0000000..71206de
--- /dev/null
+++ b/src/types/user.ts
@@ -0,0 +1,48 @@
+/**
+ * User types (organizer and participant)
+ */
+
+import { z } from "zod";
+
+const BaseUserSchema = z.object({
+ id: z.string(),
+ username: z.string(),
+ attrs: z.array(z.string()).optional(),
+});
+
+export const OrganizerSchema = BaseUserSchema.extend({
+ role: z.literal("organizer"),
+});
+
+export const ParticipantSchema = BaseUserSchema.extend({
+ role: z.literal("participant"),
+
+ email: z.string(),
+ firstName: z.string(),
+ lastName: z.string(),
+ phone: z.string(),
+ shirtSize: z.string(),
+ dietaryRestrictions: z.string(),
+ rfidUUID: z.string(),
+
+ teamId: z.string().nullable().optional(),
+ attendedEvents: z.array(z.string()),
+
+ resumeURL: z.string().nullable().optional(),
+});
+
+export const UserSchema = z.discriminatedUnion("role", [
+ OrganizerSchema,
+ ParticipantSchema,
+]);
+
+/** The partial participant is the data of other users that participants can see */
+export const PartialParticipantSchema = ParticipantSchema.pick({
+ id: true,
+ username: true,
+});
+
+export type PartialParticipant = z.infer;
+export type Organizer = z.infer;
+export type Participant = z.infer;
+export type UserData = z.infer;
diff --git a/storage.rules b/storage.rules
new file mode 100644
index 0000000..f922873
--- /dev/null
+++ b/storage.rules
@@ -0,0 +1,25 @@
+rules_version = '2';
+
+service firebase.storage {
+ match /b/{bucket}/o {
+ // Helper function to check for organizer role
+ function isOrganizer() {
+ return request.auth.token.isOrganizer == true;
+ }
+
+ // Allow users to upload their own resumes
+ // Path: /resumes/{userId}/filename.pdf
+ match /resumes/{userId}/{fileName} {
+ allow read: if request.auth != null && (request.auth.uid == userId);
+ allow write: if request.auth != null
+ && request.auth.uid == userId
+ && request.resource.size < 5 * 1024 * 1024 // Max 5MB
+ && request.resource.contentType == 'application/pdf'; // Only PDF
+ }
+
+ match /{allPaths=**} {
+ // Organizers have full admin access to EVERYTHING
+ allow read, write: if isOrganizer();
+ }
+ }
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..b37e868
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..856b926
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..9937693
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import path from "node:path";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 8080,
+ strictPort: true,
+ },
+});
|