494 lines
13 KiB
JavaScript
494 lines
13 KiB
JavaScript
/* ==========================================================================
|
|
librus-tui frontend - navigation, commands, events, bootstrap
|
|
========================================================================== */
|
|
|
|
"use strict";
|
|
|
|
function navigate(view) {
|
|
if (!state.session || !state.session.authenticated) return;
|
|
const target = VIEWS.includes(view) ? view : "dashboard";
|
|
|
|
state.view = target;
|
|
if (target !== "grades") state.gradeFilter = "";
|
|
|
|
$$("#sidebar .nav-item[data-view]").forEach((element) => {
|
|
element.classList.toggle("active", element.dataset.view === target);
|
|
});
|
|
|
|
const title = $("#view-title");
|
|
if (title) title.textContent = VIEW_TITLES[target] || target;
|
|
document.title = `librus-tui :: ${target}`;
|
|
const display = $("#command-display");
|
|
if (display) display.textContent = `synergia:${target}`;
|
|
closeModal();
|
|
|
|
history.replaceState(null, "", `#${target}`);
|
|
|
|
const viewElement = $("#view");
|
|
if (viewElement) viewElement.scrollTop = 0;
|
|
|
|
loadView(target);
|
|
}
|
|
|
|
async function loadView(view) {
|
|
if (!state.session || !state.session.authenticated) return;
|
|
const token = ++state.loadToken;
|
|
setViewLoading(`loading ${VIEW_TITLES[view] || view}…`);
|
|
|
|
try {
|
|
switch (view) {
|
|
case "grades":
|
|
await renderGrades(token);
|
|
break;
|
|
case "timetable":
|
|
await renderTimetable(token);
|
|
break;
|
|
case "homework":
|
|
await renderHomework(token);
|
|
break;
|
|
case "attendance":
|
|
await renderAttendance(token);
|
|
break;
|
|
case "messages":
|
|
await renderMessages(token);
|
|
break;
|
|
case "freedays":
|
|
await renderFreedays(token);
|
|
break;
|
|
case "dashboard":
|
|
default:
|
|
await renderDashboard(token);
|
|
break;
|
|
}
|
|
|
|
if (token !== state.loadToken) return;
|
|
setViewReady();
|
|
} catch (error) {
|
|
if (token !== state.loadToken) return;
|
|
if (error instanceof ApiError && error.status === 401) return;
|
|
renderViewError(error);
|
|
}
|
|
}
|
|
|
|
function renderViewError(error) {
|
|
stopSpinner();
|
|
setLed("offline");
|
|
setStatus(`[fail] ${error.message}`, "error");
|
|
setMeta("error");
|
|
const message = esc(error.message || "Unknown error.");
|
|
$("#view").innerHTML = `
|
|
<div class="error-panel">
|
|
<div class="panel-title"><span>signal lost</span><span class="title-note">upstream error</span></div>
|
|
<div class="error-message">
|
|
${message}
|
|
<br><br>
|
|
<span class="dim">The Synergia API may be temporarily unavailable, or the session may have expired.
|
|
Check the server log for details.</span>
|
|
</div>
|
|
<div class="error-actions">
|
|
<button class="btn btn-warn" type="button" data-action="retry">[ retry ]</button>
|
|
<button class="btn" type="button" data-action="logout">[ logout ]</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function refreshCurrent() {
|
|
if (!state.session || !state.session.authenticated) return;
|
|
loadView(state.view);
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
toast("[ok] exited fullscreen", "ok", 1400);
|
|
} else {
|
|
document.documentElement.requestFullscreen();
|
|
toast("[ok] entering fullscreen", "ok", 1400);
|
|
}
|
|
} catch (error) {
|
|
toast(`[fail] ${error.message || "fullscreen unavailable"}`, "warn");
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ commands */
|
|
|
|
function openCommandBar() {
|
|
const line = $("#command-line");
|
|
const input = $("#command-input");
|
|
const hint = $("#command-hint");
|
|
const display = $("#command-display");
|
|
if (!line || !input) return;
|
|
line.classList.add("command-mode");
|
|
input.classList.remove("hidden");
|
|
if (hint) hint.classList.add("hidden");
|
|
if (display) display.textContent = "";
|
|
input.value = "";
|
|
input.focus();
|
|
}
|
|
|
|
function closeCommandBar() {
|
|
const line = $("#command-line");
|
|
const input = $("#command-input");
|
|
const hint = $("#command-hint");
|
|
const display = $("#command-display");
|
|
if (line) line.classList.remove("command-mode");
|
|
if (input) {
|
|
input.classList.add("hidden");
|
|
input.value = "";
|
|
}
|
|
if (hint) hint.classList.remove("hidden");
|
|
if (display) {
|
|
display.textContent = state.session?.authenticated ? `synergia:${state.view}` : "";
|
|
}
|
|
}
|
|
|
|
function runCommand(raw) {
|
|
const text = String(raw || "").trim().replace(/^:/, "");
|
|
if (!text) return;
|
|
|
|
state.commandHistory.push(text);
|
|
if (state.commandHistory.length > 60) state.commandHistory.shift();
|
|
state.commandPosition = -1;
|
|
|
|
const parts = text.split(/\s+/);
|
|
const command = parts[0].toLowerCase();
|
|
const argument = parts.slice(1).join(" ");
|
|
|
|
if (VIEW_ALIASES[command]) {
|
|
navigate(VIEW_ALIASES[command]);
|
|
return;
|
|
}
|
|
|
|
switch (command) {
|
|
case "reload":
|
|
case "refresh":
|
|
case "r":
|
|
refreshCurrent();
|
|
toast(`Refreshing ${VIEW_TITLES[state.view] || state.view}…`, "ok", 1800);
|
|
break;
|
|
case "logout":
|
|
case "q":
|
|
confirmLogout();
|
|
break;
|
|
case "help":
|
|
case "?":
|
|
openHelpModal();
|
|
break;
|
|
case "week": {
|
|
const week = Number.parseInt(argument, 10);
|
|
if (!Number.isFinite(week) || week < 0 || week > 3) {
|
|
toast("Usage: :week 0..3", "warn");
|
|
break;
|
|
}
|
|
state.timetableWeek = week;
|
|
navigate("timetable");
|
|
break;
|
|
}
|
|
case "goto": {
|
|
const target = VIEW_ALIASES[String(argument).toLowerCase()];
|
|
if (target) navigate(target);
|
|
else toast(`Unknown section: ${argument || "(empty)"}`, "warn");
|
|
break;
|
|
}
|
|
case "status":
|
|
toast(
|
|
`session ${state.session?.login || "—"} · ${state.session?.demo ? "demo" : "live"} · view ${state.view}`
|
|
);
|
|
break;
|
|
case "clear":
|
|
$("#view").innerHTML = `<div class="empty">screen cleared</div>`;
|
|
setStatus("[ok] screen cleared", "ok");
|
|
break;
|
|
case "fullscreen":
|
|
case "fs":
|
|
toggleFullscreen();
|
|
break;
|
|
default:
|
|
toast(`Unknown command: ${command}. Try :help.`, "warn");
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- actions */
|
|
|
|
function handleAction(action) {
|
|
switch (action) {
|
|
case "refresh":
|
|
refreshCurrent();
|
|
break;
|
|
case "help":
|
|
openHelpModal();
|
|
break;
|
|
case "logout":
|
|
confirmLogout();
|
|
break;
|
|
case "logout-confirm":
|
|
doLogout();
|
|
break;
|
|
case "tt-prev":
|
|
changeTimetableWeek(-1);
|
|
break;
|
|
case "tt-next":
|
|
changeTimetableWeek(1);
|
|
break;
|
|
case "grades-clear": {
|
|
state.gradeFilter = "";
|
|
const input = $("#grade-filter");
|
|
if (input) {
|
|
input.value = "";
|
|
input.focus();
|
|
}
|
|
renderGradesList();
|
|
break;
|
|
}
|
|
case "retry":
|
|
loadView(state.view);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------- events */
|
|
|
|
function bindEvents() {
|
|
const loginForm = $("#login-form");
|
|
if (loginForm) {
|
|
loginForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
submitLogin(false);
|
|
});
|
|
}
|
|
|
|
const demoButton = $("#demo-btn");
|
|
if (demoButton) {
|
|
demoButton.addEventListener("click", () => submitLogin(true));
|
|
}
|
|
|
|
const commandInput = $("#command-input");
|
|
if (commandInput) {
|
|
commandInput.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const value = commandInput.value;
|
|
closeCommandBar();
|
|
runCommand(value);
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeCommandBar();
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (state.commandHistory.length) {
|
|
if (state.commandPosition === -1) state.commandPosition = state.commandHistory.length - 1;
|
|
else state.commandPosition = Math.max(0, state.commandPosition - 1);
|
|
commandInput.value = state.commandHistory[state.commandPosition];
|
|
}
|
|
} else if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (state.commandPosition !== -1) {
|
|
state.commandPosition = Math.min(
|
|
state.commandHistory.length - 1,
|
|
state.commandPosition + 1
|
|
);
|
|
commandInput.value = state.commandHistory[state.commandPosition] || "";
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const viewElement = $("#view");
|
|
if (viewElement) {
|
|
viewElement.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && event.target && event.target.id === "grade-filter") {
|
|
event.target.blur();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const close = event.target.closest("[data-modal-close]");
|
|
if (close) {
|
|
event.preventDefault();
|
|
closeModal();
|
|
return;
|
|
}
|
|
|
|
const viewTarget = event.target.closest("[data-view]");
|
|
if (viewTarget) {
|
|
event.preventDefault();
|
|
navigate(viewTarget.dataset.view);
|
|
return;
|
|
}
|
|
|
|
const actionTarget = event.target.closest("[data-action]");
|
|
if (actionTarget && !actionTarget.disabled) {
|
|
event.preventDefault();
|
|
handleAction(actionTarget.dataset.action);
|
|
return;
|
|
}
|
|
|
|
const grade = event.target.closest("[data-grade]");
|
|
if (grade) {
|
|
openGradeDetail(Number(grade.dataset.grade));
|
|
return;
|
|
}
|
|
|
|
const exam = event.target.closest("[data-exam]");
|
|
if (exam) {
|
|
openExamDetail(Number(exam.dataset.exam));
|
|
return;
|
|
}
|
|
|
|
const message = event.target.closest("[data-message]");
|
|
if (message) {
|
|
selectMessage(Number(message.dataset.message));
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
|
|
const modalOpen = !$("#modal-root").classList.contains("hidden");
|
|
if (modalOpen) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeModal();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const appVisible = !$("#app-screen").classList.contains("hidden");
|
|
if (!appVisible) return;
|
|
|
|
const active = document.activeElement;
|
|
const typing =
|
|
active &&
|
|
(active.tagName === "INPUT" ||
|
|
active.tagName === "TEXTAREA" ||
|
|
active.tagName === "SELECT");
|
|
if (typing) return;
|
|
|
|
const viewElement = $("#view");
|
|
|
|
if (event.key === ":") {
|
|
event.preventDefault();
|
|
openCommandBar();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "?") {
|
|
event.preventDefault();
|
|
openHelpModal();
|
|
return;
|
|
}
|
|
|
|
if (event.key >= "1" && event.key <= "7") {
|
|
event.preventDefault();
|
|
navigate(VIEWS[Number(event.key) - 1]);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "r" || event.key === "R") {
|
|
event.preventDefault();
|
|
refreshCurrent();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "q") {
|
|
event.preventDefault();
|
|
confirmLogout();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "/" && state.view === "grades") {
|
|
event.preventDefault();
|
|
focusGradeFilter();
|
|
return;
|
|
}
|
|
|
|
if ((event.key === "[" || event.key === "]") && state.view === "timetable") {
|
|
event.preventDefault();
|
|
changeTimetableWeek(event.key === "]" ? 1 : -1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "j" || event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (state.view === "messages") moveMessage(1);
|
|
else if (viewElement) viewElement.scrollBy({ top: 76, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "k" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (state.view === "messages") moveMessage(-1);
|
|
else if (viewElement) viewElement.scrollBy({ top: -76, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "g") {
|
|
event.preventDefault();
|
|
if (state.view === "messages") selectMessage(0);
|
|
else if (viewElement) viewElement.scrollTo({ top: 0, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "G") {
|
|
event.preventDefault();
|
|
if (state.view === "messages") selectMessage(state.messages.length - 1);
|
|
else if (viewElement) viewElement.scrollTo({ top: viewElement.scrollHeight, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter" && state.view === "messages" && active?.tagName !== "A") {
|
|
event.preventDefault();
|
|
loadMessage(state.messageIndex, true);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
const target = location.hash.replace(/^#/, "");
|
|
if (
|
|
state.session &&
|
|
state.session.authenticated &&
|
|
VIEWS.includes(target) &&
|
|
target !== state.view
|
|
) {
|
|
navigate(target);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ bootstrap */
|
|
|
|
async function init() {
|
|
initClock();
|
|
bindEvents();
|
|
|
|
try {
|
|
state.config = await api("/api/config");
|
|
} catch (error) {
|
|
state.config = { demo: false, version: "1.0.0" };
|
|
}
|
|
updateDemoUI();
|
|
|
|
try {
|
|
const session = await api("/api/session");
|
|
if (session && session.authenticated) {
|
|
state.session = {
|
|
authenticated: true,
|
|
login: session.login,
|
|
demo: !!session.demo,
|
|
};
|
|
enterApp(session.login, session.demo);
|
|
} else {
|
|
showLogin();
|
|
}
|
|
} catch (error) {
|
|
showLogin(error.message);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|