Attempted working dashbaord tablepage
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
112
src/App.css
112
src/App.css
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/App.jsx
90
src/App.jsx
@@ -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;
|
||||||
213
src/components/ClassDetail.css
Normal file
213
src/components/ClassDetail.css
Normal 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;
|
||||||
|
}
|
||||||
149
src/components/ClassDetail.jsx
Normal file
149
src/components/ClassDetail.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/components/GradesTable.css
Normal file
140
src/components/GradesTable.css
Normal 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;
|
||||||
|
}
|
||||||
134
src/components/GradesTable.jsx
Normal file
134
src/components/GradesTable.jsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
src/components/LoadingSpinner.jsx
Normal file
12
src/components/LoadingSpinner.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
16
src/main.jsx
16
src/main.jsx
@@ -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
35
src/services/api.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user