diff --git a/app/src/App.jsx b/app/src/App.jsx index 16f47ad..bc5abf0 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -18,6 +18,7 @@ import Footer from "./components/Footer"; import LeetcodeRankings from "./components/LeetcodeRankings"; import LeetcodeRankingsCCPS from "./components/LeetcodeRankingsCCPS"; import LeetcodeGraphs from "./components/LeetcodeGraphs"; +import FriendsPage from "./components/FriendsPage.jsx"; import { AuthProvider } from "./Context/AuthContext.jsx"; import Dashboard from "./components/discussion-forum/dashboard.jsx"; import { SidebarProvider } from "./components/ui/sidebar.jsx"; @@ -186,6 +187,22 @@ function App() { } /> + + + + } + /> + {/* } /> */} } /> diff --git a/app/src/components/CodeforcesTable.jsx b/app/src/components/CodeforcesTable.jsx index 62d484b..a8e0f82 100644 --- a/app/src/components/CodeforcesTable.jsx +++ b/app/src/components/CodeforcesTable.jsx @@ -13,6 +13,25 @@ import { User, Trophy, Users, Loader2, Search,Crown } from "lucide-react"; const BACKEND = import.meta.env.VITE_BACKEND; +const readJsonIfAvailable = async (response) => { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.toLowerCase().includes("application/json")) { + const fallbackText = await response.text(); + return { + isJson: false, + data: null, + message: fallbackText || `Unexpected response (${response.status})`, + }; + } + + try { + const data = await response.json(); + return { isJson: true, data, message: null }; + } catch { + return { isJson: false, data: null, message: "Invalid JSON response" }; + } +}; + export function CFTable({ codeforcesUsers }) { let accessToken = null; try { @@ -33,6 +52,7 @@ export function CFTable({ codeforcesUsers }) { const [contestName, setContestName] = useState(""); const [contestLoaded, setContestLoaded] = useState(false); const [showFriendsInContest, setShowFriendsInContest] = useState(false); + const [friendsError, setFriendsError] = useState(""); // New states for logged in user const [loggedInUser, setLoggedInUser] = useState(null); @@ -219,51 +239,136 @@ useEffect(() => { } }; - // Get friends list (TODO: Implement backend integration) + const syncContestFriendFlags = (nextFriends) => { + setContestStandings((current) => + current.map((standing) => ({ + ...standing, + isFriend: nextFriends.includes(standing.username), + })), + ); + }; + + // Get friends list from backend const getcffriends = async () => { + if (!accessToken) { + setFriendsError(""); + setCodeforcesfriends([]); + syncContestFriendFlags([]); + return; + } + try { - // TODO: Implement actual friends list from backend - // For now, using local storage - const savedFriends = localStorage.getItem('codeforces_friends'); - if (savedFriends) { - setCodeforcesfriends(JSON.parse(savedFriends)); - } else { - setCodeforcesfriends([]); + const response = await fetch(BACKEND + "/codeforcesFL/", { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + accessToken, + }, + }); + const parsed = await readJsonIfAvailable(response); + if (!response.ok || !parsed.isJson) { + console.error("Failed to fetch Codeforces friends:", parsed.message); + setFriendsError(parsed.message || "Could not refresh friends right now."); + return; } + const newData = parsed.data; + const nextFriends = Array.isArray(newData) ? newData : []; + setFriendsError(""); + setCodeforcesfriends(nextFriends); + syncContestFriendFlags(nextFriends); } catch (error) { - console.error("Error fetching friends:", error); - setCodeforcesfriends([]); + console.error("Failed to fetch Codeforces friends:", error); + setFriendsError("Could not refresh friends right now. Showing last known data."); } }; // Friend functions async function addfriend(username) { - if (!isAuthenticated) { + if (!accessToken) { alert("Please login to add friends."); return; } - const currentFriends = Array.isArray(codeforcesfriends) ? codeforcesfriends : []; - if (!currentFriends.includes(username)) { - const updatedFriends = [...currentFriends, username]; - setCodeforcesfriends(updatedFriends); - localStorage.setItem('codeforces_friends', JSON.stringify(updatedFriends)); + + if (codeforcesfriends.includes(username)) { + return; + } + + try { + const response = await fetch(BACKEND + "/codeforcesFA/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + accessToken, + }, + body: JSON.stringify({ + friendName: username, + }), + }); + const parsed = await readJsonIfAvailable(response); + if (!response.ok) { + console.error("Failed to add Codeforces friend:", parsed.message); + alert(parsed.message || "Failed to add friend. Please try again."); + return; + } + setFriendsError(""); + setCodeforcesfriends((current) => { + const nextFriends = current.includes(username) + ? current + : [...current, username]; + syncContestFriendFlags(nextFriends); + return nextFriends; + }); + } catch (error) { + console.error("Failed to add Codeforces friend:", error); + alert("Failed to add friend. Please check your connection and try again."); } } async function dropfriend(username) { - if (!isAuthenticated) { + if (!accessToken) { alert("Please login to remove friends."); return; } - const currentFriends = Array.isArray(codeforcesfriends) ? codeforcesfriends : []; - const updatedFriends = currentFriends.filter((friend) => friend !== username); - setCodeforcesfriends(updatedFriends); - localStorage.setItem('codeforces_friends', JSON.stringify(updatedFriends)); + + try { + const response = await fetch(BACKEND + "/codeforcesFD/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + accessToken, + }, + body: JSON.stringify({ + friendName: username, + }), + }); + const parsed = await readJsonIfAvailable(response); + if (!response.ok) { + console.error("Failed to remove Codeforces friend:", parsed.message); + alert(parsed.message || "Failed to remove friend. Please try again."); + return; + } + setFriendsError(""); + setCodeforcesfriends((current) => { + const nextFriends = current.filter((friendName) => friendName !== username); + syncContestFriendFlags(nextFriends); + return nextFriends; + }); + } catch (error) { + console.error("Failed to remove Codeforces friend:", error); + alert("Failed to remove friend. Please check your connection and try again."); + } } useEffect(() => { - getcffriends(); - }, []); + if (isAuthenticated) { + getcffriends(); + } else { + setFriendsError(""); + setCodeforcesfriends([]); + syncContestFriendFlags([]); + setShowFriendsInContest(false); + } + }, [isAuthenticated]); // Filter logic for friends tab useEffect(() => { @@ -289,7 +394,6 @@ useEffect(() => { } } - // TODO: Sort by rating (implement when backend is ready) usersToDisplay.sort((a, b) => b.rating - a.rating); setFilteredusers(usersToDisplay); @@ -646,7 +750,6 @@ useEffect(() => {

Friends Leaderboard

- {/* TODO: Sort by rating when backend is implemented */} Sorted by rating @@ -669,6 +772,11 @@ useEffect(() => { ) : filteredusers.length > 0 ? ( <> + {friendsError ? ( +
+ {friendsError} +
+ ) : null}
@@ -678,9 +786,7 @@ useEffect(() => { {loggedInUser && " (including you)"}
-
- TODO: Implement rating-based sorting from backend -
+
Sorted by rating
`https://codeforces.com/profile/${username}`, + rankLabel: "Rating Rank", + metricLabel: "Rating", + metricValue: (user) => user.rating ?? 0, + details: (user) => `Max ${user.max_rating ?? 0} | Solved ${user.total_solved ?? 0}`, + sortUsers: (users) => + [...users].sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)), + }, + { + key: "codechef", + title: "CodeChef", + endpoint: "/codechefFL/", + removeEndpoint: "/codechefFD/", + profile: (username) => `https://www.codechef.com/users/${username}`, + rankLabel: "Rating Rank", + metricLabel: "Rating", + metricValue: (user) => user.rating ?? 0, + details: (user) => + `Global ${user.Global_rank ?? "N/A"} | Country ${user.Country_rank ?? "N/A"}`, + sortUsers: (users) => + [...users].sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)), + }, + { + key: "leetcode", + title: "LeetCode", + endpoint: "/leetcodeFL/", + removeEndpoint: "/leetcodeFD/", + profile: (username) => `https://leetcode.com/u/${username}`, + rankLabel: "Global Rank", + metricLabel: "Solved", + metricValue: (user) => user.total_solved ?? 0, + details: (user) => `Platform rank ${user.ranking ?? "N/A"}`, + sortUsers: (users) => + [...users].sort((a, b) => (a.ranking ?? Infinity) - (b.ranking ?? Infinity)), + }, + { + key: "github", + title: "GitHub", + endpoint: "/githubFL/", + removeEndpoint: "/githubFD/", + profile: (username) => `https://github.com/${username}`, + rankLabel: "Contribution Rank", + metricLabel: "Contributions", + metricValue: (user) => user.contributions ?? 0, + details: (user) => `Repos ${user.repositories ?? 0} | Stars ${user.stars ?? 0}`, + sortUsers: (users) => + [...users].sort((a, b) => (b.contributions ?? 0) - (a.contributions ?? 0)), + }, + { + key: "openlake", + title: "OpenLake", + endpoint: "/openlakeFL/", + removeEndpoint: "/openlakeFD/", + profile: (username) => `https://github.com/${username}`, + rankLabel: "Contribution Rank", + metricLabel: "Contributions", + metricValue: (user) => user.contributions ?? 0, + details: () => "OpenLake contributors leaderboard", + sortUsers: (users) => + [...users].sort((a, b) => (b.contributions ?? 0) - (a.contributions ?? 0)), + }, +]; + +const PLATFORM_CONFIG_BY_KEY = PLATFORM_CONFIG.reduce((acc, platform) => { + acc[platform.key] = platform; + return acc; +}, {}); + +const readJsonIfAvailable = async (response) => { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.toLowerCase().includes("application/json")) { + const fallbackText = await response.text(); + return { + isJson: false, + data: null, + message: fallbackText || `Unexpected response (${response.status})`, + }; + } + + try { + const data = await response.json(); + return { isJson: true, data, message: null }; + } catch { + return { isJson: false, data: null, message: "Invalid JSON response" }; + } +}; + +const mapFromUsers = (users) => + new Map((Array.isArray(users) ? users : []).map((user) => [user.username, user])); + +const findPlatformFriends = (friendNames, users, sortUsers) => { + const usersMap = mapFromUsers(users); + const sorted = sortUsers(Array.isArray(users) ? users : []); + const rankingMap = new Map(); + sorted.forEach((user, idx) => { + rankingMap.set(user.username, idx + 1); + }); + + return friendNames + .map((name) => usersMap.get(name)) + .filter(Boolean) + .map((user) => ({ + ...user, + rank: rankingMap.get(user.username) ?? null, + })); +}; + +export default function FriendsPage({ + codeforcesUsers, + codechefUsers, + leetcodeUsers, + githubUsers, + openlakeUsers, +}) { + const { open, isMobile } = useSidebar(); + const { userNames, authTokens } = useAuth(); + const [friendsByPlatform, setFriendsByPlatform] = useState({ + codeforces: [], + codechef: [], + leetcode: [], + github: [], + openlake: [], + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const accessToken = authTokens?.access || null; + const isAuthenticated = Boolean(accessToken); + + const refreshFriends = async () => { + if (!accessToken) { + setFriendsByPlatform({ + codeforces: [], + codechef: [], + leetcode: [], + github: [], + openlake: [], + }); + return; + } + + setLoading(true); + setError(""); + try { + const responses = await Promise.allSettled( + PLATFORM_CONFIG.map((platform) => + fetch(BACKEND + platform.endpoint, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + accessToken, + }, + }), + ), + ); + + const parsed = await Promise.all( + responses.map(async (responseResult) => { + if (responseResult.status !== "fulfilled") { + return null; + } + return readJsonIfAvailable(responseResult.value); + }), + ); + + let hadFailure = false; + setFriendsByPlatform((previous) => { + const nextState = { ...previous }; + + PLATFORM_CONFIG.forEach((platform, idx) => { + const responseResult = responses[idx]; + const result = parsed[idx]; + if (responseResult.status !== "fulfilled" || !result) { + hadFailure = true; + return; + } + const response = responseResult.value; + if (!response.ok || !result.isJson || !Array.isArray(result.data)) { + hadFailure = true; + return; + } + nextState[platform.key] = result.data; + }); + + return nextState; + }); + if (hadFailure) { + setError("Some friends lists could not be loaded. Showing last successful data."); + } + } catch { + setError("Unable to load friends right now. Please try again."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshFriends(); + }, [isAuthenticated]); + + const platformFriendRows = useMemo( + () => ({ + codeforces: findPlatformFriends( + friendsByPlatform.codeforces, + codeforcesUsers, + PLATFORM_CONFIG_BY_KEY.codeforces.sortUsers, + ), + codechef: findPlatformFriends( + friendsByPlatform.codechef, + codechefUsers, + PLATFORM_CONFIG_BY_KEY.codechef.sortUsers, + ), + leetcode: findPlatformFriends( + friendsByPlatform.leetcode, + leetcodeUsers, + PLATFORM_CONFIG_BY_KEY.leetcode.sortUsers, + ), + github: findPlatformFriends( + friendsByPlatform.github, + githubUsers, + PLATFORM_CONFIG_BY_KEY.github.sortUsers, + ), + openlake: findPlatformFriends( + friendsByPlatform.openlake, + openlakeUsers, + PLATFORM_CONFIG_BY_KEY.openlake.sortUsers, + ), + }), + [friendsByPlatform, codeforcesUsers, codechefUsers, leetcodeUsers, githubUsers, openlakeUsers], + ); + + const removeFriend = async (platformKey, username) => { + if (!accessToken) { + return; + } + const platform = PLATFORM_CONFIG.find((item) => item.key === platformKey); + if (!platform) { + return; + } + + setError(""); + try { + const response = await fetch(BACKEND + platform.removeEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + accessToken, + }, + body: JSON.stringify({ friendName: username }), + }); + if (!response.ok) { + setError("Could not remove friend. Please try again."); + return; + } + + setFriendsByPlatform((current) => ({ + ...current, + [platformKey]: current[platformKey].filter((friend) => friend !== username), + })); + } catch { + setError("Could not remove friend. Please check your connection and try again."); + } + }; + + return ( +
+
+
+
Friends
+

+ {userNames?.username + ? `${userNames.username}, manage your friends across all leaderboards.` + : "Manage your friends across all leaderboards."} +

+
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {PLATFORM_CONFIG.map((platform) => { + const rows = platformFriendRows[platform.key] || []; + return ( +
+
+

{platform.title}

+ + {rows.length} friend{rows.length === 1 ? "" : "s"} + +
+ + {rows.length === 0 ? ( +

+ No friends added on {platform.title} yet. +

+ ) : ( +
+ + + + + + + + + + + + {rows.map((user) => ( + + + + + + + + ))} + +
{platform.rankLabel}Username{platform.metricLabel}DetailsAction
#{user.rank ?? "N/A"} + + {user.username} + + {platform.metricValue(user)} + {platform.details(user)} + + +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/app/src/components/Navbar.jsx b/app/src/components/Navbar.jsx index d6986b1..3462b7f 100644 --- a/app/src/components/Navbar.jsx +++ b/app/src/components/Navbar.jsx @@ -49,7 +49,7 @@ const items = [ }, { title: "Friends", - url: "/", + url: "/friends", icon: Users, }, { @@ -148,4 +148,4 @@ export const Navbar = () => { ); -}; \ No newline at end of file +};