diff --git a/src/individuals/pages/Saved.tsx b/src/individuals/pages/Saved.tsx index 4a5ecb69..9a2f662b 100644 --- a/src/individuals/pages/Saved.tsx +++ b/src/individuals/pages/Saved.tsx @@ -1,126 +1,207 @@ -import React, { useEffect } from "react"; -import { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useAuth } from "../../context/AuthContext.tsx"; import { Opportunity } from "../../types/opportunity.ts"; import { getCookie } from "../../utils.ts"; export default function SavedPage() { - const { auth } = useAuth(); + const { auth } = useAuth(); - if (!auth.isAuthenticated) { - window.location.href = "/login"; - } + if (!auth.isAuthenticated) { + window.location.href = "/login"; + } - const [saved, setSaved] = useState(null); + const [saved, setSaved] = useState(null); - const csrfToken = getCookie('csrf_access_token'); + const csrfToken = getCookie("csrf_access_token"); - const fetchSaved = async () => { - try { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_SERVER}/savedOpportunities`, { - credentials: "include", - } - ); + const fetchSaved = async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_BACKEND_SERVER}/savedOpportunities`, + { + credentials: "include", + } + ); - if (!response.ok) { - throw new Error("Saved not found"); - } + if (!response.ok) { + throw new Error("Saved not found"); + } - const data = await response.json(); - setSaved(data); - console.log(data); - } catch { - console.log("Error fetching saved"); - } + const data = await response.json(); + setSaved(data); + } catch { + console.log("Error fetching saved"); } + }; + + useEffect(() => { + fetchSaved(); + }, []); + + return ( +
+
+

+ Saved Opportunities +

+ + {saved === null && ( +

+ Loading... +

+ )} - useEffect(() => { - fetchSaved(); - }, []); - - return ( -
-

- Saved Opportunities -

- {!saved && "Loading..."} - {saved && ( - - - - - - - - - - - - + {saved !== null && ( +
+ {saved.length === 0 ? ( +

+ You don’t have any saved opportunities yet. +

+ ) : ( +
+
NameDescriptionRecommended ExperiencePayCreditsSemesterYearApplication DueLocationUnsave
+ + + + + + + + + + + + - {saved.map((opportunity) => ( - - - - - - - - - + + + {saved.map((opportunity) => { + const today = new Date(); + const dueDate = new Date(opportunity.application_due); + const oneWeek = 7 * 24 * 60 * 60 * 1000; + + let dueClass = + "p-3 border border-gray-300 dark:border-gray-600"; + if (dueDate < today) { + dueClass += " text-red-500 font-semibold"; + } else if ( + dueDate.getTime() - today.getTime() <= oneWeek + ) { + dueClass += " text-orange-400 font-semibold"; + } + + return ( + + + + + + + + + + + - - + ); + + if (!response.ok) { + throw new Error("Failed to unsave"); + } + + setSaved((prev) => + prev + ? prev.filter( + (o) => o.id !== opportunity.id + ) + : prev + ); + } catch { + console.log( + "Error unsaving opportunity" + ); + } + }} + > + Unsave + + - ))} + ); + })} +
+ Name + + Description + + Recommended Experience + + Pay + + Credits + + Semester + + Year + + Application Due + + Location + + Unsave +
{opportunity.name}{opportunity.description}{opportunity.recommended_experience}{opportunity.pay}{opportunity.credits}{opportunity.semester}{opportunity.year} { - const today = new Date(); - const dueDate = new Date(opportunity.application_due); - const oneWeek = 7 * 24 * 60 * 60 * 1000; - - if (dueDate < today) { - return "red"; - } else if (dueDate.getTime() - today.getTime() <= oneWeek) { - return "orange"; - } else { - return "black"; +
+ {opportunity.name} + + {opportunity.description} + + {opportunity.recommended_experience} + + {opportunity.pay + ? `$${opportunity.pay}/hr` + : ""} + + {opportunity.credits} + + {opportunity.semester} + + {opportunity.year} + + {new Date( + opportunity.application_due + ).toLocaleDateString("en-US")} + + {opportunity.location} + + {opportunity.location} - -
+
)} -
- ); -}; + + )} + + + ); +} diff --git a/src/opportunities/components/FiltersField.tsx b/src/opportunities/components/FiltersField.tsx index cf049855..458d3e4f 100644 --- a/src/opportunities/components/FiltersField.tsx +++ b/src/opportunities/components/FiltersField.tsx @@ -13,42 +13,57 @@ interface FiltersFieldProps { setPopUpMenu: () => void; } -export default function FiltersField({ resetFilters, deleteFilter, filters, setPopUpMenu }: FiltersFieldProps) { +export default function FiltersField({ + resetFilters, + deleteFilter, + filters, + setPopUpMenu, +}: FiltersFieldProps) { return ( -
-
+
+
+
+ {/* Make sure SearchBar forwards className to the actual */} - + Change Filters - {/* Fix rendering with new filters = [ [],[],[] ]*/} + {/* Filter “chips” */} - {filters.map((filter) => { - return ( - } - key={filter} - special={false} - > - {filter} - - ) - })} + {filters.map((filter) => ( + } + special={false} + + > + {filter} + + ))}
- + Reset
-
+ +
); -}; +} + diff --git a/src/opportunities/components/JobDetails.tsx b/src/opportunities/components/JobDetails.tsx index f898189c..0b179c28 100644 --- a/src/opportunities/components/JobDetails.tsx +++ b/src/opportunities/components/JobDetails.tsx @@ -19,25 +19,46 @@ const JobDetails = ({ recommended_experience, }: JobDetailsProps) => { return ( -
- - +
+
+
+ {/* Accent vertical line */} +
- -
+
+ + + + + +
+
+ + ); }; diff --git a/src/opportunities/components/OpportunitiesDetails.tsx b/src/opportunities/components/OpportunitiesDetails.tsx index d4e41f36..1d2fde98 100644 --- a/src/opportunities/components/OpportunitiesDetails.tsx +++ b/src/opportunities/components/OpportunitiesDetails.tsx @@ -8,46 +8,64 @@ interface OpportunitiesListProps { export default function OpportunitiesList({ opportunities }: OpportunitiesListProps) { return ( -
+
- - {/* Column Headers */} - - - - - - - - - - + + + + + + + + + + + - - {/* Info about the opportunities */} + + {opportunities.length > 0 ? ( opportunities.map((opportunity) => ( - - - - - - - - + + + + + + + - - @@ -55,7 +73,10 @@ export default function OpportunitiesList({ opportunities }: OpportunitiesListPr )) ) : ( - @@ -65,4 +86,4 @@ export default function OpportunitiesList({ opportunities }: OpportunitiesListPr ); -}; \ No newline at end of file +} diff --git a/src/opportunities/components/PopUpMenu.tsx b/src/opportunities/components/PopUpMenu.tsx index 7789a33e..bd792af8 100644 --- a/src/opportunities/components/PopUpMenu.tsx +++ b/src/opportunities/components/PopUpMenu.tsx @@ -35,7 +35,7 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P const data = await response.json(); setMajors(data); } - } + }; fetchMajors(); }, []); @@ -49,7 +49,7 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P const data = await response.json(); setValidYears(data); } - } + }; fetchYears(); }, []); @@ -67,10 +67,10 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P }); interface FormData { - years: string[], - credits: string[], - hourlyPay: number, - majors: string[] + years: string[]; + credits: string[]; + hourlyPay: number; + majors: string[]; } function formatCredits(credits: string[]): string | null { @@ -96,7 +96,7 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P credits: credits, hourlyPay: Number(hourlyPay), majors: majors - } + }; const activeFilters: string[] = [ ...years, @@ -105,24 +105,41 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P ...majors ]; setFilters(activeFilters, newFilterMap); - setFunction() - }; + setFunction(); + } return ( -
- +
+ {/* Overlay */} + +
-
-
-
Filters
+ {/* Modal shell */} +
+
+
+ Filters +
+
{ - submitHandler(data); + submitHandler(data as FormData); })} className="flex flex-col gap-5" - >
{/* Added max-height and overflow-y-auto */} + > + {/* Scrollable content */} +
+ {/* Year / credits checkboxes */}
{checkboxes.map((filter) => (
@@ -139,6 +156,8 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P
))}
+ + {/* Hourly pay input */}
value >= 0 || "Hourly pay must be greater or equal to 0", + validate: (value: number) => + value >= 0 || + "Hourly pay must be greater or equal to 0", pattern: { value: /^\d+(\.\d{1,2})?$/, - message: "Hourly pay must be a positive number with up to two decimal places" + message: + "Hourly pay must be a positive number with up to two decimal places" } }) }} @@ -161,22 +183,27 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P />
+ {/* Majors list */}
-

Majors

+

+ Majors +

+
-
@@ -206,4 +254,4 @@ export default function PopUpMenu({ setFunction, reset, filters, setFilters }: P ); -} \ No newline at end of file +} diff --git a/src/opportunities/pages/IndividualPost.tsx b/src/opportunities/pages/IndividualPost.tsx index 75ed8a26..1a7ffae4 100644 --- a/src/opportunities/pages/IndividualPost.tsx +++ b/src/opportunities/pages/IndividualPost.tsx @@ -1,7 +1,6 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import JobDetails from "../components/JobDetails"; import { useParams } from "react-router-dom"; -import { useEffect } from "react"; const IndividualPost = () => { const { postID } = useParams(); @@ -9,7 +8,6 @@ const IndividualPost = () => { const [details, setDetails] = useState("Searching"); const fetchOpportunities = async () => { - // Consider moving the base URL to a configuration const baseURL = `${import.meta.env.VITE_BACKEND_SERVER}`; const url = `${baseURL}/getOpportunity/${postID}`; @@ -32,21 +30,43 @@ const IndividualPost = () => { findDetails(); }); - - - - return ( -
+
{details === "Searching" ? ( - +
+ +

+ Loading opportunity details… +

+
) : details === "Nothing found" ? ( -

No post found

+
+

No post found

+
) : ( - +
+ +
)}
); }; export default IndividualPost; + diff --git a/src/opportunities/pages/Opportunities.tsx b/src/opportunities/pages/Opportunities.tsx index f862ec3e..c82a313b 100644 --- a/src/opportunities/pages/Opportunities.tsx +++ b/src/opportunities/pages/Opportunities.tsx @@ -8,43 +8,48 @@ interface PageNavigationType { } const Opportunities: React.FC = () => { - // navigation bar const [pages, switchPage] = usePageNavigation(["Search", "Saved"], "Search") as [ PageNavigationType, (page: string) => void ]; - const activeLink = "text-black py-3 border-b-2 border-black text-lg"; - const normalLink = "text-gray-600 py-3 text-lg border-black hover:border-b-2 hover:text-black"; + // Tailwind classes with dark variants + const activeLink = + "py-3 text-lg font-semibold border-b-2 " + + "text-black dark:text-gray-100 " + + "border-black dark:border-gray-100"; + + const normalLink = + "py-3 text-lg font-semibold border-b-2 border-transparent " + + "text-gray-600 dark:text-gray-300 " + + "hover:text-black dark:hover:text-white " + + "hover:border-black dark:hover:border-gray-100"; - // displaying opportunities list component return ( -
+
-
-

Opportunities

- -
diff --git a/src/shared/components/Profile/ProfileDescription.tsx b/src/shared/components/Profile/ProfileDescription.tsx index 7e510529..b60003b5 100644 --- a/src/shared/components/Profile/ProfileDescription.tsx +++ b/src/shared/components/Profile/ProfileDescription.tsx @@ -3,19 +3,37 @@ import { Link } from "react-router-dom"; import { Profile } from "../../../types/profile.ts"; export default function ProfileDescription({ - name, department, description, website, pronouns + name, + department, + description, + website, + pronouns, }: Profile) { return ( -
+

{name}

-
{department}
- {pronouns &&
{pronouns}
} -

{description}

- {website && website.length && ( - +
{department}
+ {pronouns &&
{pronouns}
} +

{description}

+ {website && website.length > 0 && ( + {website} )}
); -}; \ No newline at end of file +} diff --git a/src/shared/components/Profile/ProfileOpportunities.tsx b/src/shared/components/Profile/ProfileOpportunities.tsx index 37e6d097..c13f9b7a 100644 --- a/src/shared/components/Profile/ProfileOpportunities.tsx +++ b/src/shared/components/Profile/ProfileOpportunities.tsx @@ -2,15 +2,22 @@ import React from "react"; import LargeTextCard from "../UIElements/LargeTextCard.tsx"; import { useState, useEffect } from "react"; -export default function ProfileOpportunities({ id, staff }: { id: string, staff: boolean }) { - const [opportunities, setOpportunities] = useState | null | "no response">(null); +export default function ProfileOpportunities({ + id, + staff, +}: { + id: string; + staff: boolean; +}) { + const [opportunities, setOpportunities] = useState< + Array<{ id: string; title: string; due: string; pay: string; credits: string }> | null | "no response" + >(null); useEffect(() => { async function setData() { const response = await fetch( - `${import.meta.env.VITE_BACKEND_SERVER}/${staff ? "staff" : "profile"}/opportunities/${id}`, { - credentials: "include", - } + `${import.meta.env.VITE_BACKEND_SERVER}/${staff ? "staff" : "profile"}/opportunities/${id}`, + { credentials: "include" } ); if (response.ok) { @@ -25,11 +32,24 @@ export default function ProfileOpportunities({ id, staff }: { id: string, staff: }, [id, staff]); const opportunityList = ( -
+
{id && Array.isArray(opportunities) && opportunities.map((opportunity) => ( - -

Posted Opportunities:

- {opportunities !== null ? opportunityList : "Loading..."} - {opportunities === "no response" && "No Opportunities Found"} +
+

+ Posted Opportunities: +

+ + {/* Loaded list */} + {opportunities !== null && opportunities !== "no response" && opportunityList} + + {/* Loading / empty states */} + {opportunities === null && ( +

Loading...

+ )} + {opportunities === "no response" && ( +

+ No Opportunities Found +

+ )}
); -}; +} diff --git a/src/shared/pages/Home.tsx b/src/shared/pages/Home.tsx index 7d108ba2..df7267f7 100644 --- a/src/shared/pages/Home.tsx +++ b/src/shared/pages/Home.tsx @@ -281,7 +281,7 @@ const Home = () => { { name: "Doan Nguyen", major: "Computer Science", - gradYear: "2027", + gradYear: "2026", role: "Frontend Developer", } ].map((member, index) => ( diff --git a/src/staff/components/CreationForms.tsx b/src/staff/components/CreationForms.tsx index 4c65269f..dffd8c2d 100644 --- a/src/staff/components/CreationForms.tsx +++ b/src/staff/components/CreationForms.tsx @@ -1,30 +1,47 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { useEffect } from "react"; import CheckBox from "../../shared/components/Checkbox.tsx"; import Input from "./Input.jsx"; import { useParams } from "react-router"; import { Locations } from "../../shared/data/locations.ts"; - interface CreationFormsProps { edit: boolean; } +interface FormData { + id: string; + title: string; + application_due: string; + type: string; + hourlyPay: number; + credits: string[]; + description: string; + recommended_experience: string; + location: string; + years: string[]; +} + export default function CreationForms({ edit }: CreationFormsProps) { const { postID } = useParams(); - const [loading, setLoading] = useState(false); - const [compensationType, setCompensationType] = useState("Any"); // Manage the state for "For Pay" or "For Credit" + const [loading, setLoading] = useState("loading"); + const [compensationType, setCompensationType] = useState("Any"); const [years, setYears] = useState([]); async function fetchYears() { - const response = await fetch(`${import.meta.env.VITE_BACKEND_SERVER}/years`); + try { + const response = await fetch( + `${import.meta.env.VITE_BACKEND_SERVER}/years` + ); + + if (!response.ok) { + throw new Error("No response for years"); + } - if (response.ok) { const data = await response.json(); setYears(data); - } else { - console.log("No response for years"); + } catch (e) { + console.log(e); setLoading("no response"); } } @@ -34,7 +51,7 @@ export default function CreationForms({ edit }: CreationFormsProps) { handleSubmit, formState: { errors }, reset, - } = useForm({ + } = useForm({ defaultValues: { id: "", title: "", @@ -45,31 +62,21 @@ export default function CreationForms({ edit }: CreationFormsProps) { description: "", recommended_experience: "", location: "Select a Department", - years: [""], + years: [], }, }); - interface FormData { - id: string; - title: string; - application_due: string; - type: string; - hourlyPay: number; - credits: string[]; - description: string; - recommended_experience: string; - location: string; - years: string[]; - } - function submitHandler(data: FormData) { - console.log({ ...data }); if (edit) { - fetch(`${import.meta.env.VITE_BACKEND_SERVER}/editOpportunity/${postID}`, { - method: "PUT", - credentials: "include", - body: JSON.stringify({ ...data }), - }).then((response) => { + fetch( + `${import.meta.env.VITE_BACKEND_SERVER}/editOpportunity/${postID}`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ).then((response) => { if (response.ok) { alert("Successfully updated"); window.location.href = `/opportunity/${postID}`; @@ -81,7 +88,8 @@ export default function CreationForms({ edit }: CreationFormsProps) { fetch(`${import.meta.env.VITE_BACKEND_SERVER}/createOpportunity`, { method: "POST", credentials: "include", - body: JSON.stringify({ ...data }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }).then((response) => { if (response.ok) { alert("Successfully created"); @@ -94,18 +102,34 @@ export default function CreationForms({ edit }: CreationFormsProps) { } }); } - }; + } useEffect(() => { async function fetchEditData() { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_SERVER}/editOpportunity/${postID}`, { - credentials: "include", - } - ); - if (response.ok) { - const { id, title, application_due, type, hourlyPay, credits, description, recommended_experience, location, years } = await response.json(); + try { + const response = await fetch( + `${import.meta.env.VITE_BACKEND_SERVER}/editOpportunity/${postID}`, + { + credentials: "include", + } + ); + if (!response.ok) throw new Error("No response"); + + const { + id, + title, + application_due, + type, + hourlyPay, + credits, + description, + recommended_experience, + location, + years, + } = await response.json(); + await Promise.all([fetchYears()]); + reset({ id, title, @@ -118,14 +142,20 @@ export default function CreationForms({ edit }: CreationFormsProps) { location, years, }); + + if (type === "For Pay" || type === "For Credit" || type === "Any") { + setCompensationType(type); + } + setLoading(false); - } else { - console.log("No response"); + } catch (e) { + console.log(e); setLoading("no response"); } } fetchYears(); + if (edit) { fetchEditData(); } else { @@ -133,159 +163,157 @@ export default function CreationForms({ edit }: CreationFormsProps) { } }, [edit, postID, reset]); - return loading === false && years != null ? ( + if (loading === "loading") return

Loading...

; + if (loading === "no response") return

There was no response

; + if (!years) return

Loading...

; + + return (
{ - submitHandler(data); - })} - className="flex flex-col gap-5" // Form container for vertical layout + onSubmit={handleSubmit(submitHandler)} + className="grid grid-cols-1 lg:grid-cols-3 gap-x-6 gap-y-6" > - {/* Group 1: Horizontal layout for Title, Location, Deadline */} -
-
- -
- -
- -
+ {/* Row 1: Title / Location / Deadline */} +
+ +
-
- -
-
+
+ +
+
+ +
- {/* Compensation Type Section with Rectangular Box */} -
-
- -
+ {/* Row 2: Compensation / Pay / Credits */} +
+ +
+
-
+ For Pay + +
-
+ For Credit + +
+ Any +
+
- {/* Conditionally Render Pay Input or Credit Checkboxes */} - {compensationType === "For Pay" || compensationType === "Any" ? ( -
-
- -
-
- ) : null} - - {compensationType === "For Credit" || compensationType === "Any" ? ( -
- -
- ) : null} -
+
+ {(compensationType === "For Pay" || compensationType === "Any") && ( + + )} +
- {/* Class Year and Description aligned horizontally */} -
-
+
+ {(compensationType === "For Credit" || compensationType === "Any") && ( -
+ )} +
-
+ {/* Row 3: Years + Description/Experience */} +
+ +
+ +
-
-
- -
+
- {/* Submit button */} -
- -
+{/* Reset + Submit row */} +
+ {/* Inner wrapper controls total width and centers the pair */} +
+ {/* Reset Button */} + + + {/* Submit Button */} + +
+
+ + - ) : loading === "no response" ? ( -

There was no response

- ) : ( -

Loading...

); -}; \ No newline at end of file +} diff --git a/src/staff/pages/CreatePost.tsx b/src/staff/pages/CreatePost.tsx index 0fad0bc5..5db1995d 100644 --- a/src/staff/pages/CreatePost.tsx +++ b/src/staff/pages/CreatePost.tsx @@ -12,13 +12,34 @@ export default function CreatePost({ edit }: CreatePostProps) { if (!auth.isAuthenticated) { window.location.href = "/login"; + return null; } return ( -
- -

{edit === true ? "Edit Research Opportunity" : "Create Research Opportunity"}

- -
+
+ + +
+

+ {edit ? "Edit Research Opportunity" : "Create Research Opportunity"} +

+ + {/* Single clear card, same style as other pages */} +
+ +
+
+
); -}; +}
PositionDescriptionLocationPayCreditsLab ManagersTermViewSave
PositionDescriptionLocationPayCreditsLab ManagersTermViewSave
{opportunity.name}{opportunity.description}{opportunity.location}{opportunity.pay ? `$${opportunity.pay}/hr` : ""}{opportunity.credits}{opportunity.lab_managers} +
+ {opportunity.name} + + {opportunity.description} + + {opportunity.location} + + {opportunity.pay ? `$${opportunity.pay}/hr` : ""} + + {opportunity.credits} + + {opportunity.lab_managers} + {opportunity.semester} {opportunity.year} + - +
+ No results found.