Files

534 lines
15 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 "settings":
await renderSettings(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 */
async 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;
case "settings-save": {
const next = collectSettingsFromForm();
saveSettings(next);
const status = $("#settings-status");
if (status) {
status.textContent = next.enabled && next.topic
? `saved · topic ${next.topic} · poll every ${next.pollMinutes} min`
: "saved · disabled (no topic)";
}
appendSettingsLog(next.enabled ? "settings saved · ntfy enabled" : "settings saved · ntfy disabled");
toast(next.enabled ? "Settings saved. NTFY enabled." : "Settings saved.", "ok", 1800);
startNotificationPolling();
break;
}
case "settings-test": {
const current = collectSettingsFromForm();
const status = $("#settings-status");
try {
await sendNtfy(current, "librus-tui test", "Test notification from librus-tui settings.");
if (status) status.textContent = "test push sent";
appendSettingsLog("test push sent");
toast("Test notification sent.", "ok", 1800);
} catch (error) {
if (status) status.textContent = `test failed: ${error.message}`;
appendSettingsLog(`test push failed: ${error.message}`);
toast(`[ntfy fail] ${error.message}`, "warn");
}
break;
}
case "settings-clear": {
const defaults = defaultSettings();
saveSettings(defaults);
renderSettings();
startNotificationPolling();
toast("Settings reset to defaults.", "ok", 1800);
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);