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

This commit is contained in:
Shanmuga Krishnan S M
2026-08-28 09:35:42 +05:30
parent 6be263771d
commit a19c36579e
6 changed files with 986 additions and 8 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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 && (