feat: Implemented fetching and a lot of other stuff

This commit is contained in:
2026-05-29 09:30:17 +02:00
parent 19ecbf1955
commit c0feda4ba8
24 changed files with 1129 additions and 82 deletions
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/../jecnam/JecnaAPI" vcs="Git" />
</component>
</project>
+16
View File
@@ -0,0 +1,16 @@
import io.github.tomhula.jecnaapi.data.grade.*;
import io.github.tomhula.jecnaapi.util.Name;
import kotlinx.datetime.LocalDate;
import java.util.Map;
import java.util.List;
public class TestAvg {
public static void main(String[] args) {
// Let's see if we can create a Grade
Grade g1 = new Grade(1, false, new Name("Jan", "Novak"), "Test", new LocalDate(2023, 1, 1), 0);
Grade g2 = new Grade(2, true, new Name("Jan", "Novak"), "Test", new LocalDate(2023, 1, 1), 0);
System.out.println("Grade 1: " + g1.getValue() + " small: " + g1.getSmall());
System.out.println("Grade 2: " + g2.getValue() + " small: " + g2.getSmall());
}
}
Symlink
+1
View File
@@ -0,0 +1 @@
/home/kuba/Coding/jecnam/JecnaAPI/
+12 -2
View File
@@ -21,7 +21,6 @@
<artifactId>javafx-maven-plugin</artifactId> <artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version> <version>0.0.8</version>
<configuration> <configuration>
<!-- Make sure this matches your exact package and class name -->
<mainClass>cz.jzitnik.Main</mainClass> <mainClass>cz.jzitnik.Main</mainClass>
</configuration> </configuration>
</plugin> </plugin>
@@ -33,7 +32,7 @@
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>21.0.1</version> <!-- Use the version matching your JDK --> <version>21.0.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
@@ -54,5 +53,16 @@
<artifactId>reflections</artifactId> <artifactId>reflections</artifactId>
<version>0.10.2</version> <version>0.10.2</version>
</dependency> </dependency>
<dependency>
<groupId>io.github.tomhula</groupId>
<artifactId>jecnaapi-jvm</artifactId>
<version>10.2.0</version>
</dependency>
<dependency>
<groupId>io.github.tomhula</groupId>
<artifactId>jecnaapi-java-jvm</artifactId>
<version>10.2.0</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
+45 -12
View File
@@ -1,47 +1,80 @@
package cz.jzitnik; package cz.jzitnik;
import atlantafx.base.theme.PrimerDark; import atlantafx.base.theme.PrimerDark;
import cz.jzitnik.auth.CredentialStore;
import cz.jzitnik.controllers.DashboardController;
import cz.jzitnik.controllers.LoginController;
import cz.jzitnik.router.Router;
import cz.jzitnik.state.AppState;
import cz.jzitnik.state.StateManager; import cz.jzitnik.state.StateManager;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle; import javafx.scene.shape.Rectangle;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.util.Objects;
import java.util.Optional;
import cz.jzitnik.query.QueryClient;
import cz.jzitnik.query.QueryOptions;
public class Main extends Application { public class Main extends Application {
@Override @Override
public void start(Stage stage) throws Exception { public void start(Stage stage) throws Exception {
Application.setUserAgentStylesheet(new PrimerDark().getUserAgentStylesheet()); Application.setUserAgentStylesheet(new PrimerDark().getUserAgentStylesheet());
// 1. Initialize State Manager
// This will find all @State annotated classes inside the cz.jzitnik package and create singletons
StateManager.getInstance().initialize("cz.jzitnik"); StateManager.getInstance().initialize("cz.jzitnik");
// 2. Set up routing container (this is our root)
StackPane root = new StackPane(); StackPane root = new StackPane();
root.setStyle("-fx-background-color: -color-bg-default;"); root.setStyle("-fx-background-color: -color-bg-default;");
// Ensure contents never bleed out and cause weird window scaling
Rectangle clip = new Rectangle(); Rectangle clip = new Rectangle();
clip.widthProperty().bind(root.widthProperty()); clip.widthProperty().bind(root.widthProperty());
clip.heightProperty().bind(root.heightProperty()); clip.heightProperty().bind(root.heightProperty());
root.setClip(clip); root.setClip(clip);
// 3. Initialize Router Router router = Router.getInstance();
cz.jzitnik.router.Router router = cz.jzitnik.router.Router.getInstance();
router.setRootContainer(root); router.setRootContainer(root);
router.registerRoute("/login", cz.jzitnik.controllers.LoginController.class);
router.registerRoute("/home", cz.jzitnik.controllers.HomeController.class);
// Initial screen router.registerAnnotatedRoutes("cz.jzitnik.controllers");
router.navigate("/login");
// 4. Display try {
Scene scene = new Scene(root, 800, 600); Optional<CredentialStore.Credentials> creds = CredentialStore.loadCredentials();
if (creds.isPresent()) {
AppState appState = StateManager.getInstance().getState(AppState.class);
if (appState != null) {
new Thread(() -> {
boolean ok = appState.login(creds.get().username, creds.get().password);
if (ok) {
Platform.runLater(() -> router.navigate("/dashboard"));
} else {
CredentialStore.clearCredentials();
Platform.runLater(() -> router.navigate("/login"));
}
}, "auto-login").start();
} else {
router.navigate("/login");
}
} else {
router.navigate("/login");
}
} catch (Throwable t) {
router.navigate("/login");
}
Scene scene = new Scene(root, 1000, 700);
scene.getStylesheets().add(Objects.requireNonNull(Main.class.getResource("/styles/dashboard.css")).toExternalForm());
stage.setTitle("JecnaClient"); stage.setTitle("JecnaClient");
stage.setScene(scene); stage.setScene(scene);
stage.show(); stage.show();
QueryClient qc = new QueryClient();
AppState state = StateManager.getInstance().getState(AppState.class);
if (state != null) {
state.setQueryClient(qc);
}
} }
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,125 @@
/*
AI GENERATED SLOP
*/
package cz.jzitnik.auth;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermission;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
public class CredentialStore {
private static final Path DIR = Paths.get(System.getProperty("user.home"), ".jecnaclient");
private static final Path KEY_FILE = DIR.resolve("secret.key");
private static final Path CRED_FILE = DIR.resolve("credentials.enc");
private static final int KEY_SIZE = 256;
private static final SecureRandom RAND = new SecureRandom();
public static final class Credentials {
public final String username;
public final String password;
public Credentials(String u, String p) {
this.username = u;
this.password = p;
}
}
public static void saveCredentials(String username, String password) throws Exception {
if (username == null || password == null) return;
ensureDir();
SecretKey key = loadOrCreateKey();
byte[] iv = new byte[12];
RAND.nextBytes(iv);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
c.init(Cipher.ENCRYPT_MODE, key, spec);
String payload = username + "\n" + password;
byte[] ct = c.doFinal(payload.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
Files.write(CRED_FILE, Base64.getEncoder().encode(out), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
trySetOwnerOnly(CRED_FILE);
}
public static Optional<Credentials> loadCredentials() throws Exception {
if (!Files.exists(CRED_FILE)) return Optional.empty();
if (!Files.exists(KEY_FILE)) return Optional.empty();
SecretKey key = loadOrCreateKey();
byte[] raw = Files.readAllBytes(CRED_FILE);
byte[] decoded = Base64.getDecoder().decode(raw);
if (decoded.length < 12) return Optional.empty();
byte[] iv = new byte[12];
System.arraycopy(decoded, 0, iv, 0, 12);
byte[] ct = new byte[decoded.length - 12];
System.arraycopy(decoded, 12, ct, 0, ct.length);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
c.init(Cipher.DECRYPT_MODE, key, spec);
byte[] plain = c.doFinal(ct);
String s = new String(plain, StandardCharsets.UTF_8);
int nl = s.indexOf('\n');
if (nl < 0) return Optional.empty();
String u = s.substring(0, nl);
String p = s.substring(nl + 1);
return Optional.of(new Credentials(u, p));
}
public static void clearCredentials() {
try {
Files.deleteIfExists(CRED_FILE);
} catch (IOException ignored) {}
}
private static void ensureDir() throws IOException {
if (!Files.exists(DIR)) {
Files.createDirectories(DIR);
trySetOwnerOnly(DIR);
}
}
private static SecretKey loadOrCreateKey() throws Exception {
if (Files.exists(KEY_FILE)) {
byte[] k = Files.readAllBytes(KEY_FILE);
return new SecretKeySpec(k, "AES");
}
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(KEY_SIZE, RAND);
SecretKey key = kg.generateKey();
Files.write(KEY_FILE, key.getEncoded(), StandardOpenOption.CREATE_NEW);
trySetOwnerOnly(KEY_FILE);
return key;
}
private static void trySetOwnerOnly(Path p) {
try {
// POSIX systems
Set<PosixFilePermission> perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
Files.setPosixFilePermissions(p, perms);
} catch (Throwable ignored) {
// Windows or other systems: best-effort, ignore
}
}
}
@@ -0,0 +1,85 @@
package cz.jzitnik.controllers;
import cz.jzitnik.router.Routable;
import cz.jzitnik.router.Route;
import cz.jzitnik.state.AppState;
import cz.jzitnik.state.InjectState;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import java.util.Map;
import cz.jzitnik.router.Router;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
public class DashboardBaseController implements Routable {
@InjectState
protected AppState appState;
@FXML
protected Label welcomeLabel;
@FXML
protected Label classLabel;
@FXML
protected void onNavigateToGrades() { Router.getInstance().navigate("/grades"); }
@FXML
protected void onNavigateToTimetable() { Router.getInstance().navigate("/timetable"); }
@FXML
protected void onNavigateToTeachers() { Router.getInstance().navigate("/teachers"); }
@FXML
protected void onNavigateToRooms() { Router.getInstance().navigate("/rooms"); }
@FXML
protected void onNavigateToAbsences() { Router.getInstance().navigate("/absences"); }
@FXML
protected void onNavigateToSpecial() { Router.getInstance().navigate("/special"); }
@FXML
protected void onDoNothing() {
}
@FXML
protected void onBack() { Router.getInstance().navigate("/home"); }
@Override
public void onNavigate(Map<String, Object> props) {
if (welcomeLabel != null && classLabel != null) {
welcomeLabel.setText(" ");
classLabel.setText(" ");
welcomeLabel.getStyleClass().add("skeleton-text");
classLabel.getStyleClass().add("skeleton-text");
cz.jzitnik.query.QueryOptions opts = cz.jzitnik.query.QueryOptions.defaultOptions();
appState.getQueryClient().fetch("user:profile", () -> {
try {
return appState.getClient().getStudentProfile().join();
} catch (Exception e) {
return null;
}
}, opts).thenAccept(result -> {
javafx.application.Platform.runLater(() -> {
welcomeLabel.getStyleClass().remove("skeleton-text");
classLabel.getStyleClass().remove("skeleton-text");
if (result.isSuccess() && result.getData().isPresent()) {
var profile = result.getData().get();
welcomeLabel.setText(profile.getFullName());
classLabel.setText(profile.getClassName());
} else {
String username = appState.getUsername();
if (username == null) username = "User";
welcomeLabel.setText(username);
classLabel.setText("Unknown Class");
}
});
});
}
}
}
@@ -0,0 +1,26 @@
package cz.jzitnik.controllers;
import cz.jzitnik.auth.CredentialStore;
import cz.jzitnik.router.Route;
import cz.jzitnik.router.Router;
import javafx.fxml.FXML;
import java.util.Map;
@Route(path = "/dashboard", fxml = "/dashboard_modern.fxml")
public class DashboardController extends DashboardBaseController {
@Override
public void onNavigate(Map<String, Object> props) {
super.onNavigate(props);
}
@FXML
public void onLogoutClick() {
appState.clear();
try {
CredentialStore.clearCredentials();
} catch (Throwable ignored) {}
Router.getInstance().navigate("/login");
}
}
@@ -0,0 +1,301 @@
package cz.jzitnik.controllers;
import cz.jzitnik.router.Route;
import cz.jzitnik.router.Router;
import cz.jzitnik.state.AppState;
import cz.jzitnik.query.QueryOptions;
import cz.jzitnik.query.QueryResult;
import io.github.tomhula.jecnaapi.data.grade.GradesPage;
import io.github.tomhula.jecnaapi.data.grade.Subject;
import io.github.tomhula.jecnaapi.data.grade.Grade;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Route(path = "/grades", fxml = "/grades.fxml")
public class GradesController extends DashboardBaseController {
@FXML
private VBox contentBox;
@FXML
private ProgressIndicator loadingIndicator;
@FXML
private ScrollPane scrollPane;
private GradesPage currentPage;
private final Map<String, List<PredictedGrade>> predictions = new HashMap<>();
public record PredictedGrade(int value, boolean isSmall) {}
@Override
public void onNavigate(Map<String, Object> props) {
super.onNavigate(props);
predictions.clear();
loadGrades();
}
private void loadGrades() {
loadingIndicator.setVisible(true);
scrollPane.setVisible(false);
contentBox.getChildren().clear();
appState.getQueryClient().fetch("grades:page", () -> {
try {
return appState.getClient().getGradesPage().join();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}, QueryOptions.defaultOptions()).thenAccept(this::handleGradesResult);
}
private void handleGradesResult(QueryResult<GradesPage> result) {
Platform.runLater(() -> {
loadingIndicator.setVisible(false);
if (result.isSuccess() && result.getData().isPresent()) {
scrollPane.setVisible(true);
currentPage = result.getData().get();
renderAllSubjects();
} else {
Label errorLabel = new Label("Failed to load grades.");
errorLabel.getStyleClass().addAll("text-danger", "title-3");
contentBox.getChildren().add(errorLabel);
contentBox.setAlignment(Pos.CENTER);
scrollPane.setVisible(true);
}
});
}
private void renderAllSubjects() {
contentBox.getChildren().clear();
contentBox.setAlignment(Pos.TOP_CENTER);
if (currentPage.getSubjects() == null || currentPage.getSubjects().isEmpty()) {
Label emptyLabel = new Label("No grades available.");
emptyLabel.getStyleClass().add("text-muted");
contentBox.getChildren().add(emptyLabel);
return;
}
for (Subject subject : currentPage.getSubjects()) {
VBox card = createSubjectCard(subject);
contentBox.getChildren().add(card);
}
}
private Float calculateAverage(Subject subject, List<PredictedGrade> preds) {
float sum = 0;
float count = 0;
if (subject.getGrades().isNotEmpty()) {
for (String part : subject.getGrades().getSubjectParts()) {
for (Grade g : subject.getGrades().get(part)) {
if (g.getValue() > 0 && g.getValue() <= 5) {
float weight = g.getSmall() ? 0.5f : 1.0f;
sum += g.getValue() * weight;
count += weight;
}
}
}
}
if (preds != null) {
for (PredictedGrade pg : preds) {
float weight = pg.isSmall() ? 0.5f : 1.0f;
sum += pg.value() * weight;
count += weight;
}
}
return count > 0 ? sum / count : null;
}
private VBox createSubjectCard(Subject subject) {
String subjectNameFull = subject.getName().getFull();
List<PredictedGrade> preds = predictions.getOrDefault(subjectNameFull, new ArrayList<>());
VBox card = new VBox();
card.getStyleClass().addAll("card", "subject-card");
card.setSpacing(10);
card.setMaxWidth(800);
// Header: Subject Name and Average
HBox header = new HBox();
header.setAlignment(Pos.CENTER_LEFT);
Label subjectName = new Label(subjectNameFull);
subjectName.getStyleClass().addAll("title-3");
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
header.getChildren().addAll(subjectName, spacer);
Float avg = calculateAverage(subject, preds);
if (avg != null && !avg.isNaN()) {
Label averageLabel = new Label(String.format("Průměr: %.2f", avg));
averageLabel.getStyleClass().addAll("text-muted", "text-bold");
if (!preds.isEmpty()) {
averageLabel.setText(averageLabel.getText() + " (s predikcí)");
averageLabel.setStyle("-fx-text-fill: -color-accent-emphasis;");
}
header.getChildren().add(averageLabel);
}
// Body: Grades FlowPane
FlowPane gradesPane = new FlowPane();
gradesPane.setHgap(8);
gradesPane.setVgap(8);
gradesPane.setAlignment(Pos.CENTER_LEFT);
boolean hasAnyGrades = false;
if (subject.getGrades().isNotEmpty()) {
for (String part : subject.getGrades().getSubjectParts()) {
for (Grade grade : subject.getGrades().get(part)) {
gradesPane.getChildren().add(createGradeBadge(grade.getValue(), grade.getSmall(), grade.getValueChar(), false, grade.getDescription(), grade.getTeacher() != null ? grade.getTeacher().getShort() : null, grade.getReceiveDate() != null ? grade.getReceiveDate().toString() : null));
hasAnyGrades = true;
}
}
}
for (int i = 0; i < preds.size(); i++) {
PredictedGrade pg = preds.get(i);
StackPane pBadge = createGradeBadge(pg.value(), pg.isSmall(), ' ', true, "Predikce", null, null);
// Add click to remove
int indexToRemove = i;
pBadge.setOnMouseClicked(e -> {
preds.remove(indexToRemove);
if (preds.isEmpty()) predictions.remove(subjectNameFull);
renderAllSubjects();
});
Tooltip.install(pBadge, new Tooltip("Predikovaná známka. Kliknutím smažete."));
gradesPane.getChildren().add(pBadge);
hasAnyGrades = true;
}
if (!hasAnyGrades) {
Label noGrades = new Label("Zatím bez známek");
noGrades.getStyleClass().addAll("text-muted", "text-small");
gradesPane.getChildren().add(noGrades);
}
// Footer: Add Prediction Tool
StackPane addBadge = new StackPane();
Label addLabel = new Label("+");
addLabel.getStyleClass().add("text-muted");
addBadge.getChildren().add(addLabel);
addBadge.getStyleClass().addAll("grade-badge", "grade-add-btn");
Tooltip.install(addBadge, new Tooltip("Přidat predikci"));
addBadge.setOnMouseClicked(e -> showPredictionDialog(subjectNameFull, preds));
gradesPane.getChildren().add(addBadge);
card.getChildren().addAll(header, gradesPane);
return card;
}
private void showPredictionDialog(String subjectNameFull, List<PredictedGrade> preds) {
Dialog<PredictedGrade> dialog = new Dialog<>();
dialog.setTitle("Nová predikce");
dialog.setHeaderText("Přidat predikovanou známku pro " + subjectNameFull);
ButtonType addButtonType = new ButtonType("Přidat", ButtonBar.ButtonData.OK_DONE);
dialog.getDialogPane().getButtonTypes().addAll(addButtonType, ButtonType.CANCEL);
GridPane grid = new GridPane();
grid.setHgap(10);
grid.setVgap(10);
grid.setPadding(new Insets(20, 50, 10, 10));
ComboBox<Integer> gradeCombo = new ComboBox<>();
gradeCombo.getItems().addAll(1, 2, 3, 4, 5);
gradeCombo.getSelectionModel().selectFirst();
CheckBox smallCheck = new CheckBox("Malá známka");
grid.add(new Label("Známka:"), 0, 0);
grid.add(gradeCombo, 1, 0);
grid.add(smallCheck, 1, 1);
dialog.getDialogPane().setContent(grid);
dialog.setResultConverter(dialogButton -> {
if (dialogButton == addButtonType) {
return new PredictedGrade(gradeCombo.getValue(), smallCheck.isSelected());
}
return null;
});
dialog.showAndWait().ifPresent(pg -> {
preds.add(pg);
predictions.put(subjectNameFull, preds);
renderAllSubjects();
});
}
private StackPane createGradeBadge(int value, boolean isSmall, char valueChar, boolean isPredicted, String desc, String teacher, String date) {
StackPane badge = new StackPane();
String valStr = String.valueOf(valueChar);
if (value != 0) {
valStr = String.valueOf(value);
}
Label gradeLabel = new Label(valStr);
gradeLabel.getStyleClass().add("grade-text");
badge.getChildren().add(gradeLabel);
badge.getStyleClass().add("grade-badge");
if (isSmall) {
badge.getStyleClass().add("grade-small");
}
if (isPredicted) {
badge.getStyleClass().add("grade-predicted");
}
// Color coding
if (value == 1) badge.getStyleClass().add("grade-1");
else if (value == 2) badge.getStyleClass().add("grade-2");
else if (value == 3) badge.getStyleClass().add("grade-3");
else if (value == 4) badge.getStyleClass().add("grade-4");
else if (value == 5) badge.getStyleClass().add("grade-5");
else badge.getStyleClass().add("grade-other");
// Tooltip
StringBuilder tooltipText = new StringBuilder();
if (desc != null && !desc.isEmpty()) {
tooltipText.append(desc).append("\n");
}
if (teacher != null && !teacher.isEmpty()) {
tooltipText.append("Učitel: ").append(teacher).append("\n");
}
if (date != null && !date.isEmpty()) {
tooltipText.append("Datum: ").append(date);
}
if (!tooltipText.isEmpty() && !isPredicted) {
Tooltip tooltip = new Tooltip(tooltipText.toString().trim());
Tooltip.install(badge, tooltip);
}
return badge;
}
@FXML
protected void onBackToDashboard() {
Router.getInstance().navigate("/dashboard");
}
}
@@ -10,7 +10,7 @@ import javafx.scene.control.Label;
import java.util.Map; import java.util.Map;
@Route(fxml = "/home.fxml") @Route(path = "/home", fxml = "/home.fxml")
public class HomeController implements Routable { public class HomeController implements Routable {
@InjectState @InjectState
@@ -21,20 +21,15 @@ public class HomeController implements Routable {
@Override @Override
public void onNavigate(Map<String, Object> props) { public void onNavigate(Map<String, Object> props) {
// Because of dependency injection, appState is already fully populated
String username = appState.getUsername(); String username = appState.getUsername();
if (username == null) username = "User"; if (username == null) username = "User";
welcomeLabel.setText("Welcome, " + username + "!"); welcomeLabel.setText("Welcome, " + username + "!");
System.out.println("Global Auth Token is: " + appState.getToken());
} }
@FXML @FXML
public void onLogoutClick() { public void onLogoutClick() {
// Clear global state
appState.clear(); appState.clear();
// Redirect
Router.getInstance().navigate("/login"); Router.getInstance().navigate("/login");
} }
} }
@@ -6,12 +6,12 @@ import cz.jzitnik.router.Router;
import cz.jzitnik.state.AppState; import cz.jzitnik.state.AppState;
import cz.jzitnik.state.InjectState; import cz.jzitnik.state.InjectState;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.PasswordField; import javafx.scene.control.*;
import javafx.scene.control.TextField;
import java.util.Map; import java.util.Map;
import javafx.application.Platform;
@Route(fxml = "/login.fxml") @Route(path = "/login", fxml = "/login.fxml")
public class LoginController implements Routable { public class LoginController implements Routable {
@InjectState @InjectState
@@ -23,24 +23,48 @@ public class LoginController implements Routable {
@FXML @FXML
private PasswordField passwordField; private PasswordField passwordField;
@FXML
private Button loginButton;
@FXML
private ProgressIndicator loadingIndicator;
@Override @Override
public void onNavigate(Map<String, Object> props) { public void onNavigate(Map<String, Object> props) {
// Here you can use props if the screen was opened with them
} }
@FXML @FXML
public void onLoginClick() { public void onLoginClick() {
String username = usernameField.getText(); final String username = usernameField.getText();
final String password = passwordField.getText();
// Save the username globally into the app state if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
if (username != null && !username.isEmpty()) { Alert a = new Alert(Alert.AlertType.ERROR, "Please enter username and password");
appState.setUsername(username); a.showAndWait();
appState.setToken("dummy_token_123"); return;
} else {
appState.setUsername("Guest");
} }
// Redirect to the home screen (no longer need to pass username in props, it's global now!) loginButton.setDisable(true);
Router.getInstance().navigate("/home"); loadingIndicator.setVisible(true);
new Thread(() -> {
boolean ok = appState.login(username, password);
Platform.runLater(() -> {
loginButton.setDisable(false);
loadingIndicator.setVisible(false);
if (ok) {
Router.getInstance().navigate("/dashboard");
} else {
Alert a = new Alert(Alert.AlertType.ERROR, "Login failed: wrong credentials or error");
a.showAndWait();
}
});
}, "login-thread").start();
}
@FXML
public void onUsernameAction() {
passwordField.requestFocus();
} }
} }
@@ -0,0 +1,77 @@
package cz.jzitnik.query;
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Supplier;
public class QueryClient {
private final Map<String, CachedItem<?>> cache = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public <T> CompletableFuture<QueryResult<T>> fetch(String key, Supplier<T> fetcher, QueryOptions opts) {
@SuppressWarnings("unchecked") CachedItem<T> item = (CachedItem<T>) cache.get(key);
long now = System.currentTimeMillis();
if (item != null) {
// if cache is fresh
if (now - item.fetchedAt <= opts.staleTime) {
return CompletableFuture.completedFuture(new QueryResult<>(item.data, null));
}
}
// perform fetch in background
CompletableFuture<QueryResult<T>> future = CompletableFuture.supplyAsync(() -> {
int attempts = 0;
Throwable lastErr = null;
while (attempts <= opts.retryAttempts) {
try {
T data = fetcher.get();
CachedItem<T> ci = new CachedItem<>(data, System.currentTimeMillis());
cache.put(key, ci);
// schedule eviction
if (opts.cacheTime > 0) {
scheduler.schedule(() -> cache.remove(key), opts.cacheTime, TimeUnit.MILLISECONDS);
}
return new QueryResult<>(data, null);
} catch (Throwable t) {
lastErr = t;
attempts++;
}
}
return new QueryResult<>(null, lastErr);
});
if (opts.refetchInterval > 0) {
scheduler.scheduleAtFixedRate(() -> {
try {
fetcher.get();
} catch (Throwable ignored) {}
}, opts.refetchInterval, opts.refetchInterval, TimeUnit.MILLISECONDS);
}
return future;
}
public <T> void set(String key, T value) {
cache.put(key, new CachedItem<>(value, System.currentTimeMillis()));
}
public void invalidate(String key) {
cache.remove(key);
}
public void shutdown() {
scheduler.shutdownNow();
}
private static class CachedItem<T> {
final T data;
final long fetchedAt;
CachedItem(T data, long fetchedAt) {
this.data = data;
this.fetchedAt = fetchedAt;
}
}
}
@@ -0,0 +1,18 @@
package cz.jzitnik.query;
public class QueryOptions {
// time until data is considered stale (ms)
public long staleTime = 5 * 60 * 1000; // 5 minutes
// time until unused cache entry is evicted (ms)
public long cacheTime = 30 * 60 * 1000; // 30 minutes
// if >0, interval to refetch in background (ms)
public long refetchInterval = 0;
// number of retry attempts on failure
public int retryAttempts = 0;
public QueryOptions() {}
public static QueryOptions defaultOptions() {
return new QueryOptions();
}
}
@@ -0,0 +1,21 @@
package cz.jzitnik.query;
import java.time.Instant;
import java.util.Optional;
public class QueryResult<T> {
private final T data;
private final Throwable error;
private final Instant fetchedAt;
public QueryResult(T data, Throwable error) {
this.data = data;
this.error = error;
this.fetchedAt = Instant.now();
}
public Optional<T> getData() { return Optional.ofNullable(data); }
public Optional<Throwable> getError() { return Optional.ofNullable(error); }
public Instant getFetchedAt() { return fetchedAt; }
public boolean isSuccess() { return error == null; }
}
@@ -11,5 +11,6 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target(ElementType.TYPE)
public @interface Route { public @interface Route {
String path();
String fxml(); String fxml();
} }
+12 -8
View File
@@ -4,13 +4,13 @@ import cz.jzitnik.state.StateManager;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.layout.Pane; import javafx.scene.layout.Pane;
import org.reflections.Reflections;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
/**
* Scalable API for managing screens.
*/
public class Router { public class Router {
private static Router instance; private static Router instance;
private Pane rootContainer; private Pane rootContainer;
@@ -29,11 +29,15 @@ public class Router {
this.rootContainer = rootContainer; this.rootContainer = rootContainer;
} }
public void registerRoute(String path, Class<?> controllerClass) { public void registerAnnotatedRoutes(String basePackage) {
if (!controllerClass.isAnnotationPresent(Route.class)) { Reflections r = new Reflections(basePackage);
throw new IllegalArgumentException("Controller " + controllerClass.getName() + " must be annotated with @Route"); Set<Class<?>> controllers = r.getTypesAnnotatedWith(Route.class);
for (Class<?> c : controllers) {
Route a = c.getAnnotation(Route.class);
if (a != null) {
routes.put(a.path(), c);
}
} }
routes.put(path, controllerClass);
} }
public void navigate(String path) { public void navigate(String path) {
@@ -51,6 +55,7 @@ public class Router {
} }
Route routeAnnotation = controllerClass.getAnnotation(Route.class); Route routeAnnotation = controllerClass.getAnnotation(Route.class);
assert routeAnnotation != null;
String fxmlPath = routeAnnotation.fxml(); String fxmlPath = routeAnnotation.fxml();
try { try {
@@ -58,7 +63,6 @@ public class Router {
Parent view = loader.load(); Parent view = loader.load();
Object controller = loader.getController(); Object controller = loader.getController();
// Inject global state into the controller if it uses @InjectState
StateManager.getInstance().injectStates(controller); StateManager.getInstance().injectStates(controller);
if (controller instanceof Routable routable) { if (controller instanceof Routable routable) {
+45 -10
View File
@@ -1,33 +1,68 @@
package cz.jzitnik.state; package cz.jzitnik.state;
import cz.jzitnik.auth.CredentialStore;
import io.github.tomhula.jecnaapi.java.JecnaClientJavaWrapper;
import cz.jzitnik.query.QueryClient;
@State @State
public class AppState { public class AppState {
private String username; private String username;
private String token; // Example for some auth token private JecnaClientJavaWrapper client;
private QueryClient queryClient;
public String getUsername() { public String getUsername() {
return username; return username;
} }
public void setUsername(String username) { public JecnaClientJavaWrapper getClient() {
this.username = username; return client;
} }
public String getToken() { public QueryClient getQueryClient() {
return token; return queryClient;
} }
public void setToken(String token) { public void setQueryClient(QueryClient qc) {
this.token = token; this.queryClient = qc;
} }
public boolean isLoggedIn() { public boolean login(String username, String password) {
return username != null && !username.isEmpty(); if (username == null || username.isEmpty() || password == null) return false;
if (client == null) {
client = new JecnaClientJavaWrapper();
}
try {
boolean ok = client.login(username, password).join();
if (ok) {
this.username = username;
try {
CredentialStore.saveCredentials(username, password);
} catch (Throwable t) {
System.err.println("Failed to save credentials: " + t.getMessage());
}
} else {
this.client = null;
}
return ok;
} catch (Exception e) {
System.err.println("Login failed with exception: " + e.getMessage());
e.printStackTrace();
this.client = null;
return false;
}
} }
public void clear() { public void clear() {
this.username = null; this.username = null;
this.token = null; if (this.client != null) {
try {
this.client.logout();
} catch (Throwable ignored) {
}
this.client = null;
}
} }
} }
@@ -50,24 +50,53 @@ public class StateManager {
*/ */
public void injectStates(Object target) { public void injectStates(Object target) {
if (target == null) return; if (target == null) return;
Class<?> current = target.getClass();
while (current != null && current != Object.class) {
for (Field field : current.getDeclaredFields()) {
if (field.isAnnotationPresent(InjectState.class)) {
Class<?> fieldType = field.getType();
Object stateInstance = states.get(fieldType);
Class<?> targetClass = target.getClass(); if (stateInstance == null) {
for (Field field : targetClass.getDeclaredFields()) { for (Object candidate : states.values()) {
if (field.isAnnotationPresent(InjectState.class)) { if (candidate != null && fieldType.isAssignableFrom(candidate.getClass())) {
Class<?> fieldType = field.getType(); stateInstance = candidate;
Object stateInstance = states.get(fieldType); break;
}
}
}
if (stateInstance == null) { if (stateInstance == null) {
throw new RuntimeException("No @State found for type: " + fieldType.getName() + " in " + targetClass.getName()); throw new RuntimeException("No @State found for type: " + fieldType.getName() + " in " + current.getName());
}
try {
field.setAccessible(true);
field.set(target, stateInstance);
} catch (IllegalAccessException e) {
throw new RuntimeException("Failed to inject state into field: " + field.getName(), e);
}
} }
}
current = current.getSuperclass();
}
}
try { /**
field.setAccessible(true); * Retrieve a managed state singleton by its class.
field.set(target, stateInstance); */
} catch (IllegalAccessException e) { @SuppressWarnings("unchecked")
throw new RuntimeException("Failed to inject state into field: " + field.getName(), e); public <T> T getState(Class<T> cls) {
Object obj = states.get(cls);
if (obj == null) {
// try assignable match
for (Object candidate : states.values()) {
if (candidate != null && cls.isAssignableFrom(candidate.getClass())) {
obj = candidate;
break;
} }
} }
} }
return (T) obj;
} }
} }
@@ -11,23 +11,17 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.Circle; import javafx.scene.shape.Circle;
import javafx.util.Duration; import javafx.util.Duration;
/**
* Reusable animated background component.
*/
public class AuroraBackground extends StackPane { public class AuroraBackground extends StackPane {
public AuroraBackground() { public AuroraBackground() {
// LOWER OPACITY: Dropped from 0.3 to 0.1 for a subtle background feel
Color color1 = Color.web("#00FFFF", 0.10); // Cyan Color color1 = Color.web("#00FFFF", 0.10); // Cyan
Color color2 = Color.web("#FF00FF", 0.10); // Magenta Color color2 = Color.web("#FF00FF", 0.10); // Magenta
Color color3 = Color.web("#7D3CFF", 0.10); // Deep Purple Color color3 = Color.web("#7D3CFF", 0.10); // Deep Purple
// MASSIVE BLOBS: Increased radius so they fill large screens beautifully
Circle blob1 = new Circle(400, color1); Circle blob1 = new Circle(400, color1);
Circle blob2 = new Circle(350, color2); Circle blob2 = new Circle(350, color2);
Circle blob3 = new Circle(450, color3); Circle blob3 = new Circle(450, color3);
// HEAVY BLUR: Increased from 100 to 200 for maximum softness
GaussianBlur blur = new GaussianBlur(200); GaussianBlur blur = new GaussianBlur(200);
blob1.setEffect(blur); blob1.setEffect(blur);
blob2.setEffect(blur); blob2.setEffect(blur);
@@ -37,18 +31,13 @@ public class AuroraBackground extends StackPane {
blob2.setBlendMode(BlendMode.ADD); blob2.setBlendMode(BlendMode.ADD);
blob3.setBlendMode(BlendMode.ADD); blob3.setBlendMode(BlendMode.ADD);
// Because this is a StackPane, all circles start perfectly centered
getChildren().addAll(blob1, blob2, blob3); getChildren().addAll(blob1, blob2, blob3);
// Animate them drifting away from the center using translation
setupAnimation(blob1, -150, -100, 150, 100, 20); setupAnimation(blob1, -150, -100, 150, 100, 20);
setupAnimation(blob2, 150, 150, -150, -150, 25); setupAnimation(blob2, 150, 150, -150, -150, 25);
setupAnimation(blob3, 0, -200, 0, 200, 15); setupAnimation(blob3, 0, -200, 0, 200, 15);
} }
/**
* Animates a node shifting back and forth from a center point.
*/
private void setupAnimation(Circle node, double startX, double startY, double endX, double endY, int durationSeconds) { private void setupAnimation(Circle node, double startX, double startY, double endX, double endY, int durationSeconds) {
Timeline timeline = new Timeline( Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyFrame(Duration.ZERO,
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.StackPane?>
<StackPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="cz.jzitnik.controllers.DashboardBaseController">
<VBox alignment="TOP_CENTER" spacing="12.0" maxWidth="700.0" style="-fx-background-color: transparent;">
<padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0"/>
</padding>
<Label text="Dashboard" styleClass="title-1"/>
<Label fx:id="welcomeLabel" styleClass="text-muted"/>
<VBox spacing="10.0" maxWidth="400.0">
<Button text="Známky" onAction="#onNavigateToGrades" maxWidth="Infinity" styleClass="accent"/>
<Button text="Rozvrh" onAction="#onNavigateToTimetable" maxWidth="Infinity"/>
<Button text="Učitelé" onAction="#onNavigateToTeachers" maxWidth="Infinity"/>
<Button text="Učebny" onAction="#onNavigateToRooms" maxWidth="Infinity"/>
<Button text="Absence" onAction="#onNavigateToAbsences" maxWidth="Infinity"/>
<Button text="Mimořádný rozvrh" onAction="#onNavigateToSpecial" maxWidth="Infinity"/>
</VBox>
<Button text="Back" onAction="#onBack" maxWidth="200.0" styleClass="muted"/>
</VBox>
</StackPane>
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>
<BorderPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="cz.jzitnik.controllers.DashboardController"
style="-fx-background-color: linear-gradient(#0f1724, #071023); -fx-padding: 20;">
<!-- Header -->
<top>
<HBox spacing="10" alignment="CENTER_LEFT">
<Label text="JecnaClient" styleClass="title-2 text-light"/>
<Region HBox.hgrow="ALWAYS" />
<HBox spacing="15" alignment="CENTER_RIGHT">
<VBox alignment="CENTER_RIGHT" spacing="2">
<Label fx:id="welcomeLabel" styleClass="text-light skeleton-text"/>
<Label fx:id="classLabel" styleClass="text-muted skeleton-text"/>
</VBox>
<Button text="Logout" onAction="#onLogoutClick" styleClass="danger small"/>
</HBox>
</HBox>
</top>
<!-- Grid menu -->
<center>
<GridPane hgap="20" vgap="20" alignment="CENTER">
<padding>
<Insets top="30" right="30" bottom="30" left="30"/>
</padding>
<!-- Row 0 -->
<Button text="Známky" GridPane.rowIndex="0" GridPane.columnIndex="0" onAction="#onNavigateToGrades" styleClass="card"/>
<Button text="Rozvrh" GridPane.rowIndex="0" GridPane.columnIndex="1" onAction="#onDoNothing" styleClass="card"/>
<Button text="Učitelé" GridPane.rowIndex="0" GridPane.columnIndex="2" onAction="#onDoNothing" styleClass="card"/>
<!-- Row 1 -->
<Button text="Učebny" GridPane.rowIndex="1" GridPane.columnIndex="0" onAction="#onDoNothing" styleClass="card"/>
<Button text="Absence" GridPane.rowIndex="1" GridPane.columnIndex="1" onAction="#onDoNothing" styleClass="card"/>
<Button text="Mimořádný rozvrh" GridPane.rowIndex="1" GridPane.columnIndex="2" onAction="#onDoNothing" styleClass="card"/>
</GridPane>
</center>
</BorderPane>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressIndicator?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.layout.VBox?>
<BorderPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="cz.jzitnik.controllers.GradesController"
style="-fx-background-color: linear-gradient(#0f1724, #071023);">
<!-- Header -->
<top>
<HBox spacing="15" alignment="CENTER_LEFT" style="-fx-background-color: rgba(255, 255, 255, 0.05); -fx-padding: 15 25;">
<Button text="← Zpět" onAction="#onBackToDashboard" styleClass="flat, small" />
<Label text="Známky" styleClass="title-2, text-light" />
</HBox>
</top>
<!-- Content -->
<center>
<StackPane>
<ProgressIndicator fx:id="loadingIndicator" maxWidth="50" maxHeight="50" />
<ScrollPane fx:id="scrollPane" fitToWidth="true" style="-fx-background: transparent; -fx-background-color: transparent;" visible="false">
<padding>
<Insets top="20" right="30" bottom="30" left="30"/>
</padding>
<VBox fx:id="contentBox" spacing="15" alignment="TOP_CENTER" maxWidth="900" />
</ScrollPane>
</StackPane>
</center>
</BorderPane>
+6 -3
View File
@@ -29,10 +29,13 @@
</VBox> </VBox>
<Label prefHeight="15.0"/> <Label prefHeight="15.0"/>
<TextField fx:id="usernameField" promptText="Username or Email"/> <TextField fx:id="usernameField" promptText="Username or Email" onAction="#onUsernameAction"/>
<PasswordField fx:id="passwordField" promptText="Password"/> <PasswordField fx:id="passwordField" promptText="Password" onAction="#onLoginClick"/>
<Button text="Login" onAction="#onLoginClick" maxWidth="Infinity" styleClass="accent"/> <?import javafx.scene.control.ProgressIndicator?>
<ProgressIndicator fx:id="loadingIndicator" visible="false" prefWidth="24.0" prefHeight="24.0" />
<Button fx:id="loginButton" text="Login" onAction="#onLoginClick" maxWidth="Infinity" styleClass="accent" defaultButton="true"/>
</VBox> </VBox>
</StackPane> </StackPane>
+124
View File
@@ -0,0 +1,124 @@
/* Dashboard styles: modern cards and header */
.title-2.text-light {
-fx-text-fill: white;
-fx-font-size: 18px;
-fx-font-weight: 600;
}
.text-light {
-fx-text-fill: rgba(255,255,255,0.8);
}
.card {
-fx-background-color: rgba(255,255,255,0.06);
-fx-text-fill: white;
-fx-padding: 20 24 20 24;
-fx-background-radius: 12;
-fx-font-size: 14px;
-fx-pref-width: 220px;
-fx-pref-height: 120px;
-fx-alignment: CENTER;
-fx-effect: dropshadow(two-pass-box, rgba(0,0,0,0.4), 6, 0.0, 0, 2);
}
.card:hover {
-fx-background-color: linear-gradient(to bottom right, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
-fx-cursor: hand;
-fx-scale-x: 1.02;
-fx-scale-y: 1.02;
}
.accent {
-fx-background-color: linear-gradient(#3b82f6, #06b6d4);
-fx-text-fill: white;
}
.danger {
-fx-background-color: #ef4444;
-fx-text-fill: white;
}
.danger.small {
-fx-font-size: 11px;
-fx-padding: 6 10 6 10;
}
.skeleton-text {
-fx-background-color: rgba(255, 255, 255, 0.1);
-fx-background-radius: 4;
-fx-min-width: 100px;
-fx-min-height: 16px;
-fx-text-fill: transparent;
}
/* Grades Styling */
.subject-card {
-fx-background-color: rgba(30, 40, 56, 0.7);
-fx-border-color: rgba(255, 255, 255, 0.05);
-fx-border-radius: 8px;
-fx-background-radius: 8px;
-fx-padding: 15px 20px;
}
.grade-badge {
-fx-background-radius: 5px;
-fx-min-width: 32px;
-fx-min-height: 32px;
-fx-alignment: center;
-fx-padding: 2px 8px;
}
.grade-small {
-fx-min-height: 20px;
-fx-max-height: 20px;
-fx-min-width: 32px;
-fx-opacity: 0.85;
-fx-padding: 0px 8px;
}
.grade-predicted {
-fx-background-color: transparent !important;
-fx-border-style: dashed;
-fx-border-width: 1px;
-fx-border-radius: 4px;
-fx-opacity: 1.0;
}
.grade-predicted.grade-1 { -fx-border-color: #2ea043; }
.grade-predicted.grade-1 .grade-text { -fx-text-fill: #2ea043; }
.grade-predicted.grade-2 { -fx-border-color: #8957e5; }
.grade-predicted.grade-2 .grade-text { -fx-text-fill: #8957e5; }
.grade-predicted.grade-3 { -fx-border-color: #d29922; }
.grade-predicted.grade-3 .grade-text { -fx-text-fill: #d29922; }
.grade-predicted.grade-4 { -fx-border-color: #f85149; }
.grade-predicted.grade-4 .grade-text { -fx-text-fill: #f85149; }
.grade-predicted.grade-5 { -fx-border-color: #da3633; }
.grade-predicted.grade-5 .grade-text { -fx-text-fill: #da3633; }
.grade-predicted.grade-other { -fx-border-color: #6e7681; }
.grade-predicted.grade-other .grade-text { -fx-text-fill: #6e7681; }
.grade-add-btn {
-fx-background-color: rgba(255, 255, 255, 0.02);
-fx-border-color: rgba(255, 255, 255, 0.2);
-fx-border-style: dashed;
-fx-border-radius: 5px;
-fx-cursor: hand;
}
.grade-add-btn:hover {
-fx-background-color: rgba(255, 255, 255, 0.08);
}
.grade-small .grade-text {
-fx-font-size: 0.85em;
}
.grade-text {
-fx-font-weight: bold;
-fx-text-fill: #ffffff;
}
.grade-1 { -fx-background-color: #2ea043; } /* Primer Green */
.grade-2 { -fx-background-color: #8957e5; } /* Primer Purple */
.grade-3 { -fx-background-color: #d29922; } /* Primer Yellow */
.grade-4 { -fx-background-color: #f85149; } /* Primer Red */
.grade-5 { -fx-background-color: #da3633; -fx-border-color: #ff7b72; -fx-border-width: 1px; -fx-border-radius: 4px; } /* Darker Red */
.grade-other { -fx-background-color: #6e7681; } /* Primer Gray */