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>
|
||||
</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 -->
|
||||
<dependency>
|
||||
<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,
|
||||
Sparkles, Flame, Award, CheckCircle2, Shield, ShieldCheck, User, Filter, X, Clock, Lock, Calendar,
|
||||
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';
|
||||
import { getBackendUrl } from '@/lib/utils';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -28,6 +28,23 @@ interface LeetcodeProfile {
|
||||
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 =
|
||||
| 'AI & Machine Learning'
|
||||
| 'Database & MongoDB'
|
||||
@@ -308,8 +325,8 @@ function CustomFormSelect({
|
||||
export default function LeetcodeLeaderboard() {
|
||||
const { user, isAuthenticated, openAuthModal } = useAuth();
|
||||
|
||||
// Top Feature Switcher State (SkillRack removed per request)
|
||||
const [activeMainTab, setActiveMainTab] = useState<'leetcode' | 'certifications'>('leetcode');
|
||||
// Top Feature Switcher State
|
||||
const [activeMainTab, setActiveMainTab] = useState<'leetcode' | 'skillrack' | 'certifications'>('leetcode');
|
||||
|
||||
// Shared Filters State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -333,7 +350,23 @@ export default function LeetcodeLeaderboard() {
|
||||
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[]>(() => {
|
||||
try {
|
||||
@@ -360,10 +393,11 @@ export default function LeetcodeLeaderboard() {
|
||||
const [certFile, setCertFile] = useState<File | 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(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchLeetcodeLeaderboard();
|
||||
fetchSkillrackLeaderboard();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
@@ -380,8 +414,10 @@ export default function LeetcodeLeaderboard() {
|
||||
useEffect(() => {
|
||||
if (user?.name) {
|
||||
setLcStudentName(user.name);
|
||||
setSrStudentName(user.name);
|
||||
if (user.department) {
|
||||
setLcDept(user.department);
|
||||
setSrDept(user.department);
|
||||
}
|
||||
}
|
||||
}, [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
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 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
|
||||
const certDomainCounts: Record<CertDomain, number> = {
|
||||
'AI & Machine Learning': certifications.filter(c => c.domain === 'AI & Machine Learning').length,
|
||||
@@ -647,7 +765,7 @@ export default function LeetcodeLeaderboard() {
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setActiveMainTab('leetcode')}
|
||||
@@ -667,6 +785,24 @@ export default function LeetcodeLeaderboard() {
|
||||
</span>
|
||||
</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
|
||||
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 ${
|
||||
@@ -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' && (
|
||||
<div className="space-y-6">
|
||||
@@ -1126,7 +1442,113 @@ export default function LeetcodeLeaderboard() {
|
||||
</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>
|
||||
{showCertModal && (
|
||||
|
||||
Reference in New Issue
Block a user