diff --git a/client/.gitignore b/client/.gitignore index cd01f46..1d84768 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -40,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/scripts \ No newline at end of file diff --git a/client/app/admin/layout.tsx b/client/app/admin/layout.tsx index 033990b..091e9fe 100644 --- a/client/app/admin/layout.tsx +++ b/client/app/admin/layout.tsx @@ -19,7 +19,9 @@ export default function AdminLayout({ -
{children}
+
+ {children} +
); diff --git a/client/app/admin/schedule/page.tsx b/client/app/admin/schedule/page.tsx index 97384b3..2e94377 100644 --- a/client/app/admin/schedule/page.tsx +++ b/client/app/admin/schedule/page.tsx @@ -1,5 +1,9 @@ import { getProjects } from "@/lib/queries"; -import { seedScheduleIfEmpty, getScheduleSlots, DEFAULT_ROOMS } from "@/lib/schedule"; +import { + seedScheduleIfEmpty, + getScheduleSlots, + DEFAULT_ROOMS, +} from "@/lib/schedule"; import { ScheduleManager } from "@/components/admin/schedule-manager"; export const metadata = { title: "Admin · Schedule" }; @@ -13,7 +17,9 @@ export default async function SchedulePage() { return (
-

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 ( + + ); +} 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 ( + + ); +} 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 ( +
+
+

+ Hacker logistics +

+

Food Menu

+

+ Check meal options and allergen notes before each food window. +

+
+ + + {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) + } + > + + + + +
+

+ {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 ( +
+ +
{children}
+
+ ); +} 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 ( +
+
+
+

+

+

+ {buildingName} +

+

+ Click a dot to focus the 3D map on judging rooms, hacker workspaces, + and support areas. +

+
+ + +
+ +
+
+ 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 ( + +
+
+
+
+
+

{title}

+
+

+ {description} +

+
+
+ + ); +} 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

+
+
+
+
+
+
+ +
+
+
+ +
+ + +
+
+
+
+ + 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 }) => ( + +
+
+
+
+
+

{label}

+
+

+ {description} +

+
+
+
+ ))} +
+
+
+ +
+ + + Live Notes + + Things worth keeping visible while hacking. + + + + {announcements.map((announcement) => ( +
+
+
+

+ {announcement} +

+
+ ))} + + +
+
+ + + +
+
+
+
+ + Mentors & Help + + + Where to go when you need technical or event support. + +
+
+
+ + {helpChannels.map(({ title, description, meta, icon: Icon }) => ( +
+
+
+
+ + {meta} + +
+

+ {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 ( + + ); + } + + return ( + + ); +} + +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)} +

+ + + +
+
+
+
+
+
+ +
+ + + +
+
+ ); +} + +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. +

+
+ +
+
+
+
+ +
+ + + Total projects + {projects.length} + + + + + Teams represented + {uniqueTeams} + + + + + Ready for judges + + {readyProjects}/{projects.length} + + + + + + +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+
+ + +
+ +
+
+
+
+ {allTeams.map((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"} +

+
+
+
+
+
+
+
+
+
+ + + +
+
+ + 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 ? ( + + ) : ( + + )} +
+ ); + })} +
+ )} +
+
+
+ )} +
+
+
+ ); +} 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 ( +
+
+

+ Live event +

+

+ Schedule +

+

+ Events connect across time slots and tracks when they continue, matching the compact live schedule style. +

+
+ +
+
+
+
+
+ {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 ( +
+ + {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

+
+
+

Status

+

Draft

+
+
+
+
+ +
+
+ + + + + + + + + + + + +