diff --git a/package.json b/package.json index 0ec387c1..c00cbd20 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@udecode/cn": "^44.0.1", "flowbite": "^2.3.0", "heroicons": "^2.1.1", + "jszip": "^3.10.1", "lucide-react": "^0.469.0", "micromodal": "^0.4.10", "react": "^18.2.0", diff --git a/src/components/dashboard_projects.jsx b/src/components/dashboard_projects.jsx new file mode 100644 index 00000000..a215b8af --- /dev/null +++ b/src/components/dashboard_projects.jsx @@ -0,0 +1,456 @@ +import { useEffect, useState } from "react"; +import { + Check, + Search, + ChevronLeft, + ChevronRight, + Download, + Loader, +} from "lucide-react"; +import Dropdown from "~/components/dropdown.jsx"; +import { cn } from "@udecode/cn"; +import Badge from "~/components/badge.jsx"; +import InformationModal from "~/components/informationModal.jsx"; +import JSZip from "jszip"; + +const options = [ + { value: "all", label: "Filter Themes" }, + { value: "mcsonae", label: "McSonae" }, + { value: "uphold", label: "Uphold" }, + { value: "singlestore", label: "SingleStore" }, +]; + +export default function Dashboard() { + const [loading, setLoading] = useState(true); + const [loadingCommits, setLoadingCommits] = useState(false); + const [resultCommits, setResultCommits] = useState(""); + const [projects, setProjects] = useState([]); + const [selectedOption, setSelectedOption] = useState("all"); + const [filteredProjects, setFilteredProjects] = useState([]); + const [search, setSearch] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [dowloaingCvs, setDownloadingCvs] = useState(false); + const itemsPerPage = 5; + const totalPages = Math.ceil(filteredProjects.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentProjects = filteredProjects.slice(startIndex, endIndex); + + // Navigation functions + const goToNextPage = () => { + setCurrentPage((current) => Math.min(current + 1, totalPages)); + }; + + const goToPreviousPage = () => { + setCurrentPage((current) => Math.max(current - 1, 1)); + }; + + const goToPage = (pageNumber) => { + setCurrentPage(pageNumber); + }; + + useEffect(() => { + setCurrentPage(1); + }, [search, selectedOption]); + + const getPageNumbers = () => { + const pageNumbers = []; + const maxVisiblePages = 3; + + let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); + const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + // Adjust start page if we're near the end + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push(i); + } + + return pageNumbers; + }; + + useEffect(() => { + async function fetchProjects() { + const response = await fetch("/api/projects/list", { method: "GET" }); + const data = await response.json(); + if (response.ok) { + setProjects(data); + setFilteredProjects(data); + } + } + fetchProjects(); + }, []); + + useEffect(() => { + async function checkAuth() { + const response = await fetch("/api/admin", { + method: "GET", + credentials: "include", + }); + const data = await response.json(); + if (!response.ok) { + window.location.href = "/"; + } else { + setLoading(false); + } + } + checkAuth(); + }, []); + + useEffect(() => { + if (search) { + const searchResults = projects.filter( + (project) => + project.name.toLowerCase().includes(search.toLowerCase()) || + project.team_code.toLowerCase().includes(search.toLowerCase()), + ); + setFilteredProjects(searchResults); + } else { + filterProjects(selectedOption); + } + }, [search]); + + async function filterProjects(option) { + setSelectedOption(option); + + if (option === "all") { + setFilteredProjects(projects); + } else if (option === "mcsonae") { + const mcsonaeProjects = projects.filter( + (team) => team.theme === "McSonae", + ); + setFilteredProjects(mcsonaeProjects); + } else if (option === "uphold") { + const upholdProjects = projects.filter((team) => team.theme === "Uphold"); + setFilteredProjects(upholdProjects); + } else if (option === "singlestore") { + const singlestoreProjects = projects.filter( + (team) => team.theme === "SingleStore", + ); + setFilteredProjects(singlestoreProjects); + } + } + + async function downloadCvs() { + setDownloadingCvs(true); + + const queryParams = new URLSearchParams({ + codes: filteredProjects.map((project) => project.team_code).join(","), + }).toString(); + + const participants_request = await fetch( + `/api/participants/list?${queryParams}`, + { + method: "GET", + }, + ); + + const teams_request = await fetch("/api/teams/list", { + method: "GET", + }); + + const map_code_participants = await participants_request.json(); + const list_teams = await teams_request.json(); + + const map_teams_participants = {}; + + list_teams.forEach((team) => { + const teamCode = team.code; + const teamName = team.name; + + if (map_code_participants[teamCode]) { + // Map the team name to the corresponding participants + map_teams_participants[teamName] = map_code_participants[teamCode]; + } + }); + + const map_teams_cvs = {}; + + await Promise.all( + Object.entries(map_teams_participants).map( + async ([teamName, participants]) => { + const queryParamsCvs = new URLSearchParams({ + emails: participants.join(","), + }).toString(); + + const cvsrequest = await fetch( + `/api/cvs/download?${queryParamsCvs}`, + { + method: "GET", + }, + ); + + if (!cvsrequest.ok) { + console.error(`Failed to download CVs for ${teamName}`); + return; + } + + const blob = await cvsrequest.blob(); + map_teams_cvs[teamName] = blob; + }, + ), + ); + + if (Object.keys(map_teams_cvs).length === 0) { + console.error("No CVs were downloaded."); + return; + } + + // Create ZIP file + const zip = new JSZip(); + for (const [teamName, blob] of Object.entries(map_teams_cvs)) { + zip.file(`${teamName}.zip`, blob); + } + + // Generate and download ZIP + const zipBlob = await zip.generateAsync({ type: "blob" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(zipBlob); + link.download = selectedOption + ".zip"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setDownloadingCvs(false); + } + + async function CheckCommits(team_code) { + setLoadingCommits(true); + const response = await fetch(`/api/projects/commits?team=${team_code}`, { + method: "GET", + }); + const data = await response.json(); + setLoadingCommits(false); + if (response.ok) { + const result = data.message.valid + ? "Last commit is valid" + : "Last commit after the deadline"; + setResultCommits(result); + } else { + setResultCommits("Error fetching commits"); + } + } + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + return ( + <> +
+
+

Projects

+
+
+ +
+
+
+
+ + setSearch(e.target.value)} + /> +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + {filteredProjects.length > 0 ? ( + currentProjects.map((project) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
+ TEAM CODE + + NAME + + LINK + + SUBMITTED AT + + THEME + + DESCRIPTION + + COMMITS +
+ {project.team_code} + + {project.name} + + {project.link} + + {new Date(project.created_at).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + {project.theme} + + {project.description ? project.description : "None"} + + +
+ No Projects found matching your search. +
+
+ {loadingCommits && ( + setLoadingCommits(false)} + /> + )} + {resultCommits && ( + setResultCommits("")} + /> + )} +
+
+ Showing {startIndex + 1}- + {Math.min(endIndex, filteredProjects.length)} of{" "} + {filteredProjects.length} projects +
+
+ + +
+ {getPageNumbers().map((pageNumber) => ( + + ))} +
+ + +
+
+
+
+ + ); +} diff --git a/src/components/forms/adminForm.jsx b/src/components/forms/adminForm.jsx index 0ca7ef03..3e61d674 100644 --- a/src/components/forms/adminForm.jsx +++ b/src/components/forms/adminForm.jsx @@ -11,7 +11,7 @@ export default function AdminForm() { credentials: "include", }); if (response.ok) { - window.location.href = "/admin/dashboard"; + window.location.href = "/admin/teams"; } } checkAuth(); @@ -34,7 +34,7 @@ export default function AdminForm() { if (!response.ok) { setError(data.message.error); } else { - window.location.href = "/admin/dashboard"; // Redirect to dashboard + window.location.href = "/admin/teams"; // Redirect to dashboard } } catch (err) { setError("An unexpected error occurred. Please try again."); diff --git a/src/components/forms/selector.jsx b/src/components/forms/selector.jsx index e578c199..6ffb69c4 100644 --- a/src/components/forms/selector.jsx +++ b/src/components/forms/selector.jsx @@ -16,7 +16,9 @@ export default function Selector({ param, title, options }) { --Please choose an option-- {options.map((option) => ( - + ))} diff --git a/src/components/informationModal.jsx b/src/components/informationModal.jsx index 25a16922..32fcd43d 100644 --- a/src/components/informationModal.jsx +++ b/src/components/informationModal.jsx @@ -14,7 +14,7 @@ export default function InformationModal({ title, content, closeModal }) { stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-circle-check-big text-center text-green-500 h-12 w-12" + className="lucide lucide-circle-check-big text-center text-green-500 h-12 w-12" > diff --git a/src/components/projectSubmission.jsx b/src/components/projectSubmission.jsx index c460fe79..988b1b22 100644 --- a/src/components/projectSubmission.jsx +++ b/src/components/projectSubmission.jsx @@ -16,7 +16,6 @@ export default function ProjectDelivery() { const [showInfoModal, setShowInfoModal] = useState(false); async function submit(e) { - console.log("submit"); e.preventDefault(); closeModal(); setLoadingState(true); @@ -27,7 +26,7 @@ export default function ProjectDelivery() { }); const data = await response.json(); - console.log(data); + if (!response.ok) { setResponseErrors(data.message.errors); setLoadingState(false); @@ -85,7 +84,7 @@ export default function ProjectDelivery() { title="Project Theme" options={themes.map((theme) => ({ key: theme.company, - value: `${theme.company}: ${theme.theme}` + value: `${theme.company}: ${theme.theme}`, }))} /> @@ -117,14 +116,14 @@ export default function ProjectDelivery() { title="Project submitted!" content={ <> - Your project has been successfully submitted! - Thank you for participating in Hackathon Bugsbyte. Good luck! + Your project has been successfully submitted! Thank you for + participating in{" "} + Hackathon Bugsbyte. Good luck! } closeModal={goBack} /> )} - ); diff --git a/src/components/registerForm.jsx b/src/components/registerForm.jsx index f4ef17e3..9d017d1d 100644 --- a/src/components/registerForm.jsx +++ b/src/components/registerForm.jsx @@ -24,7 +24,7 @@ export default function Form() { closeConfirmationModal(); setLoadingState(true); const formData = new FormData(e.target); - const response = await fetch("/api/register", { + const response = await fetch("/api/participants/register", { method: "POST", body: formData, }); @@ -44,7 +44,6 @@ export default function Form() { setResponseErrors(responseJoinTeam.message.errors); setLoadingState(false); } else { - console.log("opening modal"); openInformationModal(); } } diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 66bcbd41..16bda584 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -3,16 +3,12 @@ import { useEffect, useRef, useState } from "react"; import { X } from "lucide-react"; -interface SidebarItem { - name: string; - url: string; -} - -interface SidebarProps { - items?: SidebarItem[]; -} +const items = [ + { name: "Teams", url: "/admin/teams" }, + { name: "Projects", url: "/admin/projects" }, +]; -export default function Sidebar({ items = [] }: SidebarProps) { +export default function Sidebar() { const [isOpen, setIsOpen] = useState(false); const sidebarRef = useRef(null); diff --git a/src/data/themes.json b/src/data/themes.json index febb2302..0b74222a 100644 --- a/src/data/themes.json +++ b/src/data/themes.json @@ -1,14 +1,14 @@ [ - { - "company": "McSonae", - "theme": "Price optimization tool" - }, - { - "company": "Uphold", - "theme": "Crypto basket price predictor" - }, - { - "company": "SingleStore", - "theme": "Real-Time GenAI: Build Smarter, Faster AI Apps" - } -] \ No newline at end of file + { + "company": "McSonae", + "theme": "Price optimization tool" + }, + { + "company": "Uphold", + "theme": "Crypto basket price predictor" + }, + { + "company": "SingleStore", + "theme": "Real-Time GenAI: Build Smarter, Faster AI Apps" + } +] diff --git a/src/hiddenpages/api/projects/create.ts b/src/hiddenpages/api/projects/create.ts index 08249b93..165259d5 100644 --- a/src/hiddenpages/api/projects/create.ts +++ b/src/hiddenpages/api/projects/create.ts @@ -67,22 +67,23 @@ const validateForms = async (formData: FormData, errors: String[]) => { if (!team_code || !link) { errors.push("All fields are required."); - return false + return false; } - const valid = (await validateTeamCode(team_code, errors) && await validateLink(link, errors)) + const valid = + (await validateTeamCode(team_code, errors)) && + (await validateLink(link, errors)); return valid; }; - const validateTeamCode = async (team_code: string, errors: String[]) => { let valid = true; let { data: projects, error } = await supabase - .from("projects") - .select("*") - .eq("team_code", team_code); + .from("projects") + .select("*") + .eq("team_code", team_code); if (error) { errors.push( @@ -95,7 +96,7 @@ const validateTeamCode = async (team_code: string, errors: String[]) => { } return valid; -} +}; const validateLink = async (link: string, errors: String[]) => { const links = link.split(" "); @@ -111,10 +112,11 @@ const validateLink = async (link: string, errors: String[]) => { } return true; -} +}; const validateGithubLink = async (link: string, errors: String[]) => { - const githubLinkRegex = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; + const githubLinkRegex = + /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const match = link.match(githubLinkRegex); if (!match) { @@ -138,4 +140,4 @@ const validateGithubLink = async (link: string, errors: String[]) => { } return true; -} \ No newline at end of file +}; diff --git a/src/pages/admin/projects.astro b/src/pages/admin/projects.astro new file mode 100644 index 00000000..dc3398c4 --- /dev/null +++ b/src/pages/admin/projects.astro @@ -0,0 +1,12 @@ +--- +import BaseLayout from "~/layouts/baseLayout.astro"; +import DashboardComponent from "~/components/dashboard_projects.jsx"; +import Sidebar from "~/components/sidebar.tsx"; +--- + + + +
+ +
+
diff --git a/src/pages/admin/dashboard.astro b/src/pages/admin/teams.astro similarity index 70% rename from src/pages/admin/dashboard.astro rename to src/pages/admin/teams.astro index c8922f20..ab1df1c2 100644 --- a/src/pages/admin/dashboard.astro +++ b/src/pages/admin/teams.astro @@ -2,12 +2,10 @@ import BaseLayout from "~/layouts/baseLayout.astro"; import DashboardComponent from "~/components/dashboard.jsx"; import Sidebar from "~/components/sidebar.tsx"; - -const navigationItems = [{ name: "Teams", url: "/admin/dashboard" }]; --- - +
diff --git a/src/pages/api/cvs/download.ts b/src/pages/api/cvs/download.ts new file mode 100644 index 00000000..e0be85d6 --- /dev/null +++ b/src/pages/api/cvs/download.ts @@ -0,0 +1,82 @@ +import type { APIRoute } from "astro"; +import { createClient } from "@supabase/supabase-js"; +import JSZip from "jszip"; + +export const prerender = false; + +const supabase = createClient( + import.meta.env.SUPABASE_URL, + import.meta.env.SUPABASE_ANON_KEY, +); + +const AUTH_COOKIE_NAME = "authToken"; +const AUTH_SECRET = import.meta.env.AUTH_SECRET; + +export const GET: APIRoute = async ({ request, cookies }) => { + const authToken = cookies.get(AUTH_COOKIE_NAME); + + if (!authToken || authToken.value !== AUTH_SECRET) { + return new Response( + JSON.stringify({ message: { error: "Unauthorized" } }), + { status: 401 }, + ); + } + + const url = new URL(request.url); + const emailsParam = url.searchParams.get("emails"); + const emails = emailsParam ? emailsParam.split(",") : null; + + if (emails && Array.isArray(emails)) { + const zip = new JSZip(); + + for (const email of emails) { + const name = email.split("@")[0]; + const path = `cv/${email}/${name}.pdf`; + const { data: cv, error } = await supabase.storage + .from("files") + .download(path); + + if (error) { + const path_PDF = `cv/${email}/${name}.PDF`; + const { data: cv_PDF, error: error_PDF } = await supabase.storage + .from("files") + .download(path_PDF); + + if (error_PDF) { + console.error("Error downloading CV:", name); + return new Response( + JSON.stringify({ message: { error: "CV not found" } }), + { status: 404 }, + ); + } + + const arrayBuffer = await cv_PDF.arrayBuffer(); + zip.file(`${name}.pdf`, arrayBuffer); + continue; + } + + const arrayBuffer = await cv.arrayBuffer(); + zip.file(`${name}.pdf`, arrayBuffer); + } + + if (Object.keys(zip.files).length === 0) { + return new Response(JSON.stringify({ error: "No CVs found" }), { + status: 404, + }); + } + + const zipBlob = await zip.generateAsync({ type: "blob" }); + + return new Response(zipBlob, { + headers: { + "Content-Disposition": "attachment; filename=cvs.zip", + "Content-Type": "application/zip", + }, + }); + } + + return new Response( + JSON.stringify({ message: { error: "Invalid request" } }), + { status: 400 }, + ); +}; diff --git a/src/pages/api/participants/list.ts b/src/pages/api/participants/list.ts new file mode 100644 index 00000000..65c8f1f6 --- /dev/null +++ b/src/pages/api/participants/list.ts @@ -0,0 +1,67 @@ +import type { APIRoute } from "astro"; +import { createClient } from "@supabase/supabase-js"; + +export const prerender = false; + +const supabase = createClient( + import.meta.env.SUPABASE_URL, + import.meta.env.SUPABASE_ANON_KEY, +); + +const AUTH_COOKIE_NAME = "authToken"; +const AUTH_SECRET = import.meta.env.AUTH_SECRET; + +export const GET: APIRoute = async ({ request, cookies }) => { + const authToken = cookies.get(AUTH_COOKIE_NAME); + + if (!authToken || authToken.value !== AUTH_SECRET) { + return new Response( + JSON.stringify({ message: { error: "Unauthorized" } }), + { status: 401 }, + ); + } + + const url = new URL(request.url); + const codesParam = url.searchParams.get("codes"); + const codes = codesParam ? codesParam.split(",") : null; + + if (codes && Array.isArray(codes)) { + const { data: participants, error } = await supabase + .from("participants") + .select("*") + .in("team_code", codes); + + if (error) { + return new Response( + JSON.stringify({ message: { error: error.message } }), + { + status: 500, + }, + ); + } + + const result = codes.reduce>( + (acc, code) => { + acc[code] = participants + .filter((participant) => participant.team_code === code) + .map((participant) => participant.email); + return acc; + }, + {}, + ); + + return new Response(JSON.stringify(result), { status: 200 }); + } + + const { data: teams, error } = await supabase + .from("participants") + .select("*"); + + if (error) { + return new Response(JSON.stringify({ message: { error: error.message } }), { + status: 500, + }); + } + + return new Response(JSON.stringify(teams), { status: 200 }); +}; diff --git a/src/pages/api/register.ts b/src/pages/api/participants/register.ts similarity index 100% rename from src/pages/api/register.ts rename to src/pages/api/participants/register.ts diff --git a/src/pages/api/projects/commits.ts b/src/pages/api/projects/commits.ts new file mode 100644 index 00000000..72c0626c --- /dev/null +++ b/src/pages/api/projects/commits.ts @@ -0,0 +1,112 @@ +import type { APIRoute } from "astro"; +import { createClient } from "@supabase/supabase-js"; + +export const prerender = false; + +const supabase = createClient( + import.meta.env.SUPABASE_URL, + import.meta.env.SUPABASE_ANON_KEY, +); + +const AUTH_COOKIE_NAME = "authToken"; +const AUTH_SECRET = import.meta.env.AUTH_SECRET; + +const apiGithub = "https://api.github.com/repos/"; +const lastCommitDate = new Date("2025-03-30T13:30:00Z"); + +export const GET: APIRoute = async ({ request, cookies }) => { + const authToken = cookies.get(AUTH_COOKIE_NAME); + + if (!authToken || authToken.value !== AUTH_SECRET) { + return new Response( + JSON.stringify({ message: { error: "Unauthorized" } }), + { status: 401 }, + ); + } + + const url = new URL(request.url); + const teamParam = url.searchParams.get("team"); + + if (!teamParam) { + return new Response( + JSON.stringify({ message: { error: "Team not found" } }), + { status: 404 }, + ); + } + + const { data: links, error } = await supabase + .from("projects") + .select("link") + .eq("team_code", teamParam) + .single(); + + if (error) { + return new Response( + JSON.stringify({ message: { error: "Commits not found" } }), + { status: 404 }, + ); + } + + const valid = await checkCommits(links.link); + + if (!valid) { + return new Response( + JSON.stringify({ message: { valid: false, error: "Invalid commits" } }), + { status: 200 }, + ); + } + return new Response( + JSON.stringify({ + message: { valid: true, error: null }, + status: 200, + }), + { status: 200 }, + ); +}; + +const checkCommits = async (link: string) => { + const links = typeof link === "string" ? link.split(" ") : []; + + for (let i = 0; i < links.length; i++) { + const link = links[i].trim(); + if (link.length > 0) { + const valid = await validateCommit(link); + if (!valid) { + return false; + } + } + } + + return true; +}; + +const validateCommit = async (link: string) => { + const githubLinkRegex = + /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; + + const match = link.match(githubLinkRegex); + if (!match) { + console.log("Invalid link format"); + return false; + } + + const [, username, repositoryName] = match; + + const response = await fetch( + apiGithub + `${username}/${repositoryName}/commits`, + ); + if (!response.ok) { + console.log("Error fetching commits"); + return false; + } + + const data = await response.json(); + const lastCommit = new Date(data[0].commit.author.date); + + if (lastCommit >= lastCommitDate) { + console.log("Last commit is too recent"); + return false; + } + + return true; +}; diff --git a/src/pages/api/projects/list.ts b/src/pages/api/projects/list.ts new file mode 100644 index 00000000..7352f320 --- /dev/null +++ b/src/pages/api/projects/list.ts @@ -0,0 +1,33 @@ +import type { APIRoute } from "astro"; +import { createClient } from "@supabase/supabase-js"; + +export const prerender = false; + +const supabase = createClient( + import.meta.env.SUPABASE_URL, + import.meta.env.SUPABASE_ANON_KEY, +); + +const AUTH_COOKIE_NAME = "authToken"; +const AUTH_SECRET = import.meta.env.AUTH_SECRET; + +export const GET: APIRoute = async ({ request, cookies }) => { + const authToken = cookies.get(AUTH_COOKIE_NAME); + + if (!authToken || authToken.value !== AUTH_SECRET) { + return new Response( + JSON.stringify({ message: { error: "Unauthorized" } }), + { status: 401 }, + ); + } + + const { data: teams, error } = await supabase.from("projects").select("*"); + + if (error) { + return new Response(JSON.stringify({ message: { error: error.message } }), { + status: 500, + }); + } + + return new Response(JSON.stringify(teams), { status: 200 }); +};