diff --git a/src/App.jsx b/src/App.jsx
index d77753d..70fadf8 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -2,27 +2,29 @@ import { useState, useEffect } from 'react';
import { AlertCircle } from 'lucide-react';
import { GradesTable } from './components/GradesTable';
import { LoadingSpinner } from './components/LoadingSpinner';
+import { AuthPage } from './components/AuthPage';
+import { useAuth } from './contexts/AuthContext';
import { api } from './services/api';
import './App.css';
function App() {
+ const { user, loading: authLoading, logout } = useAuth();
const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
+ const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [username] = useState(
- import.meta.env.VITE_DEFAULT_USERNAME || 'keshav.anand.1'
- );
useEffect(() => {
- fetchGrades();
+ if (user) {
+ fetchGrades();
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [username]);
+ }, [user]);
const fetchGrades = async () => {
setLoading(true);
setError(null);
try {
- const result = await api.getGrades(username);
+ const result = await api.getGrades(user.username);
setData(result.user);
} catch (err) {
setError(err.message);
@@ -31,10 +33,22 @@ function App() {
}
};
+ // Show loading spinner while checking auth
+ if (authLoading) {
+ return ;
+ }
+
+ // Show auth page if not logged in
+ if (!user) {
+ return ;
+ }
+
+ // Show loading spinner while fetching grades
if (loading) {
return ;
}
+ // Show error state
if (error) {
return (
@@ -55,12 +69,17 @@ function App() {
STUDENT GRADES
- Logged in as: {username}
+ Logged in as: {user.username}
-
+
+
+
+
diff --git a/src/components/AuthPage.css b/src/components/AuthPage.css
new file mode 100644
index 0000000..804495c
--- /dev/null
+++ b/src/components/AuthPage.css
@@ -0,0 +1,171 @@
+.auth-container {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #1e1e1e 0%, #2b1055 100%);
+ padding: 2rem;
+}
+
+.auth-card {
+ background-color: #2b2b2b;
+ border-radius: 1rem;
+ padding: 3rem;
+ width: 100%;
+ max-width: 450px;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ border: 1px solid #374151;
+}
+
+.auth-header {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.auth-title {
+ font-size: 2rem;
+ font-weight: 700;
+ background: linear-gradient(to right, #a78bfa, #ec4899);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 0.5rem;
+}
+
+.auth-subtitle {
+ color: #9ca3af;
+ font-size: 0.875rem;
+}
+
+.auth-error {
+ background-color: rgba(239, 68, 68, 0.1);
+ border: 1px solid #ef4444;
+ color: #fca5a5;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.form-label {
+ color: #d1d5db;
+ font-size: 0.875rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.form-input {
+ background-color: #1e1e1e;
+ border: 1px solid #374151;
+ color: #e0e0e0;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.875rem;
+ transition: all 0.2s;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: #7c3aed;
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
+}
+
+.form-hint {
+ color: #6b7280;
+ font-size: 0.75rem;
+ margin: 0;
+}
+
+.submit-button {
+ background: linear-gradient(to right, #7c3aed, #ec4899);
+ color: white;
+ padding: 0.875rem;
+ border: none;
+ border-radius: 0.5rem;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.submit-button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 20px rgba(124, 58, 237, 0.3);
+}
+
+.submit-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.spinner {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.auth-switch {
+ margin-top: 1.5rem;
+ text-align: center;
+}
+
+.switch-button {
+ background: none;
+ border: none;
+ color: #a78bfa;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: color 0.2s;
+}
+
+.switch-button:hover {
+ color: #c4b5fd;
+ text-decoration: underline;
+}
+
+.auth-note {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background-color: rgba(124, 58, 237, 0.1);
+ border-radius: 0.5rem;
+ border: 1px solid rgba(124, 58, 237, 0.2);
+}
+
+.auth-note p {
+ color: #d8b4fe;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ margin: 0;
+}
diff --git a/src/components/AuthPage.jsx b/src/components/AuthPage.jsx
new file mode 100644
index 0000000..2fe327b
--- /dev/null
+++ b/src/components/AuthPage.jsx
@@ -0,0 +1,127 @@
+import { useState } from 'react';
+import { Loader2, Lock, User as UserIcon, AlertCircle } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import './AuthPage.css';
+
+export const AuthPage = () => {
+ const [isLogin, setIsLogin] = useState(true);
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { login, register } = useAuth();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ if (isLogin) {
+ await login(username, password);
+ } else {
+ await register(username, password);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
STUDENT GRADES
+
+ {isLogin ? 'Sign in to your account' : 'Create a new account'}
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+
+ {!isLogin && (
+
+
+ Note: Your Skyward credentials will be verified during registration.
+ Make sure to use your actual Skyward username and password.
+
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/GradesTable.jsx b/src/components/GradesTable.jsx
index 297ab69..2319ae8 100644
--- a/src/components/GradesTable.jsx
+++ b/src/components/GradesTable.jsx
@@ -27,18 +27,34 @@ export const GradesTable = ({ data }) => {
return acc;
}, {});
- const calculateSemesterGrade = (nw1, nw2, se1) => {
- const grades = [
- parseFloat(nw1) * 0.4,
- parseFloat(nw2) * 0.4,
- parseFloat(se1) * 0.2
- ];
+const calculateSemesterGrade = (nw1, nw2, se1) => {
+ let totalWeight = 0;
+ let weightedSum = 0;
- // Check if all grades are valid numbers
- if (grades.some(g => isNaN(g))) return null;
+ // Add NW1 (40% weight)
+ if (nw1 && !isNaN(parseFloat(nw1))) {
+ weightedSum += parseFloat(nw1) * 0.4;
+ totalWeight += 0.4;
+ }
- const total = grades.reduce((sum, g) => sum + g, 0);
- return total.toFixed(2);
+ // Add NW2 (40% weight)
+ if (nw2 && !isNaN(parseFloat(nw2))) {
+ weightedSum += parseFloat(nw2) * 0.4;
+ totalWeight += 0.4;
+ }
+
+ // Add SE1 (20% weight)
+ if (se1 && !isNaN(parseFloat(se1))) {
+ weightedSum += parseFloat(se1) * 0.2;
+ totalWeight += 0.2;
+ }
+
+ // If no valid grades, return null
+ if (totalWeight === 0) return null;
+
+ // Normalize by the total weight to get the final grade
+ const normalizedGrade = weightedSum / totalWeight;
+ return normalizedGrade.toFixed(2);
};
const getGradeColor = (grade) => {
@@ -99,7 +115,31 @@ export const GradesTable = ({ data }) => {
cls.grades['SE2']
);
isCalculated = true;
- }
+ } // Calculate FIN from S1 and S2
+ else if (cat === 'FIN') {
+ const s1 = calculateSemesterGrade(
+ cls.grades['NW1'],
+ cls.grades['NW2'],
+ cls.grades['SE1']
+ );
+ const s2 = calculateSemesterGrade(
+ cls.grades['NW3'],
+ cls.grades['NW4'],
+ cls.grades['SE2']
+ );
+
+ const validSemesters = [];
+ if (s1 && !isNaN(parseFloat(s1))) validSemesters.push(parseFloat(s1));
+ if (s2 && !isNaN(parseFloat(s2))) validSemesters.push(parseFloat(s2));
+
+ if (validSemesters.length > 0) {
+ const average = validSemesters.reduce((sum, g) => sum + g, 0) / validSemesters.length;
+ displayGrade = average.toFixed(2);
+ } else {
+ displayGrade = null;
+ }
+ isCalculated = true;
+ }
return (
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx
new file mode 100644
index 0000000..964f7f0
--- /dev/null
+++ b/src/contexts/AuthContext.jsx
@@ -0,0 +1,91 @@
+import { createContext, useContext, useState, useEffect } from 'react';
+
+const AuthContext = createContext(null);
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within AuthProvider');
+ }
+ return context;
+};
+
+export const AuthProvider = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ checkAuth();
+ }, []);
+
+ const checkAuth = async () => {
+ try {
+ const response = await fetch('http://localhost:4000/api/auth/me', {
+ credentials: 'include'
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ setUser(data.user);
+ }
+ } catch (error) {
+ console.error('Auth check failed:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const login = async (username, password) => {
+ const response = await fetch('http://localhost:4000/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ username, password })
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Login failed');
+ }
+
+ const data = await response.json();
+ setUser(data.user);
+ return data;
+ };
+
+ const register = async (username, password) => {
+ const response = await fetch('http://localhost:4000/api/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ username, password })
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Registration failed');
+ }
+
+ const data = await response.json();
+ setUser(data.user);
+ return data;
+ };
+
+ const logout = async () => {
+ try {
+ await fetch('http://localhost:4000/api/auth/logout', {
+ method: 'POST',
+ credentials: 'include'
+ });
+ } finally {
+ setUser(null);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index 0844130..c3030a2 100644
--- a/src/index.css
+++ b/src/index.css
@@ -16,7 +16,7 @@ body {
}
#root {
- min-height: 100vh;
+ min-height: 10a0vh;
}
/* Custom scrollbar */
diff --git a/src/main.jsx b/src/main.jsx
index ecf2cca..97b9728 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,10 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
+import { AuthProvider } from './contexts/AuthContext'; // ADD THIS
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
-
+
+
+
,
);
\ No newline at end of file
diff --git a/src/services/api.js b/src/services/api.js
index 48822ef..8166914 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -2,7 +2,9 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000
export const api = {
async getGrades(username) {
- const response = await fetch(`${API_BASE_URL}/users/${username}/grades`);
+ const response = await fetch(`${API_BASE_URL}/users/${username}/grades`, {
+ credentials: 'include' // ADD THIS
+ });
if (!response.ok) {
throw new Error('Failed to fetch grades');
}
@@ -10,7 +12,9 @@ export const api = {
},
async getClasses(username) {
- const response = await fetch(`${API_BASE_URL}/users/${username}/classes`);
+ const response = await fetch(`${API_BASE_URL}/users/${username}/classes`, {
+ credentials: 'include' // ADD THIS
+ });
if (!response.ok) {
throw new Error('Failed to fetch classes');
}
@@ -18,7 +22,9 @@ export const api = {
},
async getFinalGrades(username) {
- const response = await fetch(`${API_BASE_URL}/users/${username}/final-grades`);
+ const response = await fetch(`${API_BASE_URL}/users/${username}/final-grades`, {
+ credentials: 'include' // ADD THIS
+ });
if (!response.ok) {
throw new Error('Failed to fetch final grades');
}
@@ -26,7 +32,9 @@ export const api = {
},
async getStats(username) {
- const response = await fetch(`${API_BASE_URL}/stats/users/${username}`);
+ const response = await fetch(`${API_BASE_URL}/stats/users/${username}`, {
+ credentials: 'include' // ADD THIS
+ });
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
|