Skip to content
138 changes: 136 additions & 2 deletions frontend/bitmatch/src/views/AddProjectPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const CREATE_PROJECT_ENDPOINT = `${SERVER_HOST}/projects/create/`;
export default function CreateProjectForm() {
const navigate = useNavigate();
const { user } = useUser();
const [interests, setInterests] = useState([]);
const [newInterest, setNewInterest] = useState("");
const [interestError, setInterestError] = useState("");
const [skills, setSkills] = useState([]);
const [newSkill, setNewSkill] = useState("");
const [skillError, setSkillError] = useState("");
const [projectName, setProjectName] = useState("");
const [university, setUniversity] = useState("");
const [group, setGroup] = useState("");
Expand Down Expand Up @@ -139,6 +145,13 @@ export default function CreateProjectForm() {
formData.append("positions", JSON.stringify(roles));
formData.append("image_url", coverImageFile);
formData.append("owner", userUuid);
skills.forEach((skill) => {
formData.append("skill_tags", skill);
});

interests.forEach((interest) => {
formData.append("interest_tags", interest);
});

try {
const response = await fetch(CREATE_PROJECT_ENDPOINT, {
Expand Down Expand Up @@ -185,8 +198,6 @@ export default function CreateProjectForm() {
} finally {
setIsLoading(false);
}

// TODO: api call to add project to user also.
};

return (
Expand Down Expand Up @@ -406,6 +417,129 @@ export default function CreateProjectForm() {
)}
</div>
</div>
<div className="mt-6">
<label className="block font-medium mb-2">
Categories (Optional)
</label>
<div className="flex items-center space-x-2 mb-2">
<input
type="text"
value={newInterest}
onChange={(e) => {
setNewInterest(e.target.value);
setInterestError("");
}}
className="flex-grow border rounded-md p-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Backend, Frontend, DevOps, AI"
/>
<button
type="button"
onClick={() => {
if (newInterest.trim()) {
setInterests((prev) => [...prev, newInterest.trim()]);
setNewInterest("");
} else {
setInterestError("Interest can't be empty.");
}
}}
className="bg-black text-white px-4 py-2 rounded-md hover:bg-gray-800 transition-colors text-sm"
>
Add Interest
</button>
</div>
{interestError && (
<p className="text-red-500 text-sm mt-1">{interestError}</p>
)}
<div className="mt-3 border rounded-md overflow-hidden">
{interests.length === 0 ? (
<p className="text-sm text-gray-500 px-4 py-3">
No interests added yet.
</p>
) : (
interests.map((interest, index) => (
<div
key={index}
className="flex items-center justify-between px-4 py-2 border-b last:border-b-0 bg-gray-50"
>
<span className="text-sm">{interest}</span>
<button
type="button"
onClick={() =>
setInterests((prev) =>
prev.filter((_, i) => i !== index)
)
}
className="text-red-500 hover:text-red-700 transition-colors font-bold"
aria-label={`Remove interest: ${interest}`}
>
&#x2715;
</button>
</div>
))
)}
</div>
</div>

<div className="mt-6">
<label className="block font-medium mb-2">
Desired Skills (Optional)
</label>
<div className="flex items-center space-x-2 mb-2">
<input
type="text"
value={newSkill}
onChange={(e) => {
setNewSkill(e.target.value);
setSkillError("");
}}
className="flex-grow border rounded-md p-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., JavaScript, Figma, Django"
/>
<button
type="button"
onClick={() => {
if (newSkill.trim()) {
setSkills((prev) => [...prev, newSkill.trim()]);
setNewSkill("");
} else {
setSkillError("Skill can't be empty.");
}
}}
className="bg-black text-white px-4 py-2 rounded-md hover:bg-gray-800 transition-colors text-sm"
>
Add Skill
</button>
</div>
{skillError && (
<p className="text-red-500 text-sm mt-1">{skillError}</p>
)}
<div className="mt-3 border rounded-md overflow-hidden">
{skills.length === 0 ? (
<p className="text-sm text-gray-500 px-4 py-3">
No skills added yet.
</p>
) : (
skills.map((skill, index) => (
<div
key={index}
className="flex items-center justify-between px-4 py-2 border-b last:border-b-0 bg-gray-50"
>
<span className="text-sm">{skill}</span>
<button
type="button"
onClick={() =>
setSkills((prev) => prev.filter((_, i) => i !== index))
}
className="text-red-500 hover:text-red-700 transition-colors font-bold"
aria-label={`Remove skill: ${skill}`}
>
&#x2715;
</button>
</div>
))
)}
</div>
</div>

{error && (
<p className="mb-4 text-red-600 bg-red-100 border border-red-400 p-3 rounded">
Expand Down
44 changes: 31 additions & 13 deletions frontend/bitmatch/src/views/HomePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ImageSlideshow from "../components/ui/ImageSlideshow";
import ProjectCarousel from "../components/ui/ProjectCarousel";
import { useNavigate } from "react-router-dom";
import { useUser } from "@clerk/clerk-react";
import { calculateMatchScores } from "./MatchScoreUtils";

const SERVER_HOST = import.meta.env.VITE_SERVER_HOST;

Expand Down Expand Up @@ -55,23 +56,40 @@ export default function Home() {

// Fetch project data on component mount
useEffect(() => {
axios
.get(`${SERVER_HOST}/projects/`)
.then((response) => {
const sortedProjects = response.data.sort(
const fetchUserAndProjects = async () => {
try {
// Fetch user data
const userResponse = await fetch(`${SERVER_HOST}/userauth/${user.id}`);
if (!userResponse.ok) {
throw new Error("Failed to fetch user data");
}
const userData = await userResponse.json();

// Fetch project data
const projectsResponse = await axios.get(`${SERVER_HOST}/projects/`);
const projects = projectsResponse.data;

// Pass both userData and projects to a function for match score calculation
const projectsWithScores = calculateMatchScores(userData, projects);

// Sort projects based on match score
const sortedProjects = projectsWithScores.sort(
(a, b) => b.match_percentage - a.match_percentage
);

// Update state with sorted projects
setProjects(sortedProjects);
console.log(sortedProjects);
} catch (error) {
console.error("Error fetching user and projects:", error);
setError("Something went wrong. Please try again later.");
} finally {
setLoading(false);
})
.catch((error) => {
console.error("Error fetching projects:", error);
setError(
"Couldn't load projects. (DEV MESSAGE: Ensure the backend server is running)"
);
setLoading(false);
});
}, []);
}
};

fetchUserAndProjects();
}, [user.id]);

// Loading state
if (loading) {
Expand Down
39 changes: 38 additions & 1 deletion frontend/bitmatch/src/views/IndividualProjectPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MemberCard } from "@/components/project/MemberCard";
import { Badge } from "@/components/ui/badge";
import { PositionCard } from "@/components/project/PositionCard";
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -535,11 +536,47 @@ const ProjectDetailPage = () => {
<div className="flex justify-between items-center mb-4">
<h2 className="text-3xl font-bold">Overview</h2>
</div>
<h2 className="text-xl font-semibold mb-4">
<h2 className="text-xl font-bold mb-4">
Background & More Details About the Project
</h2>
<div className="mb-6">
<p className="text-sm mb-8">{project.full_description}</p>

<div className="mb-4">
{project.interest_tags?.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
<h3 className="text-xl font-bold mb-4">
Project Categories
</h3>
{project.interest_tags.map((interest, index) => (
<Badge
key={index}
variant="outline"
className="bg-gray-400 hover:bg-black"
>
{interest}
</Badge>
))}
</div>
)}

{project.skill_tags?.length > 0 && (
<>
<h3 className="text-xl font-bold mb-4">Desired Skills</h3>
<div className="flex flex-wrap gap-2">
{project.skill_tags.map((skill, index) => (
<Badge
key={index}
variant="outline"
className="bg-gray-400 hover:bg-black"
>
{skill}
</Badge>
))}
</div>
</>
)}
</div>
</div>
</TabPanel>

Expand Down
91 changes: 91 additions & 0 deletions frontend/bitmatch/src/views/MatchScoreUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Compare simple string values (e.g., institution, location)
function compareValues(valueA, valueB) {
if (!valueA || !valueB) return 0;

const a =
typeof valueA === "string"
? valueA.trim().toLowerCase()
: String(valueA).toLowerCase();
const b =
typeof valueB === "string"
? valueB.trim().toLowerCase()
: String(valueB).toLowerCase();

return a === b ? 1 : 0;
}

// Compare array-type tags (e.g., interests, skills)
function compareTags(tagsA, tagsB) {
if (!Array.isArray(tagsA) || !Array.isArray(tagsB)) return 0;

const setA = new Set(tagsA.map((tag) => tag.toLowerCase()));
const setB = new Set(tagsB.map((tag) => tag.toLowerCase()));

const intersection = [...setA].filter((tag) => setB.has(tag));
return intersection.length / Math.max(setA.size, 1); // Avoid div by zero
}

// Simple deterministic hash function
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32-bit integer
}
return hash;
}

// Stable score variation based on project/user identity
function addStableVariation(score, projectId, userId) {
const combined = `${projectId}_${userId}`;
const hash = simpleHash(combined);
const variation = ((hash % 400) - 200) / 100; // Range: -2 to +2
return Math.min(100, Math.max(0, score + variation));
}

// Main match score calculation
function calculateMatchScore(project, user) {
const weights = {
institution: 0.15,
location: 0.05,
interestTags: 0.1,
skillTags: 0.5,
roles: 0.2,
};

const institutionScore = compareValues(project.institution, user.college);
const locationScore = compareValues(project.location, user.location);

let roleScore = 0;
if (project.positions?.length && Array.isArray(user.roles)) {
roleScore = Math.max(
...project.positions.map((pos) =>
user.roles.includes(pos.title) ? 1 : 0
)
);
}

const interestScore = compareTags(project.interest_tags, user.interests);
const skillScore = compareTags(project.skill_tags, user.skills);

const totalScore =
institutionScore * weights.institution +
locationScore * weights.location +
interestScore * weights.interestTags +
skillScore * weights.skillTags +
roleScore * weights.roles;

// Apply stable variation
return Math.round(addStableVariation(totalScore * 100, project.id, user.id));
}

// Entry point
export const calculateMatchScores = (userData, projects) => {
return projects.map((project) => {
const match_percentage = calculateMatchScore(project, userData);
return {
...project,
match_percentage,
};
});
};
Loading
Loading