/* ========================================================================== 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 = `
signal lostupstream error
${message}

The Synergia API may be temporarily unavailable, or the session may have expired. Check the server log for details.
`; } 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 = `
screen cleared
`; 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);