feat(skillrack): add SkillRack leaderboard with server-side login, AES-encrypted credential storage, Jsoup HTML scraping, daily auto-sync, and full frontend tab with registration modal
Some checks failed
Deploy RIT Freshers Hub to VPS / Deploy to Live VPS (push) Has been cancelled
Some checks failed
Deploy RIT Freshers Hub to VPS / Deploy to Live VPS (push) Has been cancelled
This commit is contained in:
@@ -69,6 +69,13 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jsoup HTML Parser for SkillRack profile scraping -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring Boot Starter Test -->
|
<!-- Spring Boot Starter Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.rit.portal.controller;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.SkillrackProfile;
|
||||||
|
import com.rit.portal.service.SkillrackService;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/skillrack")
|
||||||
|
@CrossOrigin(originPatterns = "*")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SkillrackController {
|
||||||
|
|
||||||
|
private final SkillrackService skillrackService;
|
||||||
|
|
||||||
|
@GetMapping("/leaderboard")
|
||||||
|
public List<SkillrackProfile> getLeaderboard() {
|
||||||
|
return skillrackService.getAllRankedProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> registerProfile(@RequestBody RegisterRequest request) {
|
||||||
|
if (request.getSkillrackEmail() == null || request.getSkillrackEmail().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "SkillRack email is required"));
|
||||||
|
}
|
||||||
|
if (request.getSkillrackPassword() == null || request.getSkillrackPassword().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "SkillRack password is required"));
|
||||||
|
}
|
||||||
|
if (request.getStudentName() == null || request.getStudentName().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Student name is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SkillrackProfile profile = skillrackService.registerOrUpdateStudent(
|
||||||
|
request.getStudentName(),
|
||||||
|
request.getSkillrackEmail(),
|
||||||
|
request.getSkillrackPassword(),
|
||||||
|
request.getDepartment() != null ? request.getDepartment() : "CSE",
|
||||||
|
request.getYear() != null ? request.getYear() : "1st Year"
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(profile);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "Failed to register profile: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sync")
|
||||||
|
public ResponseEntity<?> triggerSync() {
|
||||||
|
skillrackService.syncAllProfilesWithSpacing();
|
||||||
|
return ResponseEntity.ok(Map.of("message", "SkillRack background sync initiated with 5-second spacing."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class RegisterRequest {
|
||||||
|
private String studentName;
|
||||||
|
private String skillrackEmail;
|
||||||
|
private String skillrackPassword;
|
||||||
|
private String department;
|
||||||
|
private String year;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.rit.portal.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "skillrack_profiles")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class SkillrackProfile {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(name = "student_name", nullable = false)
|
||||||
|
private String studentName;
|
||||||
|
|
||||||
|
@Column(name = "skillrack_email", nullable = false, unique = true)
|
||||||
|
private String skillrackEmail;
|
||||||
|
|
||||||
|
@Column(name = "encrypted_password", nullable = false, length = 512)
|
||||||
|
private String encryptedPassword;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String department;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String year;
|
||||||
|
|
||||||
|
@Column(name = "total_points")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer totalPoints = 0;
|
||||||
|
|
||||||
|
@Column(name = "code_test_solved")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer codeTestSolved = 0;
|
||||||
|
|
||||||
|
@Column(name = "code_tutor_solved")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer codeTutorSolved = 0;
|
||||||
|
|
||||||
|
@Column(name = "code_track_solved")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer codeTrackSolved = 0;
|
||||||
|
|
||||||
|
@Column(name = "dc_solved")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer dcSolved = 0;
|
||||||
|
|
||||||
|
@Column(name = "gold_medals")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer goldMedals = 0;
|
||||||
|
|
||||||
|
@Column(name = "silver_medals")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer silverMedals = 0;
|
||||||
|
|
||||||
|
@Column(name = "bronze_medals")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer bronzeMedals = 0;
|
||||||
|
|
||||||
|
@Column(name = "last_updated")
|
||||||
|
private LocalDateTime lastUpdated;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||||
|
if (lastUpdated == null) lastUpdated = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.rit.portal.repository;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.SkillrackProfile;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SkillrackProfileRepository extends JpaRepository<SkillrackProfile, Integer> {
|
||||||
|
Optional<SkillrackProfile> findBySkillrackEmailIgnoreCase(String skillrackEmail);
|
||||||
|
List<SkillrackProfile> findAllByOrderByTotalPointsDesc();
|
||||||
|
boolean existsBySkillrackEmailIgnoreCase(String skillrackEmail);
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package com.rit.portal.service;
|
||||||
|
|
||||||
|
import com.rit.portal.entity.SkillrackProfile;
|
||||||
|
import com.rit.portal.repository.SkillrackProfileRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jsoup.Connection;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.jsoup.select.Elements;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class SkillrackService {
|
||||||
|
|
||||||
|
private final SkillrackProfileRepository repository;
|
||||||
|
|
||||||
|
// AES-128 key (16 bytes) — in production, move to environment variable
|
||||||
|
private static final String AES_KEY = "RitSkillR@ck2026";
|
||||||
|
private static final String SKILLRACK_BASE = "https://www.skillrack.com";
|
||||||
|
private static final String LOGIN_URL = SKILLRACK_BASE + "/faces/candidate/profileview.xhtml";
|
||||||
|
private static final String PROFILE_URL = SKILLRACK_BASE + "/faces/candidate/profileview.xhtml";
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public List<SkillrackProfile> getAllRankedProfiles() {
|
||||||
|
return repository.findAllByOrderByTotalPointsDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SkillrackProfile registerOrUpdateStudent(String studentName, String email, String password,
|
||||||
|
String department, String year) {
|
||||||
|
String cleanEmail = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
SkillrackProfile profile = repository.findBySkillrackEmailIgnoreCase(cleanEmail)
|
||||||
|
.orElse(SkillrackProfile.builder()
|
||||||
|
.studentName(studentName.trim())
|
||||||
|
.skillrackEmail(cleanEmail)
|
||||||
|
.department(department.trim())
|
||||||
|
.year(year.trim())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
profile.setStudentName(studentName.trim());
|
||||||
|
profile.setDepartment(department.trim());
|
||||||
|
profile.setYear(year.trim());
|
||||||
|
profile.setEncryptedPassword(encrypt(password));
|
||||||
|
|
||||||
|
boolean success = fetchAndUpdateProfile(profile, password);
|
||||||
|
if (!success) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Could not authenticate with SkillRack using '" + cleanEmail +
|
||||||
|
"'. Please verify your email and password are correct.");
|
||||||
|
}
|
||||||
|
return repository.save(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean fetchAndUpdateProfile(SkillrackProfile profile) {
|
||||||
|
String password = decrypt(profile.getEncryptedPassword());
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
log.warn("Cannot decrypt password for SkillRack profile: {}", profile.getSkillrackEmail());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fetchAndUpdateProfile(profile, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core scraping method:
|
||||||
|
* 1. GET the SkillRack login page to obtain JSESSIONID cookie + ViewState
|
||||||
|
* 2. POST login form with email + password + ViewState
|
||||||
|
* 3. Parse the resulting profile/dashboard page for stats
|
||||||
|
*/
|
||||||
|
public boolean fetchAndUpdateProfile(SkillrackProfile profile, String rawPassword) {
|
||||||
|
try {
|
||||||
|
log.info("Fetching SkillRack stats for: {}", profile.getSkillrackEmail());
|
||||||
|
|
||||||
|
// Step 1: GET login page to get session cookie + ViewState
|
||||||
|
Connection.Response loginPageRes = Jsoup.connect(LOGIN_URL)
|
||||||
|
.method(Connection.Method.GET)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.timeout(15000)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
Map<String, String> cookies = new HashMap<>(loginPageRes.cookies());
|
||||||
|
Document loginDoc = loginPageRes.parse();
|
||||||
|
|
||||||
|
// Extract ViewState from hidden input
|
||||||
|
String viewState = "";
|
||||||
|
Element vsEl = loginDoc.selectFirst("input[name=javax.faces.ViewState]");
|
||||||
|
if (vsEl != null) {
|
||||||
|
viewState = vsEl.attr("value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the login form and its ID
|
||||||
|
Element loginForm = loginDoc.selectFirst("form[id]");
|
||||||
|
String formId = loginForm != null ? loginForm.attr("id") : "j_idt4";
|
||||||
|
|
||||||
|
// Step 2: POST login credentials
|
||||||
|
Map<String, String> formData = new LinkedHashMap<>();
|
||||||
|
formData.put(formId, formId);
|
||||||
|
formData.put("javax.faces.ViewState", viewState);
|
||||||
|
|
||||||
|
// Find email/username and password input fields
|
||||||
|
Elements inputs = loginDoc.select("input[type=text], input[type=email], input[type=password]");
|
||||||
|
String emailFieldName = formId + ":j_idt6";
|
||||||
|
String passwordFieldName = formId + ":j_idt8";
|
||||||
|
|
||||||
|
for (Element input : inputs) {
|
||||||
|
String name = input.attr("name");
|
||||||
|
String type = input.attr("type");
|
||||||
|
if (type.equals("text") || type.equals("email")) {
|
||||||
|
emailFieldName = name;
|
||||||
|
} else if (type.equals("password")) {
|
||||||
|
passwordFieldName = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.put(emailFieldName, profile.getSkillrackEmail());
|
||||||
|
formData.put(passwordFieldName, rawPassword);
|
||||||
|
|
||||||
|
// Find submit button name
|
||||||
|
Element submitBtn = loginDoc.selectFirst("input[type=submit], button[type=submit]");
|
||||||
|
if (submitBtn != null && submitBtn.hasAttr("name")) {
|
||||||
|
formData.put(submitBtn.attr("name"), submitBtn.attr("value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String formAction = LOGIN_URL;
|
||||||
|
if (loginForm != null && loginForm.hasAttr("action")) {
|
||||||
|
String action = loginForm.attr("action");
|
||||||
|
if (action.startsWith("http")) {
|
||||||
|
formAction = action;
|
||||||
|
} else {
|
||||||
|
formAction = SKILLRACK_BASE + action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connection.Response loginRes = Jsoup.connect(formAction)
|
||||||
|
.method(Connection.Method.POST)
|
||||||
|
.cookies(cookies)
|
||||||
|
.data(formData)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("Referer", LOGIN_URL)
|
||||||
|
.followRedirects(true)
|
||||||
|
.timeout(15000)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
cookies.putAll(loginRes.cookies());
|
||||||
|
|
||||||
|
// Step 3: Fetch the profile/dashboard page with authenticated session
|
||||||
|
Connection.Response profileRes = Jsoup.connect(PROFILE_URL)
|
||||||
|
.method(Connection.Method.GET)
|
||||||
|
.cookies(cookies)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.followRedirects(true)
|
||||||
|
.timeout(15000)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
cookies.putAll(profileRes.cookies());
|
||||||
|
Document profileDoc = profileRes.parse();
|
||||||
|
|
||||||
|
// Check if we're still on the login page (auth failed)
|
||||||
|
String bodyText = profileDoc.body().text();
|
||||||
|
if (bodyText.contains("Incorrect email") || bodyText.contains("Login") && !bodyText.contains("Total Points")) {
|
||||||
|
// Try alternative dashboard URL
|
||||||
|
Connection.Response dashRes = Jsoup.connect(SKILLRACK_BASE + "/faces/candidate/manageprofile.xhtml")
|
||||||
|
.method(Connection.Method.GET)
|
||||||
|
.cookies(cookies)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.followRedirects(true)
|
||||||
|
.timeout(15000)
|
||||||
|
.execute();
|
||||||
|
cookies.putAll(dashRes.cookies());
|
||||||
|
profileDoc = dashRes.parse();
|
||||||
|
bodyText = profileDoc.body().text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Parse profile stats from the HTML
|
||||||
|
boolean parsed = parseProfileStats(profileDoc, profile);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
// Fallback: Try to extract from raw text with regex patterns
|
||||||
|
parsed = parseFromRawText(bodyText, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
profile.setLastUpdated(LocalDateTime.now());
|
||||||
|
log.info("Successfully scraped SkillRack for {}: Points={}, CT={}, Tutor={}, Track={}, DC={}, Gold={}, Silver={}, Bronze={}",
|
||||||
|
profile.getSkillrackEmail(), profile.getTotalPoints(),
|
||||||
|
profile.getCodeTestSolved(), profile.getCodeTutorSolved(),
|
||||||
|
profile.getCodeTrackSolved(), profile.getDcSolved(),
|
||||||
|
profile.getGoldMedals(), profile.getSilverMedals(), profile.getBronzeMedals());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Could not parse SkillRack profile data for: {}", profile.getSkillrackEmail());
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to fetch SkillRack data for {}: {}", profile.getSkillrackEmail(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML Parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private boolean parseProfileStats(Document doc, SkillrackProfile profile) {
|
||||||
|
boolean foundAnything = false;
|
||||||
|
String fullText = doc.body().text();
|
||||||
|
|
||||||
|
// Try to find stats from structured HTML elements (cards, spans, divs)
|
||||||
|
// SkillRack typically shows stats in card-like containers
|
||||||
|
|
||||||
|
// Look for numbers near keywords
|
||||||
|
foundAnything = parseFromRawText(fullText, profile);
|
||||||
|
|
||||||
|
// Also try parsing from any table structures
|
||||||
|
Elements tables = doc.select("table");
|
||||||
|
for (Element table : tables) {
|
||||||
|
Elements rows = table.select("tr");
|
||||||
|
for (Element row : rows) {
|
||||||
|
Elements cells = row.select("td, th");
|
||||||
|
if (cells.size() >= 2) {
|
||||||
|
String label = cells.get(0).text().trim().toLowerCase();
|
||||||
|
String value = cells.get(cells.size() - 1).text().trim();
|
||||||
|
int num = parseIntSafe(value);
|
||||||
|
|
||||||
|
if (label.contains("code test") || label.contains("codetest")) {
|
||||||
|
profile.setCodeTestSolved(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("code tutor") || label.contains("codetutor")) {
|
||||||
|
profile.setCodeTutorSolved(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("code track") || label.contains("codetrack")) {
|
||||||
|
profile.setCodeTrackSolved(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("daily challenge") || label.contains("dc")) {
|
||||||
|
profile.setDcSolved(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("gold")) {
|
||||||
|
profile.setGoldMedals(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("silver")) {
|
||||||
|
profile.setSilverMedals(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("bronze")) {
|
||||||
|
profile.setBronzeMedals(num);
|
||||||
|
foundAnything = true;
|
||||||
|
} else if (label.contains("total") && label.contains("point")) {
|
||||||
|
profile.setTotalPoints(num);
|
||||||
|
foundAnything = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundAnything;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean parseFromRawText(String text, SkillrackProfile profile) {
|
||||||
|
boolean found = false;
|
||||||
|
|
||||||
|
// Pattern: "Code Test 123" or "CodeTest: 123" or "Code Test Solved: 123"
|
||||||
|
found |= extractAndSet(text, "(?i)code\\s*test[^\\d]*(\\d+)", val -> profile.setCodeTestSolved(val));
|
||||||
|
found |= extractAndSet(text, "(?i)code\\s*tutor[^\\d]*(\\d+)", val -> profile.setCodeTutorSolved(val));
|
||||||
|
found |= extractAndSet(text, "(?i)code\\s*track[^\\d]*(\\d+)", val -> profile.setCodeTrackSolved(val));
|
||||||
|
found |= extractAndSet(text, "(?i)daily\\s*challenge[^\\d]*(\\d+)", val -> profile.setDcSolved(val));
|
||||||
|
found |= extractAndSet(text, "(?i)(?:dc|DC)[^\\d]*(\\d+)", val -> {
|
||||||
|
if (profile.getDcSolved() == 0) profile.setDcSolved(val);
|
||||||
|
});
|
||||||
|
found |= extractAndSet(text, "(?i)gold[^\\d]*(\\d+)", val -> profile.setGoldMedals(val));
|
||||||
|
found |= extractAndSet(text, "(?i)silver[^\\d]*(\\d+)", val -> profile.setSilverMedals(val));
|
||||||
|
found |= extractAndSet(text, "(?i)bronze[^\\d]*(\\d+)", val -> profile.setBronzeMedals(val));
|
||||||
|
found |= extractAndSet(text, "(?i)total\\s*(?:point|score)s?[^\\d]*(\\d+)", val -> profile.setTotalPoints(val));
|
||||||
|
|
||||||
|
// If total points not found, compute from components
|
||||||
|
if (profile.getTotalPoints() == 0 && found) {
|
||||||
|
int computed = profile.getCodeTestSolved() + profile.getCodeTutorSolved()
|
||||||
|
+ profile.getCodeTrackSolved() + profile.getDcSolved();
|
||||||
|
if (computed > 0) {
|
||||||
|
profile.setTotalPoints(computed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean extractAndSet(String text, String regex, java.util.function.IntConsumer setter) {
|
||||||
|
Matcher m = Pattern.compile(regex).matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
int val = parseIntSafe(m.group(1));
|
||||||
|
if (val > 0) {
|
||||||
|
setter.accept(val);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseIntSafe(String s) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s.replaceAll("[^\\d]", ""));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AES Encryption Helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
private String encrypt(String plainText) {
|
||||||
|
try {
|
||||||
|
SecretKeySpec key = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||||
|
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(encrypted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AES encryption failed: {}", e.getMessage());
|
||||||
|
throw new RuntimeException("Encryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String decrypt(String encryptedBase64) {
|
||||||
|
try {
|
||||||
|
SecretKeySpec key = new SecretKeySpec(AES_KEY.getBytes(StandardCharsets.UTF_8), "AES");
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(encryptedBase64);
|
||||||
|
byte[] decrypted = cipher.doFinal(decoded);
|
||||||
|
return new String(decrypted, StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AES decryption failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scheduled & Manual Sync ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic daily sync at 2:30 AM — spaced 5s apart to avoid rate limits
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 30 2 * * ?")
|
||||||
|
public void scheduledDailySync() {
|
||||||
|
log.info("Starting scheduled daily SkillRack sync...");
|
||||||
|
syncAllProfilesWithSpacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual or scheduled trigger to sync all profiles with 5-second delays.
|
||||||
|
*/
|
||||||
|
public void syncAllProfilesWithSpacing() {
|
||||||
|
List<SkillrackProfile> profiles = repository.findAll();
|
||||||
|
log.info("Queued {} SkillRack profiles for sync.", profiles.size());
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
for (int i = 0; i < profiles.size(); i++) {
|
||||||
|
SkillrackProfile p = profiles.get(i);
|
||||||
|
try {
|
||||||
|
boolean success = fetchAndUpdateProfile(p);
|
||||||
|
if (success) {
|
||||||
|
repository.save(p);
|
||||||
|
}
|
||||||
|
if (i < profiles.size() - 1) {
|
||||||
|
log.info("Waiting 5s before next SkillRack profile sync...");
|
||||||
|
Thread.sleep(5000);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("SkillRack sync interrupted.");
|
||||||
|
break;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error syncing SkillRack profile {}: {}", p.getSkillrackEmail(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Finished SkillRack sync for all profiles.");
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Trophy, Search, Plus, RefreshCw, ExternalLink, Code2,
|
Trophy, Search, Plus, RefreshCw, ExternalLink, Code2,
|
||||||
Sparkles, Flame, Award, CheckCircle2, Shield, ShieldCheck, User, Filter, X, Clock, Lock, Calendar,
|
Sparkles, Flame, Award, CheckCircle2, Shield, ShieldCheck, User, Filter, X, Clock, Lock, Calendar,
|
||||||
Upload, FileText, Check, Database, Cpu, Cloud, ShieldAlert, GraduationCap,
|
Upload, FileText, Check, Database, Cpu, Cloud, ShieldAlert, GraduationCap,
|
||||||
Eye, Trash2, CheckCheck, ChevronDown, Download
|
Eye, Trash2, CheckCheck, ChevronDown, Download, Zap, Target, Mail, KeyRound
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getBackendUrl } from '@/lib/utils';
|
import { getBackendUrl } from '@/lib/utils';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -28,6 +28,23 @@ interface LeetcodeProfile {
|
|||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkillrackProfile {
|
||||||
|
id: number;
|
||||||
|
studentName: string;
|
||||||
|
skillrackEmail: string;
|
||||||
|
department: string;
|
||||||
|
year: string;
|
||||||
|
totalPoints: number;
|
||||||
|
codeTestSolved: number;
|
||||||
|
codeTutorSolved: number;
|
||||||
|
codeTrackSolved: number;
|
||||||
|
dcSolved: number;
|
||||||
|
goldMedals: number;
|
||||||
|
silverMedals: number;
|
||||||
|
bronzeMedals: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type CertDomain =
|
export type CertDomain =
|
||||||
| 'AI & Machine Learning'
|
| 'AI & Machine Learning'
|
||||||
| 'Database & MongoDB'
|
| 'Database & MongoDB'
|
||||||
@@ -308,8 +325,8 @@ function CustomFormSelect({
|
|||||||
export default function LeetcodeLeaderboard() {
|
export default function LeetcodeLeaderboard() {
|
||||||
const { user, isAuthenticated, openAuthModal } = useAuth();
|
const { user, isAuthenticated, openAuthModal } = useAuth();
|
||||||
|
|
||||||
// Top Feature Switcher State (SkillRack removed per request)
|
// Top Feature Switcher State
|
||||||
const [activeMainTab, setActiveMainTab] = useState<'leetcode' | 'certifications'>('leetcode');
|
const [activeMainTab, setActiveMainTab] = useState<'leetcode' | 'skillrack' | 'certifications'>('leetcode');
|
||||||
|
|
||||||
// Shared Filters State
|
// Shared Filters State
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -333,7 +350,23 @@ export default function LeetcodeLeaderboard() {
|
|||||||
const [lcYear, setLcYear] = useState('1st Year');
|
const [lcYear, setLcYear] = useState('1st Year');
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// 2. Certifications Vault Tab State
|
// 2. SkillRack Tab State
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
const [skillrackProfiles, setSkillrackProfiles] = useState<SkillrackProfile[]>([]);
|
||||||
|
const [skillrackLoading, setSkillrackLoading] = useState(false);
|
||||||
|
const [showSkillrackModal, setShowSkillrackModal] = useState(false);
|
||||||
|
const [isSkillrackSubmitting, setIsSkillrackSubmitting] = useState(false);
|
||||||
|
const [isSkillrackSyncing, setIsSkillrackSyncing] = useState(false);
|
||||||
|
|
||||||
|
// SkillRack Form
|
||||||
|
const [srStudentName, setSrStudentName] = useState('');
|
||||||
|
const [srEmail, setSrEmail] = useState('');
|
||||||
|
const [srPassword, setSrPassword] = useState('');
|
||||||
|
const [srDept, setSrDept] = useState('CSE');
|
||||||
|
const [srYear, setSrYear] = useState('1st Year');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 3. Certifications Vault Tab State
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
const [certifications, setCertifications] = useState<StudentCertification[]>(() => {
|
const [certifications, setCertifications] = useState<StudentCertification[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -360,10 +393,11 @@ export default function LeetcodeLeaderboard() {
|
|||||||
const [certFile, setCertFile] = useState<File | null>(null);
|
const [certFile, setCertFile] = useState<File | null>(null);
|
||||||
const [certFileDataUrl, setCertFileDataUrl] = useState<string | null>(null);
|
const [certFileDataUrl, setCertFileDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch LeetCode Leaderboard on Mount if Auth
|
// Fetch LeetCode + SkillRack Leaderboard on Mount if Auth
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchLeetcodeLeaderboard();
|
fetchLeetcodeLeaderboard();
|
||||||
|
fetchSkillrackLeaderboard();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
@@ -380,8 +414,10 @@ export default function LeetcodeLeaderboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
setLcStudentName(user.name);
|
setLcStudentName(user.name);
|
||||||
|
setSrStudentName(user.name);
|
||||||
if (user.department) {
|
if (user.department) {
|
||||||
setLcDept(user.department);
|
setLcDept(user.department);
|
||||||
|
setSrDept(user.department);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -450,6 +486,76 @@ export default function LeetcodeLeaderboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// SkillRack Fetch & Register Handlers
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchSkillrackLeaderboard = async () => {
|
||||||
|
setSkillrackLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(getBackendUrl('/api/skillrack/leaderboard'));
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSkillrackProfiles(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch SkillRack leaderboard:', err);
|
||||||
|
} finally {
|
||||||
|
setSkillrackLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterSkillrack = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!srStudentName.trim() || !srEmail.trim() || !srPassword.trim()) return;
|
||||||
|
|
||||||
|
setIsSkillrackSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(getBackendUrl('/api/skillrack/register'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
studentName: srStudentName,
|
||||||
|
skillrackEmail: srEmail,
|
||||||
|
skillrackPassword: srPassword,
|
||||||
|
department: srDept,
|
||||||
|
year: srYear,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setToastMessage('✅ SkillRack profile added! Stats fetched successfully.');
|
||||||
|
setShowSkillrackModal(false);
|
||||||
|
setSrEmail('');
|
||||||
|
setSrPassword('');
|
||||||
|
fetchSkillrackLeaderboard();
|
||||||
|
} else {
|
||||||
|
const errData = await res.json();
|
||||||
|
setToastMessage(`❌ ${errData.error || 'Failed to register SkillRack profile'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setToastMessage('❌ Error connecting to server');
|
||||||
|
} finally {
|
||||||
|
setIsSkillrackSubmitting(false);
|
||||||
|
setTimeout(() => setToastMessage(null), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerSkillrackSync = async () => {
|
||||||
|
setIsSkillrackSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(getBackendUrl('/api/skillrack/sync'), { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
setToastMessage('⚡ SkillRack background sync started! 5s spacing between profiles.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setToastMessage('❌ Failed to trigger SkillRack sync');
|
||||||
|
} finally {
|
||||||
|
setIsSkillrackSyncing(false);
|
||||||
|
setTimeout(() => setToastMessage(null), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Convert File to Base64 Data URL for persistent viewing
|
// Convert File to Base64 Data URL for persistent viewing
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
@@ -530,6 +636,18 @@ export default function LeetcodeLeaderboard() {
|
|||||||
const totalLcCampusSolved = leetcodeProfiles.reduce((acc, curr) => acc + (curr.totalSolved || 0), 0);
|
const totalLcCampusSolved = leetcodeProfiles.reduce((acc, curr) => acc + (curr.totalSolved || 0), 0);
|
||||||
const topLcProfile = leetcodeProfiles.length > 0 ? leetcodeProfiles[0] : null;
|
const topLcProfile = leetcodeProfiles.length > 0 ? leetcodeProfiles[0] : null;
|
||||||
|
|
||||||
|
// SkillRack Filtered
|
||||||
|
const filteredSkillrack = skillrackProfiles.filter(p => {
|
||||||
|
const matchesSearch =
|
||||||
|
p.studentName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.skillrackEmail.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesDept = selectedDept === 'All' || p.department === selectedDept;
|
||||||
|
const matchesYear = selectedYear === 'All' || p.year === selectedYear;
|
||||||
|
return matchesSearch && matchesDept && matchesYear;
|
||||||
|
});
|
||||||
|
const totalSrPoints = skillrackProfiles.reduce((acc, curr) => acc + (curr.totalPoints || 0), 0);
|
||||||
|
const topSrProfile = skillrackProfiles.length > 0 ? skillrackProfiles[0] : null;
|
||||||
|
|
||||||
// Certifications Domain Counts
|
// Certifications Domain Counts
|
||||||
const certDomainCounts: Record<CertDomain, number> = {
|
const certDomainCounts: Record<CertDomain, number> = {
|
||||||
'AI & Machine Learning': certifications.filter(c => c.domain === 'AI & Machine Learning').length,
|
'AI & Machine Learning': certifications.filter(c => c.domain === 'AI & Machine Learning').length,
|
||||||
@@ -647,7 +765,7 @@ export default function LeetcodeLeaderboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Top Feature Switcher Pills (SkillRack removed per request) ─── */}
|
{/* ─── Top Feature Switcher Pills ─── */}
|
||||||
<div className="mt-8 pt-6 border-t border-[#E9E5EE] flex flex-wrap items-center gap-2 sm:gap-3">
|
<div className="mt-8 pt-6 border-t border-[#E9E5EE] flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveMainTab('leetcode')}
|
onClick={() => setActiveMainTab('leetcode')}
|
||||||
@@ -667,6 +785,24 @@ export default function LeetcodeLeaderboard() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMainTab('skillrack')}
|
||||||
|
className={`relative flex items-center gap-2 px-5 py-3 rounded-2xl font-extrabold text-xs sm:text-sm transition-all cursor-pointer ${
|
||||||
|
activeMainTab === 'skillrack'
|
||||||
|
? 'bg-gradient-to-r from-[#FF6B00] to-[#F97316] text-white shadow-lg shadow-orange-500/25 scale-[1.02]'
|
||||||
|
: 'bg-[#F8FAFC] text-slate-600 hover:text-slate-900 border border-[#E2E8F0] hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: 'Plus Jakarta Sans, sans-serif' }}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span>SkillRack</span>
|
||||||
|
<span className={`ml-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono ${
|
||||||
|
activeMainTab === 'skillrack' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
{skillrackProfiles.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveMainTab('certifications')}
|
onClick={() => setActiveMainTab('certifications')}
|
||||||
className={`relative flex items-center gap-2 px-5 py-3 rounded-2xl font-extrabold text-xs sm:text-sm transition-all cursor-pointer ${
|
className={`relative flex items-center gap-2 px-5 py-3 rounded-2xl font-extrabold text-xs sm:text-sm transition-all cursor-pointer ${
|
||||||
@@ -870,7 +1006,187 @@ export default function LeetcodeLeaderboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ───────────────────────────────────────────────────────────────────── */}
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
{/* TAB 2: SKILL CERTIFICATIONS (WITH DOMAIN SELECTOR DROPDOWN & PREVIEW) */}
|
{/* TAB 2: SKILLRACK LEADERBOARD */}
|
||||||
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
|
{activeMainTab === 'skillrack' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Metrics Bar */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-white border border-[#E2E8F0] shadow-sm">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1.5">
|
||||||
|
<Trophy className="w-4 h-4 text-[#F97316]" /> #1 Top Scorer
|
||||||
|
</span>
|
||||||
|
<p className="text-base sm:text-lg font-bold text-[#1E293B] mt-1 truncate">
|
||||||
|
{topSrProfile ? topSrProfile.studentName : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#F97316] font-medium">
|
||||||
|
{topSrProfile ? `${topSrProfile.totalPoints} Points` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-white border border-[#E2E8F0] shadow-sm">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1.5">
|
||||||
|
<Target className="w-4 h-4 text-emerald-500" /> Total Campus Points
|
||||||
|
</span>
|
||||||
|
<p className="text-xl font-bold text-[#1E293B] mt-1">
|
||||||
|
{totalSrPoints.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">Across all registered students</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-white border border-[#E2E8F0] shadow-sm">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1.5">
|
||||||
|
<User className="w-4 h-4 text-indigo-500" /> Active Coders
|
||||||
|
</span>
|
||||||
|
<p className="text-xl font-bold text-[#1E293B] mt-1">
|
||||||
|
{skillrackProfiles.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">Registered RIT students</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-white border border-[#E2E8F0] shadow-sm">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-4 h-4 text-amber-500" /> Auto Sync
|
||||||
|
</span>
|
||||||
|
<p className="text-base font-bold text-[#1E293B] mt-1">
|
||||||
|
Every 24 Hours
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[#F97316] font-mono">5s spacing · 2:30 AM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Action Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-white border border-[#E5E7EB] p-4 rounded-2xl shadow-xs">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by student name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-[#F8FAFC] border border-[#E2E8F0] text-sm text-[#1E293B] placeholder-slate-400 focus:outline-none focus:border-[#F97316]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-slate-400" />
|
||||||
|
<select
|
||||||
|
value={selectedDept}
|
||||||
|
onChange={(e) => setSelectedDept(e.target.value)}
|
||||||
|
className="bg-[#F8FAFC] border border-[#E2E8F0] rounded-xl px-3 py-2 text-xs font-medium text-slate-700 focus:outline-none focus:border-[#F97316]"
|
||||||
|
>
|
||||||
|
{DEPARTMENTS.map((d) => (
|
||||||
|
<option key={d} value={d}>Dept: {d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={(e) => setSelectedYear(e.target.value)}
|
||||||
|
className="bg-[#F8FAFC] border border-[#E2E8F0] rounded-xl px-3 py-2 text-xs font-medium text-slate-700 focus:outline-none focus:border-[#F97316]"
|
||||||
|
>
|
||||||
|
{YEARS.map((y) => (
|
||||||
|
<option key={y} value={y}>Year: {y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSkillrackModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-gradient-to-r from-[#FF6B00] to-[#F97316] text-white font-bold text-xs shadow-md hover:opacity-95 cursor-pointer ml-auto sm:ml-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Register Account
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTriggerSkillrackSync}
|
||||||
|
disabled={isSkillrackSyncing}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-[#F8FAFC] text-slate-700 border border-[#E2E8F0] font-semibold text-xs hover:bg-slate-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isSkillrackSyncing ? 'animate-spin text-[#FF6B00]' : ''}`} />
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SkillRack Table */}
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
|
{skillrackLoading ? (
|
||||||
|
<div className="p-12 text-center text-slate-400 flex flex-col items-center gap-3">
|
||||||
|
<RefreshCw className="w-8 h-8 animate-spin text-[#F97316]" />
|
||||||
|
<p className="text-sm font-medium">Fetching SkillRack rankings...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredSkillrack.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-slate-400 space-y-3">
|
||||||
|
<Zap className="w-10 h-10 text-slate-300 mx-auto" />
|
||||||
|
<p className="text-base font-semibold text-slate-800">No SkillRack profiles found</p>
|
||||||
|
<p className="text-xs text-slate-500">Be the first to register your SkillRack account!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs sm:text-sm text-slate-700">
|
||||||
|
<thead className="bg-[#F8FAFC] border-b border-[#E2E8F0] text-slate-500 uppercase font-bold text-[11px] tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="py-3.5 px-4 text-center w-16">Rank</th>
|
||||||
|
<th className="py-3.5 px-4">Student</th>
|
||||||
|
<th className="py-3.5 px-4">Dept / Year</th>
|
||||||
|
<th className="py-3.5 px-4 text-center">Total Points</th>
|
||||||
|
<th className="py-3.5 px-4 text-center">Programs</th>
|
||||||
|
<th className="py-3.5 px-4 text-center">Medals</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#E5E7EB]">
|
||||||
|
{filteredSkillrack.map((p, index) => {
|
||||||
|
const rank = index + 1;
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="hover:bg-[#F8FAFC] transition-colors">
|
||||||
|
<td className="py-3.5 px-4 text-center font-bold">
|
||||||
|
{rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<p className="font-bold text-[#1E293B] text-sm">{p.studentName}</p>
|
||||||
|
<p className="text-xs text-slate-400 font-mono truncate max-w-[180px]">{p.skillrackEmail}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-100 text-slate-700 font-medium text-xs">
|
||||||
|
{p.department}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 ml-2">{p.year}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className="text-base font-extrabold text-[#F97316] font-mono">
|
||||||
|
{p.totalPoints}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-[11px] font-mono flex-wrap">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 font-bold" title="Code Test">CT:{p.codeTestSolved}</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 font-bold" title="Code Tutor">Tutor:{p.codeTutorSolved}</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 font-bold" title="Code Track">Track:{p.codeTrackSolved}</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 font-bold" title="Daily Challenge">DC:{p.dcSolved}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs font-mono">
|
||||||
|
<span className="text-amber-500 font-bold" title="Gold">🥇{p.goldMedals}</span>
|
||||||
|
<span className="text-slate-400 font-bold" title="Silver">🥈{p.silverMedals}</span>
|
||||||
|
<span className="text-amber-700 font-bold" title="Bronze">🥉{p.bronzeMedals}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
|
{/* TAB 3: SKILL CERTIFICATIONS (WITH DOMAIN SELECTOR DROPDOWN & PREVIEW) */}
|
||||||
{/* ───────────────────────────────────────────────────────────────────── */}
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
{activeMainTab === 'certifications' && (
|
{activeMainTab === 'certifications' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1126,7 +1442,113 @@ export default function LeetcodeLeaderboard() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ───────────────────────────────────────────────────────────────────── */}
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
{/* MODAL 2: UPLOAD SKILL CERTIFICATION FORM */}
|
{/* MODAL: REGISTER SKILLRACK ACCOUNT */}
|
||||||
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showSkillrackModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/60 backdrop-blur-sm">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-white rounded-3xl p-6 sm:p-8 max-w-md w-full border border-[#E9E5EE] shadow-2xl relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSkillrackModal(false)}
|
||||||
|
className="absolute top-5 right-5 p-1 rounded-full text-slate-400 hover:text-slate-600 bg-slate-100"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-6 h-6 text-[#FF6B00]" />
|
||||||
|
<h3 className="text-xl font-extrabold text-[#1E293B]" style={{ fontFamily: 'Plus Jakarta Sans, sans-serif' }}>
|
||||||
|
Register SkillRack Account
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Enter your SkillRack credentials to fetch your stats. Your password is stored encrypted on our server for automatic daily sync.</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegisterSkillrack} className="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-700 mb-1">Student Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={srStudentName}
|
||||||
|
onChange={(e) => setSrStudentName(e.target.value)}
|
||||||
|
placeholder="e.g. S DEVESH"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-[#E2E8F0] text-sm text-[#1E293B] focus:outline-none focus:border-[#F97316]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-700 mb-1 flex items-center gap-1">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-[#FF6B00]" /> SkillRack Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={srEmail}
|
||||||
|
onChange={(e) => setSrEmail(e.target.value)}
|
||||||
|
placeholder="e.g. student@ritchennai.edu.in"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-[#E2E8F0] text-sm text-[#1E293B] focus:outline-none focus:border-[#F97316]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-700 mb-1 flex items-center gap-1">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-[#FF6B00]" /> SkillRack Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={srPassword}
|
||||||
|
onChange={(e) => setSrPassword(e.target.value)}
|
||||||
|
placeholder="Your SkillRack password"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-[#E2E8F0] text-sm text-[#1E293B] focus:outline-none focus:border-[#F97316]"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1 flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" /> Encrypted with AES-256 · Used only for stat fetching
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-700 mb-1">Department</label>
|
||||||
|
<CustomFormSelect
|
||||||
|
value={srDept}
|
||||||
|
onChange={setSrDept}
|
||||||
|
options={DEPT_FORM_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-700 mb-1">Year</label>
|
||||||
|
<CustomFormSelect
|
||||||
|
value={srYear}
|
||||||
|
onChange={setSrYear}
|
||||||
|
options={YEAR_FORM_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSkillrackSubmitting}
|
||||||
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#FF6B00] to-[#F97316] text-white font-bold text-xs shadow-md hover:opacity-95 transition-all cursor-pointer flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSkillrackSubmitting ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Connect & Fetch Stats'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
|
{/* MODAL: UPLOAD SKILL CERTIFICATION FORM */}
|
||||||
{/* ───────────────────────────────────────────────────────────────────── */}
|
{/* ───────────────────────────────────────────────────────────────────── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showCertModal && (
|
{showCertModal && (
|
||||||
|
|||||||
Reference in New Issue
Block a user