-
Schedule manager
+
+ Schedule manager
+
Override pitch times, add delays, and search across all projects.
diff --git a/client/app/api/db-tables/route.ts b/client/app/api/db-tables/route.ts
new file mode 100644
index 0000000..55a4280
--- /dev/null
+++ b/client/app/api/db-tables/route.ts
@@ -0,0 +1,19 @@
+import { getDatabaseTables } from "@/lib/projects";
+
+// GET /api/db-tables - lists user tables and columns in the connected Neon DB.
+export async function GET() {
+ try {
+ const tables = await getDatabaseTables();
+
+ return Response.json({
+ ok: true,
+ count: tables.length,
+ tables,
+ });
+ } catch (error) {
+ return Response.json(
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
+ { status: 500 }
+ );
+ }
+}
diff --git a/client/app/api/projects/route.ts b/client/app/api/projects/route.ts
new file mode 100644
index 0000000..ec60828
--- /dev/null
+++ b/client/app/api/projects/route.ts
@@ -0,0 +1,21 @@
+import { getProjects } from "@/lib/projects";
+
+// GET /api/projects - returns all project-like rows from Neon.
+export async function GET() {
+ try {
+ const result = await getProjects();
+
+ return Response.json({
+ ok: true,
+ count: result.projects.length,
+ sourceTable: result.sourceTable,
+ availableTables: result.availableTables,
+ projects: result.projects,
+ });
+ } catch (error) {
+ return Response.json(
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
+ { status: 500 }
+ );
+ }
+}
diff --git a/client/app/globals.css b/client/app/globals.css
index f550a83..52f88a2 100644
--- a/client/app/globals.css
+++ b/client/app/globals.css
@@ -4,6 +4,15 @@
@custom-variant dark (&:is(.dark *));
+@theme {
+ --color-neutral-color: #6b7280;
+ --color-tertiary-color: #1a1a1b;
+ --color-secondary-color: #f8f9fa;
+ --color-primary-color: #e60000;
+
+ --color-background-color: #e6e6e6;
+}
+
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -53,32 +62,32 @@
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
- --primary: oklch(0.50 0.17 215);
+ --primary: oklch(0.5 0.17 215);
--primary-foreground: oklch(0.98 0.01 220);
--secondary: oklch(0.967 0.006 220);
--secondary-foreground: oklch(0.21 0.015 215);
--muted: oklch(0.967 0.006 220);
- --muted-foreground: oklch(0.50 0.020 215);
+ --muted-foreground: oklch(0.5 0.02 215);
--accent: oklch(0.967 0.006 220);
--accent-foreground: oklch(0.21 0.015 215);
--destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.90 0.010 220);
- --input: oklch(0.90 0.010 220);
- --ring: oklch(0.50 0.17 215);
+ --border: oklch(0.9 0.01 220);
+ --input: oklch(0.9 0.01 220);
+ --ring: oklch(0.5 0.17 215);
--chart-1: oklch(0.82 0.14 200);
--chart-2: oklch(0.72 0.16 203);
--chart-3: oklch(0.62 0.17 205);
--chart-4: oklch(0.52 0.18 208);
- --chart-5: oklch(0.50 0.17 215);
+ --chart-5: oklch(0.5 0.17 215);
--radius: 0.625rem;
--sidebar: oklch(0.985 0.002 220);
--sidebar-foreground: oklch(0.147 0.004 49.25);
- --sidebar-primary: oklch(0.50 0.17 215);
+ --sidebar-primary: oklch(0.5 0.17 215);
--sidebar-primary-foreground: oklch(0.98 0.01 220);
--sidebar-accent: oklch(0.967 0.006 220);
--sidebar-accent-foreground: oklch(0.21 0.015 215);
- --sidebar-border: oklch(0.90 0.010 220);
- --sidebar-ring: oklch(0.50 0.17 215);
+ --sidebar-border: oklch(0.9 0.01 220);
+ --sidebar-ring: oklch(0.5 0.17 215);
}
.dark {
@@ -89,7 +98,7 @@
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.64 0.17 215);
- --primary-foreground: oklch(0.20 0.06 215);
+ --primary-foreground: oklch(0.2 0.06 215);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.268 0.007 34.298);
@@ -104,11 +113,11 @@
--chart-2: oklch(0.72 0.16 203);
--chart-3: oklch(0.62 0.17 205);
--chart-4: oklch(0.56 0.17 215);
- --chart-5: oklch(0.50 0.17 215);
+ --chart-5: oklch(0.5 0.17 215);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.64 0.17 215);
- --sidebar-primary-foreground: oklch(0.20 0.06 215);
+ --sidebar-primary-foreground: oklch(0.2 0.06 215);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
diff --git a/client/app/hacker/Navbar.tsx b/client/app/hacker/Navbar.tsx
new file mode 100644
index 0000000..9256504
--- /dev/null
+++ b/client/app/hacker/Navbar.tsx
@@ -0,0 +1,38 @@
+import Link from "next/link";
+
+export function Navbar() {
+ const left_navbar = [{ label: "Home", href: "/hacker" }] as const;
+
+ const right_navbar = [
+ { label: "Submission", href: "/hacker/submission" },
+ { label: "Projects", href: "/hacker/projects" },
+ { label: "Schedule", href: "/hacker/schedule" },
+ ] as const;
+
+ return (
+
+
+ {left_navbar.map((portal) => (
+
+ {portal.label}
+
+ ))}
+
+
+ {right_navbar.map((portal) => (
+
+ {portal.label}
+
+ ))}
+
+
+ );
+}
diff --git a/client/app/hacker/SideBar.tsx b/client/app/hacker/SideBar.tsx
new file mode 100644
index 0000000..6cd50b9
--- /dev/null
+++ b/client/app/hacker/SideBar.tsx
@@ -0,0 +1,37 @@
+import Link from "next/link";
+
+const sidebarLinks = [
+ { label: "Dashboard", href: "/hacker" },
+ { label: "Schedule", href: "/hacker/schedule" },
+ { label: "Food", href: "/hacker/food" },
+ { label: "Location", href: "/hacker/location" },
+ { label: "Submission", href: "/hacker/submission" },
+ { label: "Projects", href: "/hacker/projects" },
+] as const;
+
+export function SideBar() {
+ return (
+
+
+
HackCanada
+
Hacker Portal
+
+
+
+ {sidebarLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+
+
+ Need help?
+
+
+ );
+}
diff --git a/client/app/hacker/assets/hackjudge1.glb b/client/app/hacker/assets/hackjudge1.glb
new file mode 100644
index 0000000..8f50d41
Binary files /dev/null and b/client/app/hacker/assets/hackjudge1.glb differ
diff --git a/client/app/hacker/food/page.tsx b/client/app/hacker/food/page.tsx
new file mode 100644
index 0000000..1cb98cd
--- /dev/null
+++ b/client/app/hacker/food/page.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useState } from "react";
+import { Info } from "lucide-react";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+type AllergenValue = boolean | "May Contain" | null;
+
+type Meal = {
+ name: string;
+ vendor: string;
+ allergens: Record
;
+};
+
+type MealGroup = {
+ label: string;
+ meals: Meal[];
+};
+
+type DayMenu = {
+ day: string;
+ groups: MealGroup[];
+};
+
+const menus: DayMenu[] = [
+ {
+ day: "Friday",
+ groups: [
+ {
+ label: "Dinner",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: true,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: false,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ ],
+ },
+ {
+ label: "Midnight Snack",
+ meals: [
+ {
+ name: "Type of Snack",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: false,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ day: "Saturday",
+ groups: [
+ {
+ label: "Breakfast",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: null,
+ Meat: null,
+ Eggs: "May Contain",
+ Nuts: "May Contain",
+ Gluten: true,
+ },
+ },
+ ],
+ },
+ {
+ label: "Lunch",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: true,
+ Eggs: false,
+ Nuts: false,
+ Gluten: true,
+ },
+ },
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: false,
+ Eggs: false,
+ Nuts: false,
+ Gluten: true,
+ },
+ },
+ ],
+ },
+ {
+ label: "Dinner",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: true,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: false,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ day: "Sunday",
+ groups: [
+ {
+ label: "Breakfast",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: null,
+ Meat: false,
+ Eggs: "May Contain",
+ Nuts: "May Contain",
+ Gluten: true,
+ },
+ },
+ ],
+ },
+ {
+ label: "Lunch",
+ meals: [
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: true,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ {
+ name: "Food",
+ vendor: "Location",
+ allergens: {
+ Halal: true,
+ Meat: false,
+ Eggs: false,
+ Nuts: false,
+ Gluten: false,
+ },
+ },
+ ],
+ },
+ ],
+ },
+];
+
+function allergenDisplay(value: AllergenValue) {
+ if (value === true) return "Yes";
+ if (value === false) return "No";
+ return value ?? "Ask staff";
+}
+
+export default function FoodPage() {
+ const [expandedAllergens, setExpandedAllergens] = useState<
+ Record
+ >({});
+
+ function setAllergenPopupOpen(key: string, open: boolean) {
+ setExpandedAllergens((current) => ({
+ ...current,
+ [key]: open,
+ }));
+ }
+
+ return (
+
+
+
+
+ {menus.map((dayMenu) => (
+
+
+ {dayMenu.day}
+
+
+
+ {dayMenu.groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.meals.map((meal, mealIndex) => {
+ const allergenKey = `${dayMenu.day}-${group.label}-${meal.name}-${mealIndex}`;
+ const isExpanded =
+ expandedAllergens[allergenKey] ?? false;
+
+ return (
+
+
+
+
+ {meal.name}
+
+
+ {meal.vendor}
+
+
+
+ setAllergenPopupOpen(allergenKey, open)
+ }
+ >
+
+
+
+ Allergens
+
+
+
+
+
+ {meal.name} allergens
+
+
+ {meal.vendor}
+
+
+
+
+
+
+ Allergen
+ Status
+
+
+
+ {Object.entries(meal.allergens).map(
+ ([allergen, value]) => (
+
+ {allergen}
+
+ {allergenDisplay(value)}
+
+
+ ),
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/app/hacker/layout.tsx b/client/app/hacker/layout.tsx
new file mode 100644
index 0000000..e35413d
--- /dev/null
+++ b/client/app/hacker/layout.tsx
@@ -0,0 +1,14 @@
+import { SideBar } from "./SideBar";
+
+export default function HackerLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+ );
+}
diff --git a/client/app/hacker/location/LocationMap.tsx b/client/app/hacker/location/LocationMap.tsx
new file mode 100644
index 0000000..3f33755
--- /dev/null
+++ b/client/app/hacker/location/LocationMap.tsx
@@ -0,0 +1,459 @@
+"use client";
+
+import { Suspense, useMemo, useRef, useState } from "react";
+import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber";
+import {
+ Environment,
+ Html,
+ OrbitControls,
+ useGLTF,
+} from "@react-three/drei";
+import { LocateFixed, MapPin, RotateCcw, Users } from "lucide-react";
+import * as THREE from "three";
+import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
+
+import { Button } from "@/components/ui/button";
+
+type MarkerCategory = "judging" | "workspace" | "support";
+
+type RoomMarker = {
+ id: string;
+ name: string;
+ category: MarkerCategory;
+ floor: string;
+ details: string;
+ position: [number, number, number];
+ cameraPosition: [number, number, number];
+ cameraTarget: [number, number, number];
+};
+
+const modelUrl = "/hacker/hackjudge1.glb";
+const buildingName = "Main Event Building";
+
+const categoryStyles: Record<
+ MarkerCategory,
+ {
+ bg: string;
+ border: string;
+ label: string;
+ ring: string;
+ text: string;
+ }
+> = {
+ judging: {
+ bg: "#38bdf8",
+ border: "border-sky-300",
+ label: "Judging rooms",
+ ring: "bg-sky-400",
+ text: "text-sky-950",
+ },
+ workspace: {
+ bg: "#22c55e",
+ border: "border-emerald-300",
+ label: "Hacker workspaces",
+ ring: "bg-emerald-500",
+ text: "text-emerald-950",
+ },
+ support: {
+ bg: "#f59e0b",
+ border: "border-amber-300",
+ label: "Support areas",
+ ring: "bg-amber-500",
+ text: "text-amber-950",
+ },
+};
+
+const roomMarkers: RoomMarker[] = [
+ {
+ id: "judging-a",
+ name: "Judging Room A",
+ category: "judging",
+ floor: "Level 1",
+ details: "Primary judging room for early project reviews.",
+ position: [-5.4, 5.85, -9.8],
+ cameraPosition: [-1.8, 12.2, -4.1],
+ cameraTarget: [-5.4, 3.1, -9.8],
+ },
+ {
+ id: "judging-b",
+ name: "Judging Room B",
+ category: "judging",
+ floor: "Level 1",
+ details: "Secondary judging room for team presentations.",
+ position: [-18.2, 5.85, -10.2],
+ cameraPosition: [-22.5, 12.2, -4.4],
+ cameraTarget: [-18.2, 3.1, -10.2],
+ },
+ {
+ id: "workspace-east",
+ name: "Open Hacker Workspace",
+ category: "workspace",
+ floor: "Level 1",
+ details: "Open seating for teams that want a noisier build area.",
+ position: [-6.5, 5.85, 7.3],
+ cameraPosition: [-1.7, 11.6, 11.8],
+ cameraTarget: [-6.5, 3.1, 7.3],
+ },
+ {
+ id: "workspace-west",
+ name: "Quiet Hacker Workspace",
+ category: "workspace",
+ floor: "Level 1",
+ details: "Lower-noise work area for focused building and debugging.",
+ position: [-17.3, 5.85, 7.1],
+ cameraPosition: [-22.6, 11.6, 11.2],
+ cameraTarget: [-17.3, 3.1, 7.1],
+ },
+ {
+ id: "help-desk",
+ name: "Help Desk",
+ category: "support",
+ floor: "Level 1",
+ details: "Go here for organizer help, wayfinding, and event questions.",
+ position: [-11.8, 5.85, -0.8],
+ cameraPosition: [-11.8, 13.2, 7.4],
+ cameraTarget: [-11.8, 3.1, -0.8],
+ },
+];
+
+const defaultCameraPosition: [number, number, number] = [7, 18, 24];
+const defaultCameraTarget: [number, number, number] = [-11.8, 2.9, -0.9];
+
+function LocationModel() {
+ const gltf = useGLTF(modelUrl);
+
+ const scene = useMemo(() => {
+ const clonedScene = gltf.scene.clone(true);
+
+ clonedScene.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ child.castShadow = true;
+ child.receiveShadow = true;
+ }
+ });
+
+ return clonedScene;
+ }, [gltf.scene]);
+
+ return ;
+}
+
+function CameraRig({
+ controlsRef,
+ selectedMarker,
+}: {
+ controlsRef: React.RefObject;
+ selectedMarker: RoomMarker | null;
+}) {
+ const { camera } = useThree();
+
+ const cameraPosition = selectedMarker?.cameraPosition ?? defaultCameraPosition;
+ const cameraTarget = selectedMarker?.cameraTarget ?? defaultCameraTarget;
+
+ const desiredPosition = useMemo(
+ () => new THREE.Vector3(...cameraPosition),
+ [cameraPosition],
+ );
+ const desiredTarget = useMemo(
+ () => new THREE.Vector3(...cameraTarget),
+ [cameraTarget],
+ );
+
+ useFrame(() => {
+ camera.position.lerp(desiredPosition, 0.075);
+
+ if (controlsRef.current) {
+ controlsRef.current.target.lerp(desiredTarget, 0.09);
+ controlsRef.current.update();
+ }
+ });
+
+ return null;
+}
+
+function MarkerDot({
+ marker,
+ selected,
+ onSelect,
+}: {
+ marker: RoomMarker;
+ selected: boolean;
+ onSelect: (markerId: string) => void;
+}) {
+ const style = categoryStyles[marker.category];
+
+ function handleMarkerClick(event: ThreeEvent) {
+ event.stopPropagation();
+ onSelect(marker.id);
+ }
+
+ return (
+
+ {
+ document.body.style.cursor = "";
+ }}
+ onPointerOver={() => {
+ document.body.style.cursor = "pointer";
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {selected ? (
+
+
+
{marker.name}
+
+ {marker.floor}
+
+
+
+ ) : null}
+
+ );
+}
+
+function MarkerLayer({
+ selectedMarkerId,
+ onSelectMarker,
+}: {
+ selectedMarkerId: string | null;
+ onSelectMarker: (markerId: string) => void;
+}) {
+ return (
+ <>
+ {roomMarkers.map((marker) => (
+
+ ))}
+ >
+ );
+}
+
+function SceneLoading() {
+ return (
+
+
+ Loading building map...
+
+
+ );
+}
+
+function LocationCanvas({
+ selectedMarker,
+ selectedMarkerId,
+ onSelectMarker,
+ onReset,
+}: {
+ selectedMarker: RoomMarker | null;
+ selectedMarkerId: string | null;
+ onSelectMarker: (markerId: string) => void;
+ onReset: () => void;
+}) {
+ const controlsRef = useRef(null);
+
+ return (
+
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+ );
+}
+
+function CategoryLegend() {
+ const categories = Object.entries(categoryStyles) as Array<
+ [MarkerCategory, (typeof categoryStyles)[MarkerCategory]]
+ >;
+
+ return (
+
+ {categories.map(([category, style]) => (
+
+
+
+ {style.label}
+
+
+ ))}
+
+ );
+}
+
+export function LocationMap() {
+ const [selectedMarkerId, setSelectedMarkerId] = useState(null);
+ const selectedMarker =
+ roomMarkers.find((marker) => marker.id === selectedMarkerId) ?? null;
+
+ return (
+
+
+
+
+
+
setSelectedMarkerId(null)}
+ onSelectMarker={setSelectedMarkerId}
+ selectedMarker={selectedMarker}
+ selectedMarkerId={selectedMarkerId}
+ />
+
+
+
+ Interactive 3D map
+
+
+ Drag to orbit, scroll to zoom
+
+
+
+
+
+
+
+ );
+}
+
+useGLTF.preload(modelUrl);
diff --git a/client/app/hacker/location/page.tsx b/client/app/hacker/location/page.tsx
new file mode 100644
index 0000000..e665349
--- /dev/null
+++ b/client/app/hacker/location/page.tsx
@@ -0,0 +1,5 @@
+import { LocationMap } from "./LocationMap";
+
+export default function LocationPage() {
+ return ;
+}
diff --git a/client/app/hacker/page.tsx b/client/app/hacker/page.tsx
index db9c911..c139406 100644
--- a/client/app/hacker/page.tsx
+++ b/client/app/hacker/page.tsx
@@ -1,5 +1,395 @@
-import { PortalStub } from "@/components/portal-stub";
+import Link from "next/link";
+import {
+ ArrowRight,
+ BookOpen,
+ CalendarClock,
+ CheckCircle2,
+ Code2,
+ ExternalLink,
+ HelpCircle,
+ Instagram,
+ Linkedin,
+ LifeBuoy,
+ MapPin,
+ MessageCircle,
+ Send,
+ Trophy,
+ Users,
+ Utensils,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+
+const dashboardActions = [
+ {
+ title: "Schedule",
+ description: "Talks, workshops, meals, and judging blocks.",
+ href: "/hacker/schedule",
+ icon: CalendarClock,
+ },
+ {
+ title: "Food",
+ description: "Meal times, menus, allergens, and snack updates.",
+ href: "/hacker/food",
+ icon: Utensils,
+ },
+ {
+ title: "Location",
+ description: "Rooms, judging areas, help desk, and workspaces.",
+ href: "/hacker/location",
+ icon: MapPin,
+ },
+ {
+ title: "Submission",
+ description: "Project details, team members, links, and tracks.",
+ href: "/hacker/submission",
+ icon: Send,
+ },
+ {
+ title: "Projects",
+ description: "Browse submissions and check judging slots.",
+ href: "/hacker/projects",
+ icon: Code2,
+ },
+] as const;
+
+const communityLinks = [
+ {
+ label: "Discord",
+ description: "Fastest place for announcements and organizer support.",
+ href: "https://discord.gg/hackcanada",
+ icon: MessageCircle,
+ },
+ {
+ label: "Instagram",
+ description: "Photos, reminders, and event updates.",
+ href: "https://instagram.com/hackcanada",
+ icon: Instagram,
+ },
+ {
+ label: "LinkedIn",
+ description: "Sponsor, career, and post-event updates.",
+ href: "https://linkedin.com/company/hackcanada",
+ icon: Linkedin,
+ },
+] as const;
+
+const helpChannels = [
+ {
+ title: "Mentor Queue",
+ description: "Get technical help for APIs, debugging, design, or pitching.",
+ meta: "Open during hacking",
+ icon: Users,
+ },
+ {
+ title: "Organizer Desk",
+ description: "Questions about food, rooms, hardware, judging, or rules.",
+ meta: "Main lobby",
+ icon: LifeBuoy,
+ },
+ {
+ title: "Judging Prep",
+ description: "Review your demo link, GitHub repo, and project summary.",
+ meta: "Before judging",
+ icon: Trophy,
+ },
+] as const;
+
+const faqItems = [
+ {
+ question: "Where should I ask questions during the event?",
+ answer:
+ "Use Discord for quick questions, then go to the organizer desk if you need in-person help.",
+ },
+ {
+ question: "What should be ready before judging?",
+ answer:
+ "Make sure your project name, description, team members, GitHub repo, demo links, and award categories are submitted.",
+ },
+ {
+ question: "How do judging times work?",
+ answer:
+ "Your judging slot appears on the Projects page once schedule_slots has been assigned for your project.",
+ },
+ {
+ question: "Where can I find food and room information?",
+ answer:
+ "Use the Food and Location pages for menus, allergens, room markers, and workspace details.",
+ },
+] as const;
+
+const announcements = [
+ "Keep your Discord notifications on for live schedule changes.",
+ "Submit project links before judging starts so judges can review them.",
+ "Use the mentor queue early if you are blocked on setup or deployment.",
+] as const;
+
+function ActionCard({
+ title,
+ description,
+ href,
+ icon: Icon,
+}: (typeof dashboardActions)[number]) {
+ return (
+
+
+
+ );
+}
export default function HackerPage() {
- return ;
+ return (
+
+
+
+
+
+
+ Hacker portal
+
+
+ Dashboard
+
+
+ Quick access to event logistics, help channels, project
+ submission, judging details, and community links.
+
+
+
+
+
Project status
+
+
+
+ Submit details before judging.
+
+
+
+ Check your assigned slot.
+
+
+
+
+
+ Open submission
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Start Here
+
+
+ The routes hackers are most likely to need during the event.
+
+
+
+
+
+
+ {dashboardActions.map((action) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Community
+
+
+ Links for updates, photos, and follow-up.
+
+
+
+
+
+ {communityLinks.map(({ label, description, href, icon: Icon }) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+ Live Notes
+
+ Things worth keeping visible while hacking.
+
+
+
+ {announcements.map((announcement) => (
+
+
+
+
+
+ {announcement}
+
+
+ ))}
+
+
+
+
+ View judging info
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mentors & Help
+
+
+ Where to go when you need technical or event support.
+
+
+
+
+
+ {helpChannels.map(({ title, description, meta, icon: Icon }) => (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ FAQ
+
+ Common answers for submission, judging, rooms, and support.
+
+
+
+
+
+
+ {faqItems.map((item) => (
+
+
+ {item.question}
+
+
+ {item.answer}
+
+
+ ))}
+
+
+
+
+
+ );
}
diff --git a/client/app/hacker/projects/layout.tsx b/client/app/hacker/projects/layout.tsx
new file mode 100644
index 0000000..df6251c
--- /dev/null
+++ b/client/app/hacker/projects/layout.tsx
@@ -0,0 +1,7 @@
+export default function ProjectsLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return children;
+}
diff --git a/client/app/hacker/projects/page.tsx b/client/app/hacker/projects/page.tsx
new file mode 100644
index 0000000..8ecb00a
--- /dev/null
+++ b/client/app/hacker/projects/page.tsx
@@ -0,0 +1,26 @@
+import { getProjectScheduleSlots, getProjects } from "@/lib/projects";
+
+import { ProjectsHub } from "./projects-hub";
+
+export const dynamic = "force-dynamic";
+
+export default async function ProjectsPage() {
+ let result: Awaited> | null = null;
+ let scheduleSlots: Awaited> = [];
+ let errorMessage: string | null = null;
+
+ try {
+ result = await getProjects();
+ scheduleSlots = await getProjectScheduleSlots(result.availableTables);
+ } catch (error) {
+ errorMessage = error instanceof Error ? error.message : String(error);
+ }
+
+ return (
+
+ );
+}
diff --git a/client/app/hacker/projects/projects-hub.tsx b/client/app/hacker/projects/projects-hub.tsx
new file mode 100644
index 0000000..5028209
--- /dev/null
+++ b/client/app/hacker/projects/projects-hub.tsx
@@ -0,0 +1,704 @@
+"use client";
+
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import {
+ CalendarClock,
+ CheckCircle2,
+ Clock3,
+ ExternalLink,
+ Github,
+ LayoutGrid,
+ LinkIcon,
+ Search,
+ Send,
+ Trophy,
+ Users,
+ Youtube,
+} from "lucide-react";
+
+import type { Project, ProjectScheduleSlot } from "@/lib/projects";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Progress } from "@/components/ui/progress";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+type ProjectsHubProps = {
+ projects: Project[];
+ scheduleSlots: ProjectScheduleSlot[];
+ errorMessage: string | null;
+};
+
+type LinkKind = "github" | "youtube" | "demo" | "devpost";
+type DisplayScheduleSlot = {
+ id: string;
+ time: string;
+ room: string;
+ status: string;
+ durationMinutes: number;
+ project: Project;
+ source: "database" | "generated";
+};
+
+const ALL_TEAMS = "All teams";
+
+const judgingRooms = [
+ "Judging Room A",
+ "Judging Room B",
+ "Expo Table 1",
+ "Expo Table 2",
+] as const;
+
+const linkKeys: Record = {
+ github: [
+ "github",
+ "github_link",
+ "github_url",
+ "git_repo",
+ "git_repository",
+ "repo",
+ "repo_url",
+ "repository",
+ "repository_url",
+ "source_code",
+ "source_code_url",
+ ],
+ youtube: [
+ "youtube",
+ "youtube_link",
+ "youtube_url",
+ "video",
+ "video_link",
+ "video_url",
+ "demo_video",
+ "demo_video_url",
+ ],
+ demo: [
+ "demo",
+ "demo_link",
+ "demo_url",
+ "live_demo",
+ "live_demo_url",
+ "live_post_demo",
+ "project_url",
+ "website",
+ ],
+ devpost: ["devpost", "devpost_link", "devpost_url"],
+};
+
+function jsonText(value: Project["raw"][string]) {
+ if (typeof value === "string") return value.trim() || null;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ if (Array.isArray(value)) {
+ const values = value
+ .map((item) => (typeof item === "string" ? item.trim() : null))
+ .filter(Boolean);
+ return values.length > 0 ? values.join(", ") : null;
+ }
+
+ return null;
+}
+
+function rawValue(project: Project, keys: string[]) {
+ for (const key of keys) {
+ const value = jsonText(project.raw[key]);
+ if (value) return value;
+ }
+
+ return null;
+}
+
+function asUrl(value: string | null) {
+ if (!value) return null;
+ if (/^https?:\/\//i.test(value)) return value;
+ return `https://${value}`;
+}
+
+function projectLink(project: Project, kind: LinkKind) {
+ if (kind === "devpost" && project.devpost_link) {
+ return asUrl(project.devpost_link);
+ }
+
+ return asUrl(rawValue(project, linkKeys[kind]));
+}
+
+function projectDescription(project: Project) {
+ return (
+ rawValue(project, [
+ "description",
+ "full_description",
+ "project_description",
+ "summary",
+ "tagline",
+ "elevator_pitch",
+ ]) ?? "No project description has been added yet."
+ );
+}
+
+function teamLabel(project: Project) {
+ if (project.team_name) return project.team_name;
+ if (project.members.length > 0) return `${project.members.length} members`;
+ return project.name ?? "Independent submission";
+}
+
+function teamFilterLabel(project: Project) {
+ if (project.team_name) return project.team_name;
+ if (project.members.length > 0) return project.members.join(", ");
+ return project.name ?? "Independent";
+}
+
+function memberLabel(project: Project) {
+ if (project.members.length > 0) return project.members.join(", ");
+ return project.name ?? "No team members listed";
+}
+
+function initials(name: string) {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("");
+}
+
+function submittedLabel(value: string | null) {
+ if (!value) return "Submission time unavailable";
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "Submitted";
+
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(date);
+}
+
+function minutesToTime(totalMinutes: number) {
+ const hours24 = Math.floor(totalMinutes / 60) % 24;
+ const minutes = totalMinutes % 60;
+ const suffix = hours24 >= 12 ? "PM" : "AM";
+ const hour = hours24 % 12 || 12;
+
+ return `${hour}:${String(minutes).padStart(2, "0")} ${suffix}`;
+}
+
+function generatedJudgingSlot(project: Project, index: number): DisplayScheduleSlot {
+ const slotIndex = Math.floor(index / judgingRooms.length);
+ const startMinutes = 9 * 60 + slotIndex * 12;
+
+ return {
+ id: `${project.id}-${index}`,
+ time: minutesToTime(startMinutes),
+ room: judgingRooms[index % judgingRooms.length],
+ status: "preview",
+ durationMinutes: 12,
+ project,
+ source: "generated",
+ };
+}
+
+function scheduleTime(value: string, delayMinutes: number) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "TBD";
+
+ const delayedDate = new Date(date.getTime() + delayMinutes * 60_000);
+ return new Intl.DateTimeFormat(undefined, {
+ timeZone: "UTC",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(delayedDate);
+}
+
+function databaseJudgingSlot(slot: ProjectScheduleSlot): DisplayScheduleSlot {
+ return {
+ id: slot.id,
+ time: scheduleTime(slot.scheduledAt, slot.delayMinutes),
+ room: slot.room,
+ status: slot.status,
+ durationMinutes: slot.durationMinutes,
+ project: slot.project,
+ source: "database",
+ };
+}
+
+function ProjectLinkButton({
+ href,
+ icon: Icon,
+ label,
+}: {
+ href: string | null;
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+}) {
+ if (!href) {
+ return (
+
+
+ {label}
+
+ );
+ }
+
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+function ProjectCard({ project }: { project: Project }) {
+ const githubUrl = projectLink(project, "github");
+ const youtubeUrl = projectLink(project, "youtube");
+ const demoUrl = projectLink(project, "demo");
+ const devpostUrl = projectLink(project, "devpost");
+ const isReady = Boolean(githubUrl || devpostUrl || demoUrl);
+
+ return (
+
+
+
+
+ {initials(project.project_name) || "P"}
+
+
+
+ {project.project_name}
+
+
+ {teamLabel(project)}
+
+
+
+
+
+ {isReady ? "Ready" : "Needs link"}
+
+
+
+
+ {projectDescription(project)}
+
+
+
+
+
+
+
+ {memberLabel(project)}
+
+
+
+ {submittedLabel(project.submitted_at ?? project.created_at)}
+
+
+
+
+
+ );
+}
+
+function EmptyState({ message }: { message: string }) {
+ return (
+
+
{message}
+
+ Submitted projects will appear here once the database has entries.
+
+
+ );
+}
+
+export function ProjectsHub({
+ projects,
+ scheduleSlots,
+ errorMessage,
+}: ProjectsHubProps) {
+ const [activeView, setActiveView] = useState<"submissions" | "schedule">(
+ "submissions"
+ );
+ const [query, setQuery] = useState("");
+ const [selectedTeam, setSelectedTeam] = useState(ALL_TEAMS);
+
+ const allTeams = useMemo(() => {
+ const teams = new Set();
+ projects.forEach((project) => teams.add(teamFilterLabel(project)));
+
+ return [ALL_TEAMS, ...Array.from(teams).sort()];
+ }, [projects]);
+
+ const filteredProjects = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ return projects.filter((project) => {
+ const team = teamFilterLabel(project);
+ const matchesTeam = selectedTeam === ALL_TEAMS || team === selectedTeam;
+ const teamSearchText = [
+ team,
+ project.team_name,
+ project.name,
+ project.members.join(" "),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ const projectSearchText = [
+ project.project_name,
+ teamSearchText,
+ projectDescription(project),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ const searchableText =
+ activeView === "schedule" ? teamSearchText : projectSearchText;
+
+ return matchesTeam && (!normalizedQuery || searchableText.includes(normalizedQuery));
+ });
+ }, [activeView, projects, query, selectedTeam]);
+
+ const readyProjects = projects.filter((project) =>
+ Boolean(
+ projectLink(project, "github") ||
+ projectLink(project, "devpost") ||
+ projectLink(project, "demo")
+ )
+ ).length;
+ const progressValue = projects.length > 0 ? (readyProjects / projects.length) * 100 : 0;
+ const uniqueTeams = new Set(
+ projects.map((project) => teamFilterLabel(project))
+ ).size;
+ const filteredProjectIds = useMemo(
+ () => new Set(filteredProjects.map((project) => project.id)),
+ [filteredProjects]
+ );
+ const hasSavedSchedule = scheduleSlots.length > 0;
+ const judgingSlots = useMemo(() => {
+ if (hasSavedSchedule) {
+ return scheduleSlots
+ .filter((slot) => filteredProjectIds.has(slot.projectId))
+ .map(databaseJudgingSlot);
+ }
+
+ return filteredProjects.map(generatedJudgingSlot);
+ }, [filteredProjectIds, filteredProjects, hasSavedSchedule, scheduleSlots]);
+
+ return (
+
+
+
+
+
+
+ Project hub
+
+
+ Hacker Projects
+
+
+ Browse submitted projects, open demo links, and check the judging
+ schedule from one place.
+
+
+
+
+
+
+
Your submission
+
+ Submit or edit before judging starts.
+
+
+
+
+
+ Open
+
+
+
+
+
+
+
+
+
+
+ Total projects
+ {projects.length}
+
+
+
+
+ Teams represented
+ {uniqueTeams}
+
+
+
+
+ Ready for judges
+
+ {readyProjects}/{projects.length}
+
+
+
+
+
+
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+ setActiveView("submissions")}
+ className={cn(
+ "inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
+ activeView === "submissions"
+ ? "bg-white text-neutral-950 shadow-sm"
+ : "text-muted-foreground hover:text-neutral-950"
+ )}
+ >
+
+ Project submissions
+
+ setActiveView("schedule")}
+ className={cn(
+ "inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
+ activeView === "schedule"
+ ? "bg-white text-neutral-950 shadow-sm"
+ : "text-muted-foreground hover:text-neutral-950"
+ )}
+ >
+
+ Judging schedule
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder={
+ activeView === "schedule"
+ ? "Search teams or members"
+ : "Search projects or teams"
+ }
+ className="h-10 bg-white pl-9"
+ />
+
+
+ {allTeams.map((team) => (
+ setSelectedTeam(team)}
+ className="max-w-48 shrink-0"
+ >
+ {team}
+
+ ))}
+
+
+
+
+ {activeView === "submissions" ? (
+ <>
+ {projects.length === 0 && !errorMessage ? (
+
+ ) : filteredProjects.length === 0 ? (
+
+ ) : (
+
+ {filteredProjects.map((project) => (
+
+ ))}
+
+ )}
+ >
+ ) : (
+
+
+
+
+
+
+
+
+
+ Judging plan
+
+
+ {hasSavedSchedule
+ ? "Times are loaded from schedule_slots."
+ : "Preview times until schedule_slots has saved rows."}
+
+
+
+
+
+
+
+ {hasSavedSchedule ? "Next saved slot" : "Next preview block"}
+
+
+ {judgingSlots[0]?.time ?? "TBD"}
+
+
+ {judgingSlots[0]?.room ?? "Waiting for submissions"}
+
+
+
+
+
+ {hasSavedSchedule
+ ? "Schedule rows come from the database."
+ : "Preview rows follow the current team filter."}
+
+
+
+ Project links stay available from each row.
+
+
+
+
+
+
+
+
+
+
+ Hacker judging schedule
+
+
+ {judgingSlots.length} slot{judgingSlots.length === 1 ? "" : "s"} shown
+
+
+
+
+
+ {judgingSlots.length === 0 ? (
+
+
+
+ ) : (
+
+ {judgingSlots.map(({ id, time, room, status, durationMinutes, project, source }, index) => {
+ const projectHref =
+ projectLink(project, "devpost") ??
+ projectLink(project, "demo") ??
+ projectLink(project, "github");
+
+ return (
+
+
+
{time}
+
+ Slot {index + 1}
+
+
+
+
+ {room}
+
+
+ {source === "database"
+ ? `${durationMinutes} min · ${status}`
+ : "Preview"}
+
+
+
+
+ {project.project_name}
+
+
+ {teamFilterLabel(project)}
+
+
+ {projectHref ? (
+
+
+
+ View
+
+
+ ) : (
+
+
+ View
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/client/app/hacker/projects/test.tsx b/client/app/hacker/projects/test.tsx
new file mode 100644
index 0000000..b4121d9
--- /dev/null
+++ b/client/app/hacker/projects/test.tsx
@@ -0,0 +1 @@
+export { default, dynamic } from "./page";
diff --git a/client/app/hacker/schedule/layout.tsx b/client/app/hacker/schedule/layout.tsx
new file mode 100644
index 0000000..df6251c
--- /dev/null
+++ b/client/app/hacker/schedule/layout.tsx
@@ -0,0 +1,7 @@
+export default function ProjectsLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return children;
+}
diff --git a/client/app/hacker/schedule/page.tsx b/client/app/hacker/schedule/page.tsx
new file mode 100644
index 0000000..cc830f4
--- /dev/null
+++ b/client/app/hacker/schedule/page.tsx
@@ -0,0 +1,454 @@
+type EventType =
+ | "main"
+ | "sponsor"
+ | "workshop"
+ | "activity"
+ | "food"
+ | "booth"
+ | "judging"
+ | "other";
+
+type ScheduleEvent = {
+ title: string;
+ location: string;
+};
+
+type EventKey =
+ | "main"
+ | "sponsorWorkshop"
+ | "otherWorkshop"
+ | "activities1"
+ | "activities2"
+ | "food"
+ | "sponsorBooth"
+ | "judging"
+ | "other";
+
+type ScheduleRow = {
+ time: string;
+ events: Partial>;
+};
+
+type DaySchedule = {
+ day: string;
+ date: string;
+ rows: ScheduleRow[];
+};
+
+type ScheduleColumn = {
+ key: EventKey;
+ label: string;
+ type: EventType;
+};
+
+const rowHeight = 58;
+const rowGap = 8;
+const timelineStartMinutes = 0;
+const timelineEndMinutes = 24 * 60;
+const timelineStepMinutes = 30;
+
+const columns: ScheduleColumn[] = [
+ { key: "main", label: "MAIN EVENT", type: "main" },
+ { key: "sponsorWorkshop", label: "SPONSOR\nWORKSHOPS", type: "sponsor" },
+ { key: "otherWorkshop", label: "OTHER\nWORKSHOPS", type: "workshop" },
+ { key: "activities1", label: "ACTIVITIES", type: "activity" },
+ { key: "activities2", label: "ACTIVITIES", type: "activity" },
+ { key: "food", label: "FOOD", type: "food" },
+ { key: "sponsorBooth", label: "SPONSOR\nBOOTH", type: "booth" },
+ { key: "judging", label: "JUDGING", type: "judging" },
+ { key: "other", label: "OTHER", type: "other" },
+];
+
+const colorMap: Record = {
+ main: "border-violet-300 bg-violet-200 text-violet-950",
+ sponsor: "border-pink-300 bg-pink-100 text-pink-950",
+ workshop: "border-yellow-300 bg-yellow-100 text-yellow-950",
+ activity: "border-green-300 bg-green-100 text-green-950",
+ food: "border-blue-300 bg-blue-100 text-blue-950",
+ booth: "border-fuchsia-200 bg-fuchsia-100 text-fuchsia-950",
+ judging: "border-orange-300 bg-orange-100 text-orange-950",
+ other: "border-purple-300 bg-purple-200 text-purple-950",
+};
+
+const schedule: DaySchedule[] = [
+ {
+ day: "Friday",
+ date: "November 21st",
+ rows: [
+ {
+ time: "5:00 PM",
+ events: {
+ main: { title: "Check-in opens", location: "Main Lobby" },
+ sponsorBooth: { title: "Sponsor fair", location: "Atrium" },
+ },
+ },
+ {
+ time: "5:30 PM",
+ events: {
+ main: { title: "Check-in opens", location: "Main Lobby" },
+ sponsorBooth: { title: "Sponsor fair", location: "Atrium" },
+ },
+ },
+ {
+ time: "6:00 PM",
+ events: {
+ main: { title: "Check-in opens", location: "Main Lobby" },
+ other: { title: "Help desk open", location: "Info Booth" },
+ },
+ },
+ {
+ time: "7:00 PM",
+ events: {
+ main: { title: "Opening ceremony", location: "Auditorium" },
+ },
+ },
+ {
+ time: "7:30 PM",
+ events: {
+ main: { title: "Opening ceremony", location: "Auditorium" },
+ },
+ },
+ {
+ time: "8:30 PM",
+ events: {
+ food: { title: "Dinner", location: "Dining Hall" },
+ activities1: { title: "Team formation", location: "Room 101" },
+ activities2: { title: "Team formation", location: "Room 101" },
+ },
+ },
+ {
+ time: "9:00 PM",
+ events: {
+ activities1: { title: "Team formation", location: "Room 101" },
+ activities2: { title: "Team formation", location: "Room 101" },
+ },
+ },
+ {
+ time: "10:00 PM",
+ events: {
+ sponsorWorkshop: { title: "Build with APIs", location: "Room 208" },
+ otherWorkshop: { title: "Project planning sprint", location: "Room 204" },
+ },
+ },
+ ],
+ },
+ {
+ day: "Saturday",
+ date: "November 22nd",
+ rows: [
+ {
+ time: "9:00 AM",
+ events: {
+ main: { title: "Hacking continues", location: "Hacker Space" },
+ food: { title: "Breakfast", location: "Dining Hall" },
+ },
+ },
+ {
+ time: "9:30 AM",
+ events: {
+ main: { title: "Hacking continues", location: "Hacker Space" },
+ },
+ },
+ {
+ time: "10:00 AM",
+ events: {
+ main: { title: "Hacking continues", location: "Hacker Space" },
+ sponsorWorkshop: { title: "AI product workshop", location: "Room 208" },
+ otherWorkshop: { title: "Design systems crash course", location: "Room 206" },
+ },
+ },
+ {
+ time: "10:30 AM",
+ events: {
+ main: { title: "Hacking continues", location: "Hacker Space" },
+ sponsorWorkshop: { title: "AI product workshop", location: "Room 208" },
+ otherWorkshop: { title: "Design systems crash course", location: "Room 206" },
+ },
+ },
+ {
+ time: "12:00 PM",
+ events: {
+ food: { title: "Lunch", location: "Dining Hall" },
+ sponsorBooth: { title: "Sponsor booth challenge", location: "Atrium" },
+ },
+ },
+ {
+ time: "1:00 PM",
+ events: {
+ activities1: { title: "Mini games", location: "Atrium" },
+ activities2: { title: "Mini games", location: "Atrium" },
+ sponsorBooth: { title: "Sponsor booth challenge", location: "Atrium" },
+ },
+ },
+ {
+ time: "6:00 PM",
+ events: {
+ food: { title: "Dinner", location: "Dining Hall" },
+ other: { title: "Mentor office hours", location: "Help Desk" },
+ },
+ },
+ {
+ time: "11:30 PM",
+ events: {
+ main: { title: "Project submission deadline", location: "Online" },
+ },
+ },
+ ],
+ },
+ {
+ day: "Sunday",
+ date: "November 23rd",
+ rows: [
+ {
+ time: "9:00 AM",
+ events: {
+ food: { title: "Breakfast", location: "Dining Hall" },
+ judging: { title: "Judging begins", location: "Expo Floor" },
+ },
+ },
+ {
+ time: "9:30 AM",
+ events: {
+ judging: { title: "Judging begins", location: "Expo Floor" },
+ },
+ },
+ {
+ time: "11:00 AM",
+ events: {
+ judging: { title: "Final demos", location: "Auditorium" },
+ sponsorBooth: { title: "Sponsor expo", location: "Atrium" },
+ },
+ },
+ {
+ time: "12:30 PM",
+ events: {
+ food: { title: "Lunch", location: "Dining Hall" },
+ judging: { title: "Final demos", location: "Auditorium" },
+ },
+ },
+ {
+ time: "3:00 PM",
+ events: {
+ main: { title: "Closing ceremony", location: "Auditorium" },
+ },
+ },
+ ],
+ },
+];
+
+const fullTimelineRows = generateTimelineRows();
+
+function isSameEvent(a: ScheduleEvent | undefined, b: ScheduleEvent | undefined) {
+ return Boolean(a && b && a.title === b.title && a.location === b.location);
+}
+
+function shouldSkipEvent(day: DaySchedule, rowIndex: number, key: EventKey) {
+ if (rowIndex === 0) return false;
+ return isSameEvent(day.rows[rowIndex]?.events[key], day.rows[rowIndex - 1]?.events[key]);
+}
+
+function getEventHeight(day: DaySchedule, rowIndex: number, key: EventKey) {
+ const startEvent = day.rows[rowIndex]?.events[key];
+ if (!startEvent) return rowHeight;
+
+ let span = 1;
+ for (let i = rowIndex + 1; i < day.rows.length; i++) {
+ if (!isSameEvent(startEvent, day.rows[i]?.events[key])) break;
+ span += 1;
+ }
+
+ return span * rowHeight + (span - 1) * rowGap;
+}
+
+function getMergedColumns(day: DaySchedule, rowIndex: number) {
+ const row = day.rows[rowIndex];
+ const mergedColumns: Array<{
+ event: ScheduleEvent | undefined;
+ key: EventKey;
+ span: number;
+ type: EventType;
+ }> = [];
+
+ let index = 0;
+ while (index < columns.length) {
+ const column = columns[index];
+ const event = row?.events[column.key];
+ let span = 1;
+
+ while (event && index + span < columns.length) {
+ const nextColumn = columns[index + span];
+ const nextEvent = row?.events[nextColumn.key];
+ if (!isSameEvent(event, nextEvent)) break;
+ span += 1;
+ }
+
+ mergedColumns.push({
+ event,
+ key: column.key,
+ span,
+ type: column.type,
+ });
+
+ index += span;
+ }
+
+ return mergedColumns;
+}
+
+function withFullTimeline(day: DaySchedule): DaySchedule {
+ const rowsByTime = new Map(day.rows.map((row) => [row.time, row]));
+
+ return {
+ ...day,
+ rows: fullTimelineRows.map((row) => rowsByTime.get(row.time) ?? row),
+ };
+}
+
+function EventBlock({
+ day,
+ event,
+ rowIndex,
+ eventKey,
+ type,
+}: {
+ day: DaySchedule;
+ event: ScheduleEvent;
+ rowIndex: number;
+ eventKey: EventKey;
+ type: EventType;
+}) {
+ return (
+
+
+ {event.title}
+
+ {event.location ? (
+
+ {event.location}
+
+ ) : null}
+
+ );
+}
+
+function DayScheduleView({ day }: { day: DaySchedule }) {
+ const fullDay = withFullTimeline(day);
+
+ return (
+
+
+ {day.day}, {day.date}
+
+
+
+ {fullDay.rows.map((row, rowIndex) => (
+
+
+
+ {row.time}
+
+
+ {getMergedColumns(fullDay, rowIndex).map((column, columnIndex) => (
+
+ {column.event && !shouldSkipEvent(fullDay, rowIndex, column.key) ? (
+
+ ) : null}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
+
+function generateTimelineRows() {
+ const rows: ScheduleRow[] = [];
+
+ for (
+ let minutes = timelineStartMinutes;
+ minutes < timelineEndMinutes;
+ minutes += timelineStepMinutes
+ ) {
+ rows.push({
+ time: formatMinutesAsTime(minutes),
+ events: {},
+ });
+ }
+
+ return rows;
+}
+
+function formatMinutesAsTime(totalMinutes: number) {
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ const period = hours >= 12 ? "PM" : "AM";
+ const displayHours = hours % 12 || 12;
+
+ return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
+}
+
+export default function SchedulePage() {
+ return (
+
+
+
+
+
+
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+
+
+ {schedule.map((day) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/app/hacker/submission/page.tsx b/client/app/hacker/submission/page.tsx
new file mode 100644
index 0000000..399be12
--- /dev/null
+++ b/client/app/hacker/submission/page.tsx
@@ -0,0 +1,470 @@
+import {
+ CheckCircle2,
+ Code2,
+ FileText,
+ Github,
+ Info,
+ LinkIcon,
+ Send,
+ Trophy,
+ Youtube,
+} from "lucide-react";
+import { redirect } from "next/navigation";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { getSql } from "@/lib/db";
+
+const linkFields = [
+ {
+ id: "githubLink",
+ label: "GitHub Repository",
+ placeholder: "https://github.com/team/project",
+ icon: Github,
+ required: true,
+ },
+ {
+ id: "devpostLink",
+ label: "Devpost URL",
+ placeholder: "https://devpost.com/software/project",
+ icon: LinkIcon,
+ required: false,
+ },
+ {
+ id: "demoLink",
+ label: "Live Demo URL",
+ placeholder: "https://project-demo.com",
+ icon: LinkIcon,
+ required: false,
+ },
+ {
+ id: "youtubeLink",
+ label: "YouTube Demo",
+ placeholder: "https://youtube.com/watch?v=...",
+ icon: Youtube,
+ required: false,
+ },
+] as const;
+
+const categoryOptions = [
+ "AI",
+ "Web",
+ "Design",
+ "Social Impact",
+ "Beginner",
+] as const;
+
+function optionalText(formData: FormData, key: string) {
+ const value = formData.get(key);
+ if (typeof value !== "string") return null;
+
+ const trimmed = value.trim();
+ return trimmed || null;
+}
+
+function requiredText(formData: FormData, key: string, label: string) {
+ const value = optionalText(formData, key);
+ if (!value) {
+ throw new Error(`${label} is required.`);
+ }
+
+ return value;
+}
+
+async function submitProject(formData: FormData) {
+ "use server";
+
+ const projectName = requiredText(formData, "projectName", "Project name");
+ const elevatorPitch = optionalText(formData, "tagline");
+ const fullDescription = requiredText(
+ formData,
+ "description",
+ "Full description"
+ );
+ const builtWith = requiredText(formData, "builtWith", "Built with");
+ const teamMembers = requiredText(formData, "teamMembers", "Team members");
+ const gitRepo = requiredText(formData, "githubLink", "Git repository");
+ const livePostDemo = optionalText(formData, "demoLink");
+ const devpostUrl = optionalText(formData, "devpostLink");
+ const youtubeDemo = optionalText(formData, "youtubeLink");
+ const awardCategories = formData
+ .getAll("projectCategories")
+ .map((category) => (typeof category === "string" ? category.trim() : ""))
+ .filter(Boolean);
+
+ const sql = getSql();
+
+ await sql`
+ CREATE EXTENSION IF NOT EXISTS pgcrypto
+ `;
+
+ await sql`
+ CREATE TABLE IF NOT EXISTS projects (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ project_name text NOT NULL,
+ devpost_link text UNIQUE,
+ tracks text[] NOT NULL DEFAULT ARRAY[]::text[],
+ submitter_name text,
+ submitter_email text,
+ members text[] NOT NULL DEFAULT ARRAY[]::text[],
+ submitted_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ elevator_pitch text,
+ full_description text,
+ built_with text,
+ git_repo text,
+ live_post_demo text,
+ youtube_demo text
+ )
+ `;
+
+ await sql`
+ ALTER TABLE projects
+ ADD COLUMN IF NOT EXISTS id uuid DEFAULT gen_random_uuid(),
+ ADD COLUMN IF NOT EXISTS project_name text,
+ ADD COLUMN IF NOT EXISTS devpost_link text,
+ ADD COLUMN IF NOT EXISTS tracks text[] NOT NULL DEFAULT ARRAY[]::text[],
+ ADD COLUMN IF NOT EXISTS submitter_name text,
+ ADD COLUMN IF NOT EXISTS submitter_email text,
+ ADD COLUMN IF NOT EXISTS members text[] NOT NULL DEFAULT ARRAY[]::text[],
+ ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
+ ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
+ ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
+ ADD COLUMN IF NOT EXISTS elevator_pitch text,
+ ADD COLUMN IF NOT EXISTS full_description text,
+ ADD COLUMN IF NOT EXISTS built_with text,
+ ADD COLUMN IF NOT EXISTS git_repo text,
+ ADD COLUMN IF NOT EXISTS live_post_demo text,
+ ADD COLUMN IF NOT EXISTS youtube_demo text
+ `;
+
+ await sql`
+ CREATE UNIQUE INDEX IF NOT EXISTS projects_devpost_link_unique_idx
+ ON projects (devpost_link)
+ `;
+
+ await sql`
+ INSERT INTO projects (
+ project_name,
+ devpost_link,
+ tracks,
+ members,
+ submitted_at,
+ elevator_pitch,
+ full_description,
+ built_with,
+ git_repo,
+ live_post_demo,
+ youtube_demo
+ )
+ VALUES (
+ ${projectName},
+ ${devpostUrl},
+ ARRAY(
+ SELECT jsonb_array_elements_text(${JSON.stringify(awardCategories)}::jsonb)
+ ),
+ ARRAY(
+ SELECT trim(member)
+ FROM regexp_split_to_table(${teamMembers}, E'[\\n,;]+') AS member
+ WHERE trim(member) <> ''
+ ),
+ now(),
+ ${elevatorPitch},
+ ${fullDescription},
+ ${builtWith},
+ ${gitRepo},
+ ${livePostDemo},
+ ${youtubeDemo}
+ )
+ ON CONFLICT (devpost_link) DO UPDATE SET
+ project_name = EXCLUDED.project_name,
+ tracks = EXCLUDED.tracks,
+ members = EXCLUDED.members,
+ submitted_at = EXCLUDED.submitted_at,
+ elevator_pitch = EXCLUDED.elevator_pitch,
+ full_description = EXCLUDED.full_description,
+ built_with = EXCLUDED.built_with,
+ git_repo = EXCLUDED.git_repo,
+ live_post_demo = EXCLUDED.live_post_demo,
+ youtube_demo = EXCLUDED.youtube_demo,
+ updated_at = now()
+ `;
+
+ redirect("/hacker/projects");
+}
+
+function RequiredMark() {
+ return * ;
+}
+
+function SectionTitle({
+ icon: Icon,
+ title,
+ description,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+ );
+}
+
+function FormField({
+ id,
+ label,
+ required,
+ children,
+}: {
+ id: string;
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label} {required ? : null}
+
+ {children}
+
+ );
+}
+
+export default function SubmissionPage() {
+ return (
+
+
+
+
+
+
+ Project submission
+
+
+ Submit Your Hackathon Project
+
+
+ Finalize project details, team members, links, and award
+ categories before judging begins.
+
+
+
+
+
+
Required
+
+ Project, team, GitHub
+
+
+
+
Optional
+
Demo, YouTube
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/app/layout.tsx b/client/app/layout.tsx
index d7e4feb..9377273 100644
--- a/client/app/layout.tsx
+++ b/client/app/layout.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
-import { fredoka, rubik } from "@/lib/fonts";
+import { figtree, fredoka, jetbrainsMono, rubik } from "@/lib/fonts";
export const metadata: Metadata = {
title: "HackCanada Judging Platform",
@@ -13,7 +13,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
diff --git a/client/components/admin/schedule-manager.tsx b/client/components/admin/schedule-manager.tsx
index 268a37f..6947e96 100644
--- a/client/components/admin/schedule-manager.tsx
+++ b/client/components/admin/schedule-manager.tsx
@@ -3,12 +3,24 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
-import { Search, Clock, Plus, Minus, RotateCcw, Loader2, Trash2, CalendarPlus } from "lucide-react";
+import {
+ Search,
+ Clock,
+ Plus,
+ Minus,
+ RotateCcw,
+ Loader2,
+ Trash2,
+ CalendarPlus,
+} from "lucide-react";
import type { ScheduleSlot } from "@/lib/schedule";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
-import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
+import {
+ NativeSelect,
+ NativeSelectOption,
+} from "@/components/ui/native-select";
import {
Dialog,
DialogContent,
@@ -63,11 +75,17 @@ const TRACK_ACCENTS = [
];
function trackAccent(track: string) {
let h = 0;
- for (let i = 0; i < track.length; i++) h = (h * 31 + track.charCodeAt(i)) >>> 0;
+ for (let i = 0; i < track.length; i++)
+ h = (h * 31 + track.charCodeAt(i)) >>> 0;
return TRACK_ACCENTS[h % TRACK_ACCENTS.length];
}
-type Row = { key: string; startAt: string; endAt: string; byRoom: Record };
+type Row = {
+ key: string;
+ startAt: string;
+ endAt: string;
+ byRoom: Record;
+};
type EditState = {
id: string;
@@ -123,22 +141,32 @@ export function ScheduleManager({
const key = s.scheduledAt;
if (!map.has(key)) {
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
- map.set(key, { key, startAt: start.toISOString(), endAt: end.toISOString(), byRoom: {} });
+ map.set(key, {
+ key,
+ startAt: start.toISOString(),
+ endAt: end.toISOString(),
+ byRoom: {},
+ });
}
map.get(key)!.byRoom[s.room] = s;
}
return [...map.values()].sort(
- (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
+ (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(),
);
}, [slots]);
const q = query.trim().toLowerCase();
const matches = (s: ScheduleSlot | undefined) =>
- !!s && !!q && (s.projectName.toLowerCase().includes(q) || s.track.toLowerCase().includes(q));
+ !!s &&
+ !!q &&
+ (s.projectName.toLowerCase().includes(q) ||
+ s.track.toLowerCase().includes(q));
const matchCount = q ? slots.filter((s) => matches(s)).length : 0;
async function nudge(slot: ScheduleSlot, minutes: number) {
- const scheduledAt = new Date(new Date(slot.scheduledAt).getTime() + minutes * 60_000).toISOString();
+ const scheduledAt = new Date(
+ new Date(slot.scheduledAt).getTime() + minutes * 60_000,
+ ).toISOString();
setBusy(true);
try {
const res = await fetch(`/api/admin/schedule/${slot.id}`, {
@@ -148,7 +176,9 @@ export function ScheduleManager({
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error ?? "Update failed");
- setSlots((prev) => prev.map((s) => (s.id === data.slot.id ? data.slot : s)));
+ setSlots((prev) =>
+ prev.map((s) => (s.id === data.slot.id ? data.slot : s)),
+ );
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Update failed");
@@ -171,8 +201,10 @@ export function ScheduleManager({
setSlots((prev) =>
prev.map((s) => ({
...s,
- scheduledAt: new Date(new Date(s.scheduledAt).getTime() + globalDelay * 60_000).toISOString(),
- }))
+ scheduledAt: new Date(
+ new Date(s.scheduledAt).getTime() + globalDelay * 60_000,
+ ).toISOString(),
+ })),
);
toast.success(`Delayed all pitches by ${globalDelay} min`);
setGlobalDelay(0);
@@ -200,7 +232,9 @@ export function ScheduleManager({
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error ?? "Update failed");
- setSlots((prev) => prev.map((s) => (s.id === data.slot.id ? data.slot : s)));
+ setSlots((prev) =>
+ prev.map((s) => (s.id === data.slot.id ? data.slot : s)),
+ );
setEditing(null);
toast.success("Pitch updated");
router.refresh();
@@ -215,7 +249,9 @@ export function ScheduleManager({
if (!deleting) return;
setBusy(true);
try {
- const res = await fetch(`/api/admin/schedule/${deleting.id}`, { method: "DELETE" });
+ const res = await fetch(`/api/admin/schedule/${deleting.id}`, {
+ method: "DELETE",
+ });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error ?? "Delete failed");
setSlots((prev) => prev.filter((s) => s.id !== deleting.id));
@@ -266,7 +302,7 @@ export function ScheduleManager({
function openAdd() {
const last = slots.reduce(
(max, s) => Math.max(max, new Date(s.scheduledAt).getTime()),
- Date.now()
+ Date.now(),
);
setAdding({
projectId: "",
@@ -293,16 +329,38 @@ export function ScheduleManager({
Delay all
-
setGlobalDelay((d) => Math.max(0, d - 5))} disabled={busy}>
+ setGlobalDelay((d) => Math.max(0, d - 5))}
+ disabled={busy}
+ >
- {globalDelay} min
- setGlobalDelay((d) => d + 5)} disabled={busy}>
+
+ {globalDelay} min
+
+ setGlobalDelay((d) => d + 5)}
+ disabled={busy}
+ >
-
- {busy ? : }
+
+ {busy ? (
+
+ ) : (
+
+ )}
Apply