Crank dat!
This commit is contained in:
17 files changed
+6624
No files matched your search
@@ -0,0 +1,493 @@
|
||||
/* ==========================================================================
|
||||
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);
|
||||
Reference in new issue
Block a user