Attempted working dashbaord tablepage

This commit is contained in:
2025-12-22 12:51:01 -06:00
parent 14091f1f25
commit d10f7f3173
12 changed files with 876 additions and 112 deletions

View File

@@ -5,6 +5,8 @@
"": { "": {
"name": "spaceward-frontend", "name": "spaceward-frontend",
"dependencies": { "dependencies": {
"i": "^0.3.7",
"lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
}, },
@@ -310,6 +312,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"i": ["i@0.3.7", "", {}, "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -346,6 +350,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

View File

@@ -10,6 +10,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"i": "^0.3.7",
"lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

View File

@@ -1,24 +1,73 @@
#root { .app-container {
max-width: 1280px; min-height: 100vh;
margin: 0 auto; background-color: #1e1e1e;
padding: 2rem; padding: 2rem;
}
.app-header {
max-width: 1400px;
margin: 0 auto 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-title {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(to right, #a78bfa, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-subtitle {
color: #9ca3af;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.username-highlight {
color: #a78bfa;
}
.refresh-button {
background-color: #7c3aed;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.refresh-button:hover {
background-color: #6d28d9;
}
.loading-container,
.error-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #1e1e1e;
}
.loading-content,
.error-content {
text-align: center; text-align: center;
} }
.logo { .loading-spinner {
height: 6em; animation: spin 1s linear infinite;
padding: 1.5em; color: #a78bfa;
will-change: filter; margin: 0 auto 1rem;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
} }
@keyframes logo-spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
@@ -27,16 +76,33 @@
} }
} }
@media (prefers-reduced-motion: no-preference) { .loading-text,
a:nth-of-type(2) .logo { .error-text {
animation: logo-spin infinite 20s linear; color: #9ca3af;
} font-family: "JetBrains Mono", monospace;
} }
.card { .error-icon {
padding: 2em; color: #ef4444;
margin: 0 auto 1rem;
} }
.read-the-docs { .error-message {
color: #888; color: #f87171;
margin-bottom: 1rem;
}
.retry-button {
background-color: #7c3aed;
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: #6d28d9;
} }

View File

@@ -1,35 +1,71 @@
import { useState } from 'react' import { useState, useEffect } from 'react';
import reactLogo from './assets/react.svg' import { AlertCircle } from 'lucide-react';
import viteLogo from '/vite.svg' import { GradesTable } from './components/GradesTable';
import './App.css' import { LoadingSpinner } from './components/LoadingSpinner';
import { api } from './services/api';
import './App.css';
function App() { function App() {
const [count, setCount] = useState(0) const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [username] = useState(
import.meta.env.VITE_DEFAULT_USERNAME || 'keshav.anand.1'
);
useEffect(() => {
fetchGrades();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username]);
const fetchGrades = async () => {
setLoading(true);
setError(null);
try {
const result = await api.getGrades(username);
setData(result.user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return (
<div className="error-container">
<div className="error-content">
<AlertCircle className="error-icon" size={48} />
<p className="error-message">Error: {error}</p>
<button onClick={fetchGrades} className="retry-button">
Retry
</button>
</div>
</div>
);
}
return ( return (
<> <div className="app-container">
<div> <div className="app-header">
<a href="https://vite.dev" target="_blank"> <div>
<img src={viteLogo} className="logo" alt="Vite logo" /> <h1 className="app-title">STUDENT GRADES</h1>
</a> <p className="app-subtitle">
<a href="https://react.dev" target="_blank"> Logged in as: <span className="username-highlight">{username}</span>
<img src={reactLogo} className="logo react" alt="React logo" /> </p>
</a> </div>
</div> <button onClick={fetchGrades} className="refresh-button">
<h1>Vite + React</h1> Refresh
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button> </button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div> </div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more <GradesTable data={data} />
</p> </div>
</> );
)
} }
export default App export default App;

View File

@@ -0,0 +1,213 @@
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 50;
}
.modal-content {
background-color: #1e1e1e;
border-radius: 0.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 1200px;
width: 100%;
max-height: 90vh;
overflow: hidden;
border: 1px solid #374151;
}
.modal-header {
background: linear-gradient(to right, #581c87, #4338ca);
color: white;
padding: 1.5rem;
border-bottom: 1px solid #374151;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: start;
}
.class-name {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.class-info {
display: flex;
align-items: center;
gap: 0.75rem;
color: #ddd6fe;
font-size: 0.875rem;
}
.category-badge {
padding: 0.25rem 0.5rem;
background-color: rgba(139, 92, 246, 0.3);
border-radius: 0.25rem;
}
.final-grade {
font-size: 4rem;
font-weight: 700;
background: linear-gradient(to right, #4ade80, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.action-buttons {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.action-btn {
background-color: rgba(255, 255, 255, 0.1);
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.action-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.assignments-container {
overflow-y: auto;
max-height: calc(90vh - 250px);
background-color: #2b2b2b;
}
.assignments-table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.assignments-table thead {
background-color: #1e1e1e;
position: sticky;
top: 0;
z-index: 10;
}
.assignments-table th {
text-align: left;
padding: 0.75rem;
font-weight: 600;
color: #d1d5db;
border-bottom: 1px solid #374151;
}
.section-header {
font-weight: 600;
}
.section-header.major {
background-color: rgba(234, 88, 12, 0.2);
color: #fdba74;
}
.section-header.minor {
background-color: rgba(234, 179, 8, 0.2);
color: #fde047;
}
.section-header td {
padding: 0.75rem;
}
.assignment-row {
border-bottom: 1px solid #1f2937;
transition: background-color 0.2s;
}
.assignment-row:hover {
background-color: #323232;
}
.assignment-row td {
padding: 0.75rem;
}
.assignment-name {
display: flex;
align-items: center;
gap: 0.5rem;
color: #d8b4fe;
}
.alert-icon {
color: #f87171;
flex-shrink: 0;
}
.due-date {
color: #9ca3af;
}
.score {
font-weight: 700;
}
.score-excellent {
color: #4ade80;
}
.score-good {
color: #60a5fa;
}
.score-average {
color: #fbbf24;
}
.score-poor {
color: #f87171;
}
.attempts {
text-align: center;
color: #60a5fa;
}
.modal-footer {
background-color: #1e1e1e;
padding: 1rem;
display: flex;
justify-content: flex-end;
border-top: 1px solid #374151;
}
.close-btn {
background-color: #7c3aed;
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.close-btn:hover {
background-color: #6d28d9;
}

View File

@@ -0,0 +1,149 @@
import { Calculator, Scale, Printer, AlertCircle } from 'lucide-react';
import './ClassDetail.css';
export const ClassDetail = ({ classData, onClose }) => {
const majorGrades = classData.assignments.filter(a => a.isMajorGrade);
const minorGrades = classData.assignments.filter(a => !a.isMajorGrade);
const calculateAverage = (grades) => {
if (grades.length === 0) return '0.00%';
const sum = grades.reduce((acc, g) => {
const scoreMatch = g.score.match(/[\d.]+/);
const score = scoreMatch ? parseFloat(scoreMatch[0]) : 0;
return acc + score;
}, 0);
return `${(sum / grades.length).toFixed(2)}%`;
};
const getScoreColor = (score) => {
const scoreMatch = score.match(/[\d.]+/);
const numScore = scoreMatch ? parseFloat(scoreMatch[0]) : 0;
if (numScore >= 90) return 'score-excellent';
if (numScore >= 80) return 'score-good';
if (numScore >= 70) return 'score-average';
return 'score-poor';
};
const hasAlert = (score) => {
const scoreMatch = score.match(/[\d.]+/);
const numScore = scoreMatch ? parseFloat(scoreMatch[0]) : 0;
return numScore < 80;
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<div className="header-content">
<div className="header-left">
<h2 className="class-name">{classData.className}</h2>
<div className="class-info">
<span>{classData.teacher}</span>
<span></span>
<span>{classData.period}</span>
<span></span>
<span className="category-badge">{classData.category}</span>
</div>
</div>
<div className="header-right">
<div className="final-grade">
{classData.finalGrades?.[0]?.grade || 'N/A'}
</div>
</div>
</div>
{/* Action buttons */}
<div className="action-buttons">
<button className="action-btn">
<Calculator size={16} />
Calculations
</button>
<button className="action-btn">
<Scale size={16} />
Grading Scale
</button>
<button className="action-btn">
<Printer size={16} />
Print
</button>
</div>
</div>
{/* Assignments Table */}
<div className="assignments-container">
<table className="assignments-table">
<thead>
<tr>
<th>Description</th>
<th>Due Date</th>
<th>Score</th>
<th>Attempts</th>
</tr>
</thead>
<tbody>
{/* Major Grades */}
{majorGrades.length > 0 && (
<>
<tr className="section-header major">
<td colSpan={2}>Major Grades</td>
<td>{calculateAverage(majorGrades)}</td>
<td></td>
</tr>
{majorGrades.map((assignment, idx) => (
<tr key={idx} className="assignment-row">
<td className="assignment-name">
{hasAlert(assignment.score) && (
<AlertCircle size={16} className="alert-icon" />
)}
<span>{assignment.name}</span>
</td>
<td className="due-date">{assignment.dueDate}</td>
<td className={`score ${getScoreColor(assignment.score)}`}>
{assignment.score}
</td>
<td className="attempts">{assignment.attempts}</td>
</tr>
))}
</>
)}
{/* Minor Grades */}
{minorGrades.length > 0 && (
<>
<tr className="section-header minor">
<td colSpan={2}>Minor Grades</td>
<td>{calculateAverage(minorGrades)}</td>
<td></td>
</tr>
{minorGrades.map((assignment, idx) => (
<tr key={idx} className="assignment-row">
<td className="assignment-name">
{hasAlert(assignment.score) && (
<AlertCircle size={16} className="alert-icon" />
)}
<span>{assignment.name}</span>
</td>
<td className="due-date">{assignment.dueDate}</td>
<td className={`score ${getScoreColor(assignment.score)}`}>
{assignment.score}
</td>
<td className="attempts">{assignment.attempts}</td>
</tr>
))}
</>
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="modal-footer">
<button onClick={onClose} className="close-btn">
Close
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
.grades-table-container {
background-color: #2b2b2b;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid #374151;
}
.grades-table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.grades-table thead {
background-color: #1e1e1e;
}
.grades-table th {
padding: 1rem;
font-weight: 600;
color: #d1d5db;
border-bottom: 2px solid #7c3aed;
text-align: center;
}
.header-class {
text-align: left !important;
position: sticky;
left: 0;
background-color: #1e1e1e;
z-index: 10;
}
.header-category {
min-width: 80px;
}
.class-row {
border-bottom: 1px solid #1f2937;
transition: background-color 0.2s;
}
.class-row:hover {
background-color: #323232;
}
.cell-class {
padding: 1rem;
position: sticky;
left: 0;
background-color: #2b2b2b;
z-index: 5;
}
.class-row:hover .cell-class {
background-color: #323232;
}
.class-name {
color: #d8b4fe;
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.class-period {
color: #6b7280;
font-size: 0.75rem;
margin-top: 0.25rem;
}
.class-teacher {
color: #9ca3af;
font-size: 0.75rem;
margin-top: 0.125rem;
}
.cell-missing {
text-align: center;
padding: 1rem;
}
.cell-grade {
text-align: center;
padding: 1rem;
}
.grade-btn {
background: none;
border: none;
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 1.125rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s;
}
.grade-btn:hover {
transform: scale(1.1);
}
.grade-excellent {
color: #4ade80;
}
.grade-excellent:hover {
background-color: rgba(74, 222, 128, 0.1);
}
.grade-good {
color: #60a5fa;
}
.grade-good:hover {
background-color: rgba(96, 165, 250, 0.1);
}
.grade-average {
color: #fbbf24;
}
.grade-average:hover {
background-color: rgba(251, 191, 36, 0.1);
}
.grade-poor {
color: #f87171;
}
.grade-poor:hover {
background-color: rgba(248, 113, 113, 0.1);
}
.grade-empty {
color: #4b5563;
font-size: 1.5rem;
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { ClassDetail } from './ClassDetail';
import './GradesTable.css';
const CATEGORIES = ['NW1', 'NW2', 'SE1', 'S1', 'NW3', 'NW4', 'SE2', 'S2', 'FIN'];
export const GradesTable = ({ data }) => {
const [selectedClass, setSelectedClass] = useState(null);
// Group classes by class name and period
const groupedClasses = data?.classes.reduce((acc, cls) => {
const key = `${cls.className}-${cls.period}`;
if (!acc[key]) {
acc[key] = {
className: cls.className,
period: cls.period,
teacher: cls.teacher,
grades: {},
classObjects: {}
};
}
// Store the grade
const finalGrade = cls.finalGrades?.[0]?.grade || '';
const gradeNum = finalGrade.match(/[\d.]+/)?.[0] || '';
acc[key].grades[cls.category] = gradeNum;
acc[key].classObjects[cls.category] = cls;
return acc;
}, {});
const calculateSemesterGrade = (nw1, nw2, se1) => {
const grades = [
parseFloat(nw1) * 0.4,
parseFloat(nw2) * 0.4,
parseFloat(se1) * 0.2
];
// Check if all grades are valid numbers
if (grades.some(g => isNaN(g))) return null;
const total = grades.reduce((sum, g) => sum + g, 0);
return total.toFixed(2);
};
const getGradeColor = (grade) => {
const numGrade = parseFloat(grade);
if (isNaN(numGrade)) return 'grade-none';
if (numGrade >= 90) return 'grade-excellent';
if (numGrade >= 80) return 'grade-good';
if (numGrade >= 70) return 'grade-average';
return 'grade-poor';
};
return (
<>
<div className="grades-table-container">
<table className="grades-table">
<thead>
<tr>
<th className="header-class">Class</th>
<th className="header-missing">Missing<br/>Assignments</th>
{CATEGORIES.map(cat => (
<th key={cat} className="header-category">{cat}</th>
))}
</tr>
</thead>
<tbody>
{groupedClasses && Object.values(groupedClasses)
.sort((a, b) => {
const periodA = a.period.match(/Period\s+(\d+)/)?.[1];
const periodB = b.period.match(/Period\s+(\d+)/)?.[1];
return (parseInt(periodA) || 0) - (parseInt(periodB) || 0);
})
.map((cls, idx) => (
<tr key={idx} className="class-row">
<td className="cell-class">
<div className="class-name">{cls.className}</div>
<div className="class-period">{cls.period}</div>
<div className="class-teacher">{cls.teacher}</div>
</td>
<td className="cell-missing"></td>
{CATEGORIES.map(cat => {
let displayGrade = cls.grades[cat];
let isCalculated = false;
// Calculate S1 from NW1, NW2, SE1
if (cat === 'S1') {
displayGrade = calculateSemesterGrade(
cls.grades['NW1'],
cls.grades['NW2'],
cls.grades['SE1']
);
isCalculated = true;
}
// Calculate S2 from NW3, NW4, SE2
else if (cat === 'S2') {
displayGrade = calculateSemesterGrade(
cls.grades['NW3'],
cls.grades['NW4'],
cls.grades['SE2']
);
isCalculated = true;
}
return (
<td key={cat} className="cell-grade">
{displayGrade ? (
<button
onClick={() => !isCalculated && setSelectedClass(cls.classObjects[cat])}
className={`grade-btn ${getGradeColor(displayGrade)} ${isCalculated ? 'calculated' : ''}`}
disabled={isCalculated}
>
{displayGrade}
</button>
) : (
<span className="grade-empty"></span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{selectedClass && (
<ClassDetail
classData={selectedClass}
onClose={() => setSelectedClass(null)}
/>
)}
</>
);
};

View File

@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react';
export const LoadingSpinner = () => {
return (
<div className="loading-container">
<div className="loading-content">
<Loader2 className="loading-spinner" size={48} />
<p className="loading-text">Loading grades...</p>
</div>
</div>
);
};

View File

@@ -1,68 +1,39 @@
:root { @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark; * {
color: rgba(255, 255, 255, 0.87); margin: 0;
background-color: #242424; padding: 0;
box-sizing: border-box;
}
font-synthesis: none; body {
text-rendering: optimizeLegibility; font-family: 'JetBrains Mono', monospace;
background-color: #1e1e1e;
color: #e0e0e0;
line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a { #root {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }
h1 { /* Custom scrollbar */
font-size: 3.2em; ::-webkit-scrollbar {
line-height: 1.1; width: 10px;
height: 10px;
} }
button { ::-webkit-scrollbar-track {
border-radius: 8px; background: #2b2b2b;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} }
@media (prefers-color-scheme: light) { ::-webkit-scrollbar-thumb {
:root { background: #4a4a4a;
color: #213547; border-radius: 5px;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }
::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}

View File

@@ -1,10 +1,10 @@
import { StrictMode } from 'react' import React from 'react';
import { createRoot } from 'react-dom/client' import ReactDOM from 'react-dom/client';
import './index.css' import App from './App.jsx';
import App from './App.jsx' import './index.css';
createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<StrictMode> <React.StrictMode>
<App /> <App />
</StrictMode>, </React.StrictMode>,
) );

35
src/services/api.js Normal file
View File

@@ -0,0 +1,35 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
export const api = {
async getGrades(username) {
const response = await fetch(`${API_BASE_URL}/users/${username}/grades`);
if (!response.ok) {
throw new Error('Failed to fetch grades');
}
return response.json();
},
async getClasses(username) {
const response = await fetch(`${API_BASE_URL}/users/${username}/classes`);
if (!response.ok) {
throw new Error('Failed to fetch classes');
}
return response.json();
},
async getFinalGrades(username) {
const response = await fetch(`${API_BASE_URL}/users/${username}/final-grades`);
if (!response.ok) {
throw new Error('Failed to fetch final grades');
}
return response.json();
},
async getStats(username) {
const response = await fetch(`${API_BASE_URL}/stats/users/${username}`);
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
return response.json();
}
};