Crank dat!

This commit is contained in:
purplebored committed 2026-11-08 19:28:03 +01:00
commit fe13a4d989
17 files changed
+6624

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
/* ==========================================================================
librus-tui frontend - core: state, helpers, API, chrome
========================================================================== */
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays"];
const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const VIEW_TITLES = {
dashboard: "dashboard",
grades: "grades",
timetable: "timetable",
homework: "homework & tests",
attendance: "attendance",
messages: "messages",
freedays: "free days",
};
const VIEW_ALIASES = {
d: "dashboard",
dash: "dashboard",
dashboard: "dashboard",
g: "grades",
grades: "grades",
oceny: "grades",
t: "timetable",
plan: "timetable",
timetable: "timetable",
h: "homework",
hw: "homework",
exams: "homework",
homework: "homework",
sprawdziany: "homework",
a: "attendance",
attendance: "attendance",
frekwencja: "attendance",
m: "messages",
msg: "messages",
messages: "messages",
wiadomosci: "messages",
f: "freedays",
free: "freedays",
freedays: "freedays",
dni: "freedays",
};
const state = {
config: { demo: false, version: "1.0.0" },
session: null,
view: "dashboard",
loadToken: 0,
messageToken: 0,
loginBusy: false,
timetableWeek: 0,
gradeFilter: "",
gradeGroups: [],
gradesFlat: [],
exams: [],
messages: [],
messageIndex: -1,
messageCache: new Map(),
commandHistory: [],
commandPosition: -1,
lastSync: null,
};
let spinnerTimer = null;
let bootTimer = null;
/* ------------------------------------------------------------------ helpers */
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
}
function personName(person) {
if (!person) return "";
return `${person.FirstName || ""} ${person.LastName || ""}`.trim();
}
function parseDate(value) {
if (!value) return null;
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
function isoDate(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function fmtDate(value, withYear = true) {
const date = parseDate(value);
if (!date) return "—";
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
return withYear ? `${day}.${month}.${date.getFullYear()}` : `${day}.${month}`;
}
function fmtDateShort(value) {
return fmtDate(value, false);
}
function weekdayShort(value) {
const date = parseDate(value);
if (!date) return "";
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getDay()];
}
function daysUntil(value) {
const date = parseDate(value);
if (!date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
date.setHours(0, 0, 0, 0);
return Math.round((date - today) / 86400000);
}
function countdownLabel(value) {
const days = daysUntil(value);
if (days === null) return "";
if (days === 0) return "today";
if (days === 1) return "tomorrow";
if (days === -1) return "yesterday";
if (days > 1) return `in ${days} days`;
return `${-days} days ago`;
}
function countdownClass(value) {
const days = daysUntil(value);
if (days === 0) return "now";
if (days !== null && days > 0 && days <= 7) return "soon";
return "";
}
function shortTime(value) {
if (!value) return "";
const match = String(value).match(/^(\d{1,2}):(\d{2})/);
if (!match) return String(value);
return `${match[1].padStart(2, "0")}:${match[2]}`;
}
function timeRange(from, to) {
const start = shortTime(from);
const end = shortTime(to);
if (start && end) return `${start}–${end}`;
return start || end || "—";
}
function timeToMinutes(value) {
const match = String(value || "").match(/^(\d{1,2}):(\d{2})/);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function truncate(value, length = 90) {
const text = String(value ?? "");
if (text.length <= length) return text;
return `${text.slice(0, Math.max(0, length - 1))}…`;
}
function titleCase(value) {
const text = String(value ?? "");
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
}
function weightNumber(weight) {
const number = Number.parseFloat(String(weight ?? "").replace(",", "."));
return Number.isFinite(number) && number > 0 ? number : 1;
}
function numericGrade(grade) {
const number = Number.parseFloat(String(grade ?? "").replace(",", "."));
return Number.isFinite(number) ? number : null;
}
function gradeClass(grade) {
const number = numericGrade(grade);
if (number === null) return "g-none";
if (number >= 5) return "g-excellent";
if (number >= 4) return "g-good";
if (number >= 3) return "g-ok";
return "g-bad";
}
function subjectAverage(grades) {
let total = 0;
let weights = 0;
for (const grade of grades) {
const value = numericGrade(grade.Grade);
if (value === null) continue;
const weight = weightNumber(grade.Weight);
total += value * weight;
weights += weight;
}
return weights ? total / weights : null;
}
function averageLabel(grades) {
const average = subjectAverage(grades);
return average === null ? "—" : average.toFixed(2);
}
function examTagClass(type) {
const text = String(type || "").toLowerCase();
if (text.includes("sprawdzian") || text.includes("test")) return "tag-red";
if (text.includes("kartk") || text.includes("quiz")) return "tag-amber";
if (text.includes("projekt") || text.includes("wyprac") || text.includes("esej")) return "tag-cyan";
return "tag-dim";
}
function schoolAddress(school) {
if (!school) return "—";
const parts = [school.Street, school.BuildingNumber].filter(Boolean).join(" ");
const city = [school.PostCode, school.Town].filter(Boolean).join(" ");
return [parts, city].filter(Boolean).join(", ") || school.Name || "—";
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/* ---------------------------------------------------------------------- API */
class ApiError extends Error {
constructor(message, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
async function api(path, options = {}) {
const opts = { credentials: "same-origin", ...options };
if (opts.body && typeof opts.body !== "string") {
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
opts.body = JSON.stringify(opts.body);
}
let response;
try {
response = await fetch(path, opts);
} catch (error) {
throw new ApiError("Network error: cannot reach the librus-tui server.", 0);
}
let data = null;
try {
data = await response.json();
} catch (error) {
data = null;
}
if (!response.ok) {
if (
response.status === 401 &&
state.session &&
state.session.authenticated &&
!path.startsWith("/api/login")
) {
handleExpiredSession();
}
const message = (data && data.error) || `Request failed (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return data;
}
/* ------------------------------------------------------------ chrome helpers */
function setLed(kind) {
const led = $("#connection-led");
if (!led) return;
led.classList.remove("online", "busy", "offline");
if (kind) led.classList.add(kind);
}
function setStatus(text, kind = "") {
const element = $("#status-left");
if (!element) return;
element.textContent = text;
element.className = `status-left${kind ? ` ${kind}` : ""}`;
}
function setMeta(text) {
const element = $("#view-meta");
if (element) element.textContent = text;
}
function syncTime() {
return new Date().toLocaleTimeString("en-GB", { hour12: false });
}
function setViewReady(note) {
stopSpinner();
setLed("online");
state.lastSync = new Date();
const label = note || `loaded ${VIEW_TITLES[state.view] || state.view}`;
setMeta(`synced ${syncTime()}`);
setStatus(`[ok] ${label}`, "ok");
const statusRight = $("#status-right");
if (statusRight) statusRight.textContent = "synergia://connected";
}
function startSpinner() {
stopSpinner();
const frames = "|/-\\";
let index = 0;
spinnerTimer = setInterval(() => {
$$(".spinner").forEach((element) => {
element.textContent = frames[index % frames.length];
});
index += 1;
}, 110);
}
function stopSpinner() {
if (spinnerTimer) {
clearInterval(spinnerTimer);
spinnerTimer = null;
}
}
function setViewLoading(label) {
const view = $("#view");
if (!view) return;
view.innerHTML = `<div class="loading"><span class="spinner">|</span> ${esc(label)}</div>`;
setMeta("contacting synergia…");
setStatus(`[...] ${label}`);
setLed("busy");
startSpinner();
}
function toast(message, kind = "ok", duration = 3800) {
const root = $("#toasts");
if (!root) return;
const element = document.createElement("div");
element.className = `toast ${kind}`;
const prefix = document.createElement("span");
prefix.className = "toast-prefix";
prefix.textContent = kind === "err" ? "!!" : kind === "warn" ? "!" : ">>";
element.appendChild(prefix);
element.appendChild(document.createTextNode(message));
root.appendChild(element);
setTimeout(() => {
element.remove();
}, duration);
}
/* --------------------------------------------------------------------- modal */
function openModal(title, bodyHtml, actions = []) {
const root = $("#modal-root");
const titleElement = $("#modal-title");
const bodyElement = $("#modal-body");
const actionsElement = $("#modal-actions");
if (!root || !titleElement || !bodyElement || !actionsElement) return;
titleElement.textContent = title;
bodyElement.innerHTML = bodyHtml;
actionsElement.innerHTML = "";
for (const action of actions) {
const button = document.createElement("button");
button.type = "button";
button.className = `btn${action.kind ? ` ${action.kind}` : ""}`;
button.textContent = action.label;
if (action.close) {
button.dataset.modalClose = "";
} else if (action.action) {
button.dataset.action = action.action;
}
actionsElement.appendChild(button);
}
root.classList.remove("hidden");
const box = $("#modal-box");
if (box) box.focus();
}
function closeModal() {
const root = $("#modal-root");
if (root) root.classList.add("hidden");
}
function openHelpModal() {
const body = `
<p><b>librus-tui</b> is a keyboard-first terminal frontend for the Librus Synergia
student portal. Everything is rendered from the Synergia gateway API.</p>
<div class="help-section">global keys</div>
<dl class="help-grid">
<dt>1 … 7</dt><dd>jump to a section</dd>
<dt>r</dt><dd>refresh the current section</dd>
<dt>:</dt><dd>open the command line</dd>
<dt>?</dt><dd>this help window</dd>
<dt>q</dt><dd>log out</dd>
<dt>j / k</dt><dd>scroll, or move through the inbox</dd>
<dt>g / G</dt><dd>jump to top / bottom</dd>
</dl>
<div class="help-section">sections</div>
<dl class="help-grid">
<dt>grades</dt><dd><kbd>/</kbd> filter · click a grade for details</dd>
<dt>timetable</dt><dd><kbd>[</kbd> previous week · <kbd>]</kbd> next week</dd>
<dt>homework</dt><dd>click a row for the full description</dd>
<dt>messages</dt><dd><kbd>j</kbd>/<kbd>k</kbd> select · <kbd>Enter</kbd> open · attachments download</dd>
</dl>
<div class="help-section">commands</div>
<dl class="help-grid">
<dt>:dashboard :grades …</dt><dd>jump to any section (aliases: d, g, t, h, a, m, f)</dd>
<dt>:week 0..3</dt><dd>select a timetable week</dd>
<dt>:reload</dt><dd>refresh the current section</dd>
<dt>:status</dt><dd>print session information</dd>
<dt>:clear</dt><dd>clear the screen</dd>
<dt>:fullscreen / :fs</dt><dd>toggle browser fullscreen</dd>
<dt>:logout</dt><dd>end the Synergia session</dd>
</dl>
<div class="help-section">about</div>
<p>Synergia client adapted from
<a href="https://github.com/dani3l0/librusik" target="_blank" rel="noopener noreferrer">dani3l0/librusik</a>
(<code>lib/api/librus.py</code>, stable).<br>
Your credentials are exchanged directly with Librus and are never written to disk.</p>
`;
openModal("help", body, [{ label: "[ close ]", close: true }]);
}
/* ----------------------------------------------------------------- boot log */
function appendBoot(kind, text) {
const log = $("#bootlog");
if (!log) return;
const line = document.createElement("span");
if (kind) line.className = kind;
line.textContent = `${text}\n`;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
function clearBoot() {
const log = $("#bootlog");
if (log) log.innerHTML = "";
}
function stopBootTimer() {
if (bootTimer) {
clearInterval(bootTimer);
bootTimer = null;
}
}
function startBootTimer() {
stopBootTimer();
const lines = [
["info", " $ ssh synergia.librus.pl"],
["cmd", " GET /loguj/portalRodzina"],
["cmd", " POST api.librus.pl/OAuth/Authorization?client_id=46"],
["cmd", " GET /OAuth/Authorization/2FA?client_id=46"],
["cmd", " waiting for DZIENNIKSID / SDZIENNIKSID …"],
["cmd", " probing /gateway/api/2.0/Auth/TokenInfo …"],
];
let index = 0;
bootTimer = setInterval(() => {
if (index < lines.length) {
appendBoot(lines[index][0], lines[index][1]);
index += 1;
} else {
stopBootTimer();
}
}, 360);
}
+264
View File
@@ -0,0 +1,264 @@
/* ==========================================================================
librus-tui frontend - authentication, session chrome, clock
========================================================================== */
"use strict";
function initClock() {
const update = () => {
const now = new Date();
const dateElement = $("#date");
const clockElement = $("#clock");
if (dateElement) {
dateElement.textContent = now
.toLocaleDateString("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
})
.toUpperCase();
}
if (clockElement) {
clockElement.textContent = now.toLocaleTimeString("en-GB", { hour12: false });
}
};
update();
setInterval(update, 1000);
}
function updateDemoUI() {
const demoButton = $("#demo-btn");
if (demoButton) demoButton.classList.toggle("hidden", !state.config.demo);
const version = $("#version");
if (version) version.textContent = state.config.version || "1.0.0";
}
function setLoginBusy(busy) {
const loginInput = $("#login");
const passwordInput = $("#password");
const loginButton = $("#login-btn");
const demoButton = $("#demo-btn");
if (loginInput) loginInput.disabled = busy;
if (passwordInput) passwordInput.disabled = busy;
if (loginButton) {
loginButton.disabled = busy;
loginButton.textContent = busy ? "[ authenticating… ]" : "[ authenticate ]";
}
if (demoButton) {
demoButton.disabled = busy;
demoButton.textContent = busy ? "[ ... ]" : "[ enter demo mode ]";
}
}
function showLogin(message) {
state.session = null;
state.loginBusy = false;
state.loadToken += 1;
state.messageToken += 1;
stopSpinner();
stopBootTimer();
setLoginBusy(false);
const loginScreen = $("#login-screen");
const appScreen = $("#app-screen");
if (loginScreen) loginScreen.classList.remove("hidden");
if (appScreen) appScreen.classList.add("hidden");
const demoBadge = $("#demo-badge");
if (demoBadge) demoBadge.classList.add("hidden");
const version = $("#version");
if (version) version.textContent = state.config.version || "1.0.0";
setLed("offline");
setMeta("");
setStatus("ready");
const statusRight = $("#status-right");
if (statusRight) statusRight.textContent = "synergia://offline";
const sideStatus = $("#side-status");
if (sideStatus) sideStatus.textContent = "offline";
const sideName = $("#side-user-name");
if (sideName) sideName.textContent = "disconnected";
const sideMeta = $("#side-user-meta");
if (sideMeta) sideMeta.textContent = "synergia://";
const prompt = $("#prompt");
if (prompt) prompt.textContent = "~$";
closeCommandBar();
closeModal();
document.title = "librus-tui :: synergia";
history.replaceState(null, "", location.pathname);
const view = $("#view");
if (view) view.innerHTML = "";
if (message) {
clearBoot();
appendBoot("err", `!! ${message}`);
appendBoot("", " enter credentials to reconnect");
}
updateDemoUI();
if (state.config.demo) {
appendBoot("warn", "[!] demo mode available — no Librus account required");
}
setTimeout(() => {
const loginInput = $("#login");
if (loginInput) loginInput.focus();
}, 40);
}
function enterApp(login, demo) {
state.session = { authenticated: true, login: login || "user", demo: !!demo };
state.loginBusy = false;
setLoginBusy(false);
stopBootTimer();
stopSpinner();
const loginScreen = $("#login-screen");
const appScreen = $("#app-screen");
if (loginScreen) loginScreen.classList.add("hidden");
if (appScreen) appScreen.classList.remove("hidden");
const demoBadge = $("#demo-badge");
if (demoBadge) demoBadge.classList.toggle("hidden", !demo);
const version = $("#version");
if (version) version.textContent = state.config.version || "1.0.0";
setLed("online");
setStatus("[ok] session established", "ok");
const sideStatus = $("#side-status");
if (sideStatus) sideStatus.textContent = "connected";
const sideName = $("#side-user-name");
if (sideName) sideName.textContent = login || "connected";
const sideMeta = $("#side-user-meta");
if (sideMeta) sideMeta.textContent = demo ? "demo dataset" : "synergia://connected";
const statusRight = $("#status-right");
if (statusRight) statusRight.textContent = demo ? "synergia://demo" : "synergia://connected";
const prompt = $("#prompt");
if (prompt) prompt.textContent = `${String(login || "user").toLowerCase()}@librus:~$`;
toast(demo ? "Demo session started." : "Signed in to Synergia.", "ok");
const requested = location.hash.replace(/^#/, "");
const target = VIEWS.includes(requested) ? requested : "dashboard";
navigate(target);
}
function applyUserChrome(bootstrap) {
const me = (bootstrap && bootstrap.me) || {};
const school = (bootstrap && bootstrap.school) || {};
const fullName = personName(me) || state.session?.login || "student";
const sideName = $("#side-user-name");
if (sideName) sideName.textContent = fullName;
const sideMeta = $("#side-user-meta");
if (sideMeta) {
const classPart = me.Class ? `class ${me.Class}` : "student";
const schoolPart = school.Name ? ` · ${truncate(school.Name, 30)}` : "";
sideMeta.textContent = classPart + schoolPart;
}
const prompt = $("#prompt");
if (prompt) {
const shortName = (me.FirstName || state.session?.login || "user").toLowerCase();
prompt.textContent = `${shortName}@librus:~$`;
}
const statusRight = $("#status-right");
if (statusRight) {
statusRight.textContent = state.session?.demo
? "synergia://demo"
: "synergia://connected";
}
}
async function submitLogin(demo = false) {
if (state.loginBusy) return;
const loginInput = $("#login");
const passwordInput = $("#password");
const login = demo ? "demo" : (loginInput?.value || "").trim();
const password = demo ? "" : passwordInput?.value || "";
if (!demo && (!login || !password)) {
toast("Login and password are required.", "warn");
(login ? passwordInput : loginInput)?.focus();
return;
}
state.loginBusy = true;
setLoginBusy(true);
clearBoot();
setLed("busy");
setStatus("[...] authenticating with synergia");
setMeta("oauth handshake…");
appendBoot("cmd", " $ librus-tui auth --oauth client_id=46");
startBootTimer();
try {
const result = await api("/api/login", {
method: "POST",
body: demo ? { demo: true } : { login, password },
});
stopBootTimer();
appendBoot("ok", "[ ok ] credentials accepted");
appendBoot("info", " session established · cookies held server-side");
await sleep(420);
appendBoot("cmd", " launching interface …");
if (passwordInput) passwordInput.value = "";
state.session = {
authenticated: true,
login: demo ? "demo" : login,
demo: !!(result && result.demo),
};
await sleep(240);
enterApp(demo ? "demo" : login, !!(result && result.demo));
} catch (error) {
stopBootTimer();
appendBoot("err", `[fail] ${error.message}`);
setLed("offline");
setStatus("authentication failed", "error");
setMeta("login failed");
toast(error.message, "err", 7000);
state.loginBusy = false;
setLoginBusy(false);
passwordInput?.focus();
}
}
function handleExpiredSession() {
if (!state.session) return;
showLogin("Session expired or revoked by Synergia.");
toast("Your session expired. Sign in again.", "warn", 6000);
}
function confirmLogout() {
openModal(
"logout",
`<p>Close the current Synergia session?</p>
<p class="dim">Server-side cookies are discarded immediately. Nothing is persisted.</p>`,
[
{ label: "[ logout ]", action: "logout-confirm", kind: "btn-warn" },
{ label: "[ cancel ]", close: true },
]
);
}
async function doLogout() {
closeModal();
const wasLoggedIn = !!(state.session && state.session.authenticated);
if (wasLoggedIn) {
try {
await api("/api/logout", { method: "POST" });
} catch (error) {
/* the local session is dropped regardless */
}
}
state.session = null;
state.messages = [];
state.messageIndex = -1;
state.messageCache.clear();
showLogin("session closed.");
toast("Logged out.", "ok");
}
+220
View File
@@ -0,0 +1,220 @@
/* ==========================================================================
librus-tui frontend - dashboard view
========================================================================== */
"use strict";
async function renderDashboard(token) {
const [bootstrap, notifications, timetable, freedays] = await Promise.all([
api("/api/bootstrap"),
api("/api/notifications").catch(() => null),
api("/api/timetable?week=0").catch(() => null),
api("/api/freedays").catch(() => null),
]);
if (token !== state.loadToken) return;
applyUserChrome(bootstrap);
const me = bootstrap.me || {};
const school = bootstrap.school || {};
const behaviour = bootstrap.behaviour;
const lucky = bootstrap.luckyNumber;
const notif = notifications || {};
const now = new Date();
const todayName = DAY_NAMES[now.getDay()];
const todayIndex = (now.getDay() + 6) % 7; // Monday = 0
const todayIso = isoDate(now);
const week = (timetable && timetable.Timetable) || {};
let dayName = todayName;
let lessons = week[todayName] || [];
let dayDate = (lessons[0] && lessons[0].Date) || todayIso;
if (!lessons.length) {
// Prefer the next day that still belongs to the current week.
for (let offset = 1; offset < 7 - todayIndex; offset += 1) {
const candidate = WEEK_ORDER[todayIndex + offset];
const candidateLessons = week[candidate] || [];
if (candidateLessons.length) {
dayName = candidate;
lessons = candidateLessons;
dayDate = candidateLessons[0].Date || todayIso;
break;
}
}
}
if (!lessons.length) {
// Weekends: peek at next week so the panel is not empty.
try {
const nextWeek = await api("/api/timetable?week=1");
if (token !== state.loadToken) return;
const nextData = (nextWeek && nextWeek.Timetable) || {};
const firstDay = WEEK_ORDER.find((day) => (nextData[day] || []).length);
if (firstDay) {
dayName = firstDay;
lessons = nextData[firstDay];
dayDate = (lessons[0] && lessons[0].Date) || todayIso;
}
} catch (error) {
/* dashboard works without a preview */
}
}
const isToday = dayDate === todayIso;
const schoolFree = ((freedays && freedays.school) || [])
.filter((item) => item && item.DateFrom)
.slice()
.sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0));
const upcomingFree = schoolFree
.filter((item) => (daysUntil(item.DateFrom) ?? -1) >= 0)
.slice(0, 4);
const freeRows = upcomingFree.length
? upcomingFree
.map(
(item) => `
<div class="freeday-line" title="${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}">
<span class="name">${esc(item.Name || "free day")}</span>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))} · ${esc(fmtDateShort(item.DateFrom))}</span>
</div>`
)
.join("")
: `<div class="empty">no upcoming free days</div>`;
const lessonsHtml = renderDashboardLessons(lessons, isToday);
const html = `
<div class="view-inner">
<div class="dash-grid">
<section class="panel span-2 identity-panel">
<div class="panel-title">
<span>student profile</span>
<span class="title-note">${esc(me.Type || "synergia")}</span>
</div>
<div class="panel-body">
<div class="identity-name">${esc(me.FirstName || "")} ${esc(me.LastName || "")}</div>
<div class="identity-sub">
<span class="class-chip">class ${esc(me.Class || "—")}</span>
<span class="dim"> · tutor ${esc(personName(me.Tutor) || "—")}</span>
</div>
<dl class="kv">
<dt>school</dt><dd>${esc(school.Name || "—")}</dd>
<dt>address</dt><dd>${esc(schoolAddress(school))}</dd>
<dt>year</dt><dd>${esc(fmtDate(me.SchoolYearStarts))} → ${esc(fmtDate(me.SchoolYearEnds))}</dd>
<dt>semester</dt><dd>ends ${esc(fmtDate(me.SchoolYearMiddles))}</dd>
</dl>
</div>
</section>
<section class="panel">
<div class="panel-title"><span>lucky number</span><span class="title-note">daily draw</span></div>
<div class="lucky">
<div class="lucky-number">${lucky === null || lucky === undefined ? "–" : esc(lucky)}</div>
<div class="lucky-label">${lucky === null || lucky === undefined ? "not drawn yet" : "today's number"}</div>
</div>
</section>
<section class="panel behaviour-panel">
<div class="panel-title"><span>behaviour</span><span class="title-note">semester 1</span></div>
<div class="behaviour">
<div class="behaviour-grade">${behaviour ? esc(titleCase(behaviour.Grade)) : "—"}</div>
<div class="behaviour-teacher">${behaviour ? esc(personName(behaviour.Teacher) || "—") : "no grade yet"}</div>
</div>
</section>
<section class="panel counters-panel">
<div class="panel-title">
<span>counters</span>
<span class="title-note">click to open a section</span>
</div>
<div class="stat-grid">
<button class="stat" type="button" data-view="grades">
<span class="stat-value">${notif.grades ?? "—"}</span>
<span class="stat-label">grades</span>
</button>
<button class="stat" type="button" data-view="homework">
<span class="stat-value">${notif.exams ?? "—"}</span>
<span class="stat-label">homework</span>
</button>
<button class="stat warn" type="button" data-view="attendance">
<span class="stat-value">${notif.absences ?? "—"}</span>
<span class="stat-label">absences</span>
</button>
<button class="stat" type="button" data-view="freedays">
<span class="stat-value">${notif.conferences ?? "—"}</span>
<span class="stat-label">conferences</span>
</button>
</div>
</section>
<section class="panel span-2 today-panel">
<div class="panel-title">
<span>${isToday ? "today" : "next school day"} · ${esc(dayName)}</span>
<span class="title-note">${esc(fmtDate(dayDate))}${isToday ? "" : " · coming up"}</span>
</div>
<div class="panel-body tight">${lessonsHtml}</div>
</section>
<section class="panel freedays-panel">
<div class="panel-title">
<span>upcoming breaks</span>
<span class="title-note">${upcomingFree.length} ahead</span>
</div>
<div class="panel-body tight">${freeRows}</div>
</section>
</div>
</div>
`;
$("#view").innerHTML = html;
}
function renderDashboardLessons(lessons, isToday) {
if (!lessons || !lessons.length) {
return `<div class="empty">no lessons</div>`;
}
const sorted = lessons
.slice()
.sort((a, b) => (timeToMinutes(a.HourFrom) ?? 9999) - (timeToMinutes(b.HourFrom) ?? 9999));
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
let currentIndex = -1;
let nextIndex = -1;
if (isToday) {
for (let index = 0; index < sorted.length; index += 1) {
const lesson = sorted[index];
const start = timeToMinutes(lesson.HourFrom);
const end = timeToMinutes(lesson.HourTo);
if (start !== null && end !== null && nowMinutes >= start && nowMinutes <= end) {
currentIndex = index;
} else if (nextIndex === -1 && start !== null && start > nowMinutes && !lesson.isCancelled) {
nextIndex = index;
}
}
}
return `<div class="lesson-list">${sorted
.map((lesson, index) => {
const classes = ["lesson-line"];
if (index === currentIndex) classes.push("now");
if (index === nextIndex) classes.push("next");
if (lesson.isCancelled) classes.push("cancelled");
const marker = index === currentIndex ? ">" : index === nextIndex ? "»" : "·";
const room = [lesson.Classroom, personName(lesson.Teacher)].filter(Boolean).join(" · ");
return `
<div class="${classes.join(" ")}" title="${esc(room)}">
<span class="time">${esc(shortTime(lesson.HourFrom) || "—")}</span>
<span class="marker">${marker}</span>
<span class="subject">${esc(lesson.Subject || "lesson")}</span>
<span class="room">${esc(lesson.Classroom || "")}${lesson.isCancelled ? " [cancelled]" : ""}</span>
</div>`;
})
.join("")}</div>`;
}
+167
View File
@@ -0,0 +1,167 @@
/* ==========================================================================
librus-tui frontend - grades view
========================================================================== */
"use strict";
async function renderGrades(token) {
const data = await api("/api/grades");
if (token !== state.loadToken) return;
const subjects = data.subjects || {};
const sorted = Object.entries(subjects).sort((a, b) =>
String(a[0]).localeCompare(String(b[0]), "pl")
);
state.gradesFlat = [];
state.gradeGroups = sorted.map(([subject, grades]) => {
const items = (grades || []).map((grade) => {
const item = { ...grade, _subject: subject, _index: state.gradesFlat.length };
state.gradesFlat.push(item);
return item;
});
return { subject, grades: items };
});
const overall = averageLabel(state.gradesFlat);
const gradeCount = state.gradesFlat.length;
$("#view").innerHTML = `
<div class="view-inner">
<div class="toolbar">
<span>filter</span>
<input class="tool-input" id="grade-filter" type="text"
placeholder="subject or category…" spellcheck="false" autocomplete="off">
<button class="btn btn-sm" type="button" data-action="grades-clear">[ clear ]</button>
<span class="grow"></span>
<span class="grades-summary">
<span>average <b>${esc(overall)}</b></span>
<span><b>${gradeCount}</b> grades</span>
<span><b>${state.gradeGroups.length}</b> subjects</span>
</span>
</div>
<div class="grades-grid" id="grades-list"></div>
<div class="toolbar" style="margin-top:12px">
<div class="legend">
<span><span class="swatch green"></span>5 / 6</span>
<span><span class="swatch cyan"></span>4</span>
<span><span class="swatch amber"></span>3</span>
<span><span class="swatch red"></span>1 / 2</span>
<span class="dim">dashed chip = final · outlined = semester</span>
</div>
</div>
</div>
`;
const input = $("#grade-filter");
if (input) {
input.value = state.gradeFilter;
input.addEventListener("input", () => {
state.gradeFilter = input.value;
renderGradesList();
});
}
renderGradesList();
}
function gradeWeightLabel(weight) {
const raw = String(weight ?? "").toLowerCase();
if (!raw || raw === "none" || raw === "null") return "";
return `w${raw}`;
}
function renderGradesList() {
const container = $("#grades-list");
if (!container) return;
const query = state.gradeFilter.trim().toLowerCase();
const groups = state.gradeGroups.filter((group) => {
if (!query) return true;
if (group.subject.toLowerCase().includes(query)) return true;
return group.grades.some((grade) =>
`${grade.Category || ""} ${grade.Grade || ""}`.toLowerCase().includes(query)
);
});
if (!groups.length) {
container.innerHTML = `<div class="empty" style="grid-column:1/-1">no matching grades</div>`;
return;
}
container.innerHTML = groups
.map((group) => {
const average = averageLabel(group.grades);
const chips = group.grades.length
? group.grades
.map((grade) => {
const weight = gradeWeightLabel(grade.Weight);
const classes = ["grade-chip", gradeClass(grade.Grade)];
if (grade.isFinal) classes.push("final");
if (grade.isSemester) classes.push("semester");
const label = `${group.subject} · ${grade.Grade} · ${grade.Category || "grade"} · ${fmtDate(grade.AddDate)}`;
return `
<button class="${classes.join(" ")}" type="button" data-grade="${grade._index}" title="${esc(label)}">
${weight ? `<span class="g-weight">${esc(weight)}</span>` : ""}
<span class="g">${esc(grade.Grade)}</span>
<span class="g-meta">${esc(grade.Category || "—")}</span>
</button>`;
})
.join("")
: `<span class="dim" style="font-size:10.5px">— no grades yet —</span>`;
return `
<section class="panel subject-card">
<div class="panel-title">
<span>${esc(group.subject)}</span>
<span class="subject-average">avg ${esc(average)}</span>
</div>
<div class="panel-body">
<div class="grade-chips">${chips}</div>
</div>
</section>`;
})
.join("");
}
function focusGradeFilter() {
const input = $("#grade-filter");
if (input) {
input.focus();
input.select();
}
}
function openGradeDetail(index) {
const grade = state.gradesFlat[index];
if (!grade) return;
const flags = [];
if (grade.isFinal) flags.push("final");
if (grade.isSemester) flags.push("semester");
if (grade.Weight && String(grade.Weight).toLowerCase() !== "none") {
flags.push(`weight ${grade.Weight}`);
}
const comment =
grade.Comment && grade.Comment !== "No comment"
? `<div class="detail-comment">${esc(grade.Comment)}</div>`
: "";
const body = `
<dl class="detail-grid">
<dt>subject</dt><dd>${esc(grade._subject)}</dd>
<dt>grade</dt><dd>${esc(grade.Grade)}</dd>
<dt>category</dt><dd>${esc(grade.Category || "—")}</dd>
<dt>weight</dt><dd>${esc(grade.Weight ?? "—")}</dd>
<dt>added</dt><dd>${esc(fmtDate(grade.AddDate))}</dd>
<dt>teacher</dt><dd>${esc(personName(grade.AddedBy) || "—")}</dd>
<dt>semester</dt><dd>${esc(grade.Semester ?? "—")}${flags.length ? ` · ${esc(flags.join(" · "))}` : ""}</dd>
</dl>
${comment}
`;
openModal(`grade ${grade.Grade} · ${grade._subject}`, body, [
{ label: "[ close ]", close: true },
]);
}
+182
View File
@@ -0,0 +1,182 @@
/* ==========================================================================
librus-tui frontend - timetable view
========================================================================== */
"use strict";
async function renderTimetable(token) {
const data = await api(`/api/timetable?week=${state.timetableWeek}`);
if (token !== state.loadToken) return;
const timetable = data.Timetable || {};
const weekStart = parseDate(data.weekStart) || mondayOfCurrentWeek(state.timetableWeek);
const weekEnd = parseDate(data.weekEnd) || addDays(weekStart, 6);
const weekStartIso = isoDate(weekStart);
const weekEndIso = isoDate(weekEnd);
const columns = WEEK_ORDER.filter(
(day, index) => index < 5 || (timetable[day] || []).length > 0
);
const numericLessons = [];
let hasExtra = false;
for (const day of columns) {
for (const entry of timetable[day] || []) {
const raw = String(entry.Lesson ?? "");
if (/^\d+$/.test(raw)) {
numericLessons.push(Number(raw));
} else {
hasExtra = true;
}
}
}
const maxLesson = Math.max(8, ...numericLessons);
const rows = [];
for (let number = 1; number <= maxLesson; number += 1) rows.push(String(number));
if (hasExtra) rows.push("extra");
const grid = {};
for (const day of columns) {
grid[day] = new Map();
for (const entry of timetable[day] || []) {
const raw = String(entry.Lesson ?? "");
const key = /^\d+$/.test(raw) ? raw : "extra";
if (!grid[day].has(key)) grid[day].set(key, []);
grid[day].get(key).push(entry);
}
}
const today = isoDate();
const weekLabel =
state.timetableWeek === 0
? "current week"
: state.timetableWeek === 1
? "next week"
: `week +${state.timetableWeek}`;
const header = columns
.map((day) => {
const index = WEEK_ORDER.indexOf(day);
const date = addDays(weekStart, index);
const dateIso = isoDate(date);
return `
<div class="tt-head ${dateIso === today ? "today" : ""}">
<span>${esc(day.slice(0, 3))}</span>
<span class="date">${esc(fmtDate(dateIso, false))}</span>
</div>`;
})
.join("");
const body = rows
.map((row) => {
let timeLabel = "";
if (row !== "extra") {
for (const day of columns) {
const entry = (grid[day].get(row) || [])[0];
if (entry && entry.HourFrom) {
timeLabel = shortTime(entry.HourFrom);
break;
}
}
} else {
for (const day of columns) {
const entry = (grid[day].get("extra") || [])[0];
if (entry && entry.HourFrom) {
timeLabel = shortTime(entry.HourFrom);
break;
}
}
}
const hourCell = `
<div class="tt-hour">
<b>${row === "extra" ? "+" : esc(row)}</b>
<span>${esc(timeLabel)}</span>
</div>`;
const cells = columns
.map((day) => {
const entries = grid[day].get(row) || [];
if (!entries.length) {
return `<div class="tt-cell"><div class="tt-free">·</div></div>`;
}
return `<div class="tt-cell">${entries.map(renderTimetableEntry).join("")}</div>`;
})
.join("");
return hourCell + cells;
})
.join("");
$("#view").innerHTML = `
<div class="view-inner">
<div class="toolbar">
<button class="btn btn-sm" type="button" data-action="tt-prev" ${state.timetableWeek <= 0 ? "disabled" : ""}>[ &lt; prev ]</button>
<button class="btn btn-sm" type="button" data-action="tt-next" ${state.timetableWeek >= 3 ? "disabled" : ""}>[ next &gt; ]</button>
<span><b>${esc(weekLabel)}</b></span>
<span class="dim">${esc(fmtDate(weekStartIso))} → ${esc(fmtDate(weekEndIso))}</span>
<span class="grow"></span>
<span class="dim"><kbd>[</kbd> <kbd>]</kbd> switch week</span>
</div>
<div class="timetable-scroll">
<div class="timetable" style="grid-template-columns: 60px repeat(${columns.length}, minmax(150px, 1fr))">
<div class="tt-hour corner">lesson</div>
${header}
${body}
</div>
</div>
<div class="toolbar" style="margin-top:10px">
<span class="dim">
<span class="tag tag-amber">SUB</span> substitution ·
<span class="tag tag-red">CNL</span> cancelled ·
<span class="tag tag-cyan">EXTRA</span> additional activity
</span>
</div>
</div>
`;
}
function renderTimetableEntry(entry) {
const flags = [];
if (entry.isSubstitution) flags.push(`<span class="tt-flag sub">SUB</span>`);
if (entry.isCancelled) flags.push(`<span class="tt-flag cancel">CNL</span>`);
if (entry.isAdditional) flags.push(`<span class="tt-flag extra">EXTRA</span>`);
const teacher = personName(entry.Teacher);
const title = [
entry.Subject,
timeRange(entry.HourFrom, entry.HourTo),
entry.Classroom ? `room ${entry.Classroom}` : "",
teacher,
]
.filter(Boolean)
.join(" · ");
return `
<div class="tt-lesson ${entry.isCancelled ? "cancelled" : ""}" title="${esc(title)}">
<span class="tt-subject">${esc(entry.Subject || "lesson")}</span>
<span class="tt-meta">${esc(timeRange(entry.HourFrom, entry.HourTo))}</span>
<span class="tt-meta"><span class="tt-room">${esc(entry.Classroom || "?")}</span>${teacher ? ` · ${esc(teacher)}` : ""}</span>
${flags.length ? `<span class="tt-flags">${flags.join("")}</span>` : ""}
</div>`;
}
function mondayOfCurrentWeek(week = 0) {
const today = new Date();
const monday = addDays(today, -((today.getDay() + 6) % 7) + week * 7);
monday.setHours(0, 0, 0, 0);
return monday;
}
function addDays(date, days) {
const copy = new Date(date.getTime());
copy.setDate(copy.getDate() + days);
return copy;
}
function changeTimetableWeek(delta) {
const next = Math.min(3, Math.max(0, state.timetableWeek + delta));
if (next === state.timetableWeek) return;
state.timetableWeek = next;
navigate("timetable");
}
+385
View File
@@ -0,0 +1,385 @@
/* ==========================================================================
librus-tui frontend - homework, attendance, messages, free days
========================================================================== */
"use strict";
/* ------------------------------------------------------------------ homework */
async function renderHomework(token) {
const data = await api("/api/exams");
if (token !== state.loadToken) return;
const exams = (data.exams || []).slice().sort((a, b) => {
const left = parseDate(a.Date);
const right = parseDate(b.Date);
return (left ? left.getTime() : Number.MAX_SAFE_INTEGER) -
(right ? right.getTime() : Number.MAX_SAFE_INTEGER);
});
state.exams = exams;
const upcoming = exams.filter((exam) => (daysUntil(exam.Date) ?? -1) >= 0).length;
const rows = exams.length
? exams
.map((exam, index) => {
const distance = daysUntil(exam.Date);
const dateClass = distance === null ? "" : distance < 0 ? "soft" : distance <= 1 ? "urgent" : distance <= 3 ? "soon" : "";
const typeClass = examTagClass(exam.Type);
const content = exam.Content ? truncate(exam.Content, 84) : "—";
return `
<tr data-exam="${index}" title="click for details">
<td class="num">
<span class="date-badge ${dateClass}">${esc(fmtDate(exam.Date))}</span>
<div class="soft">${esc(weekdayShort(exam.Date))}</div>
</td>
<td class="strong">${esc(exam.Lesson || "—")}</td>
<td><span class="tag ${typeClass}">${esc(exam.Type || "—")}</span></td>
<td class="num">${esc(timeRange(exam.StartTime, exam.EndTime))}</td>
<td class="soft">${esc(personName(exam.AddedBy) || "—")}</td>
<td class="content-cell">${esc(content)}</td>
</tr>`;
})
.join("")
: "";
$("#view").innerHTML = `
<div class="view-inner">
<div class="toolbar">
<span><b>${upcoming}</b> upcoming</span>
<span class="dim">·</span>
<span><b>${exams.length}</b> total</span>
<span class="grow"></span>
<span class="dim">click a row for the full description</span>
</div>
<section class="panel">
<div class="panel-title"><span>homework &amp; tests</span><span class="title-note">sorted by date</span></div>
${
exams.length
? `<div class="table-wrap"><table class="data-table">
<thead>
<tr><th>date</th><th>subject</th><th>type</th><th>time</th><th>teacher</th><th>description</th></tr>
</thead>
<tbody>${rows}</tbody>
</table></div>`
: `<div class="empty">no homework or tests</div>`
}
</section>
</div>
`;
}
function openExamDetail(index) {
const exam = state.exams[index];
if (!exam) return;
const body = `
<dl class="detail-grid">
<dt>subject</dt><dd>${esc(exam.Lesson || "—")}</dd>
<dt>type</dt><dd>${esc(exam.Type || "—")}</dd>
<dt>date</dt><dd>${esc(fmtDate(exam.Date))} · ${esc(weekdayShort(exam.Date))}</dd>
<dt>time</dt><dd>${esc(timeRange(exam.StartTime, exam.EndTime))}</dd>
<dt>teacher</dt><dd>${esc(personName(exam.AddedBy) || "—")}</dd>
<dt>added</dt><dd>${esc(fmtDate(exam.AddDate))}</dd>
</dl>
<div class="help-section">description</div>
<p style="white-space:pre-wrap">${esc(exam.Content || "—")}</p>
`;
openModal(`homework · ${exam.Lesson || "—"}`, body, [{ label: "[ close ]", close: true }]);
}
/* ---------------------------------------------------------------- attendance */
async function renderAttendance(token) {
const data = await api("/api/attendances");
if (token !== state.loadToken) return;
const items = (data.attendances || []).slice().sort((a, b) => {
const left = parseDate(a.Date);
const right = parseDate(b.Date);
return (right ? right.getTime() : 0) - (left ? left.getTime() : 0);
});
const total = items.length;
const absent = items.filter((item) => !item.isPresence).length;
const late = items.filter((item) => /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`)).length;
const excused = items.filter((item) => /zwoln|zw\./i.test(`${item.Type} ${item.Short}`)).length;
const rows = items.length
? items
.map((item) => {
const isLate = /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`);
const tagClass = !item.isPresence ? "tag-red" : isLate ? "tag-amber" : "tag-green";
return `
<tr>
<td class="num">${esc(fmtDate(item.Date))}</td>
<td class="strong">${esc(item.Lesson || "—")}</td>
<td><span class="tag ${tagClass}">${esc(item.Short || "?")} ${esc(item.Type || "")}</span></td>
<td class="soft">${esc(personName(item.AddedBy) || "—")}</td>
<td class="num presence ${item.isPresence ? "yes" : "no"}">${item.isPresence ? "present" : "absent"}</td>
</tr>`;
})
.join("")
: "";
$("#view").innerHTML = `
<div class="view-inner">
<div class="att-summary">
<div class="att-tile"><div class="value">${total}</div><div class="label">entries</div></div>
<div class="att-tile"><div class="value">${Math.max(0, total - absent)}</div><div class="label">present</div></div>
<div class="att-tile absent"><div class="value">${absent}</div><div class="label">absences</div></div>
<div class="att-tile late"><div class="value">${late}</div><div class="label">late · ${excused} excused</div></div>
</div>
<section class="panel">
<div class="panel-title"><span>attendance register</span><span class="title-note">${total} entries</span></div>
${
items.length
? `<div class="table-wrap"><table class="data-table">
<thead><tr><th>date</th><th>subject</th><th>type</th><th>teacher</th><th>status</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`
: `<div class="empty">no attendance entries</div>`
}
</section>
</div>
`;
}
/* ------------------------------------------------------------------ messages */
async function renderMessages(token) {
const data = await api("/api/messages");
if (token !== state.loadToken) return;
state.messages = data.messages || [];
state.messageCache.clear();
state.messageIndex = state.messages.length ? 0 : -1;
$("#view").innerHTML = `
<div class="view-inner">
<div class="messages-layout">
<section class="panel">
<div class="panel-title">
<span>inbox</span>
<span class="title-note">${state.messages.length} messages</span>
</div>
<div class="message-list" id="message-list"></div>
</section>
<div class="panel" id="message-reader"></div>
</div>
</div>
`;
renderMessageList();
if (state.messageIndex >= 0) {
loadMessage(state.messageIndex);
} else {
$("#message-reader").innerHTML = `
<div class="panel-title"><span>reader</span></div>
<div class="empty">inbox empty</div>`;
}
}
function renderMessageList() {
const list = $("#message-list");
if (!list) return;
if (!state.messages.length) {
list.innerHTML = `<div class="empty">no messages</div>`;
return;
}
list.innerHTML = state.messages
.map(
(message, index) => `
<button class="message-item ${index === state.messageIndex ? "active" : ""}" type="button" data-message="${index}">
<div class="msg-top">
<span class="msg-from">${esc(message.from || "unknown")}</span>
<span class="msg-date">${esc(message.date || "")}</span>
</div>
<div class="msg-subject">${esc(message.subject || "(no subject)")}</div>
</button>`
)
.join("");
}
function highlightMessageList() {
$$("#message-list .message-item").forEach((element, index) => {
element.classList.toggle("active", index === state.messageIndex);
});
const active = $("#message-list .message-item.active");
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectMessage(index) {
if (!state.messages.length) return;
const next = Math.max(0, Math.min(state.messages.length - 1, index));
if (next === state.messageIndex) return;
state.messageIndex = next;
highlightMessageList();
loadMessage(next);
}
function moveMessage(delta) {
if (!state.messages.length) return;
selectMessage(state.messageIndex + delta);
}
async function loadMessage(index, force = false) {
const message = state.messages[index];
const reader = $("#message-reader");
if (!message || !reader) return;
const token = ++state.messageToken;
const link = message.link;
if (!force && state.messageCache.has(link)) {
renderMessageDetail(state.messageCache.get(link), index);
return;
}
reader.innerHTML = `
<div class="panel-title"><span>reader</span><span class="title-note">loading…</span></div>
<div class="loading">[..] opening message…</div>`;
try {
const detail = await api(`/api/message?link=${encodeURIComponent(link)}`);
if (token !== state.messageToken) return;
state.messageCache.set(link, detail);
renderMessageDetail(detail, index);
setStatus(`[ok] message ${index + 1}/${state.messages.length}`, "ok");
} catch (error) {
if (token !== state.messageToken) return;
reader.innerHTML = `
<div class="panel-title"><span>reader</span><span class="title-note">error</span></div>
<div class="error-message">${esc(error.message)}</div>`;
toast(error.message, "err");
}
}
function renderMessageDetail(detail, index) {
const reader = $("#message-reader");
if (!reader) return;
const attachments = (detail.attachments || [])
.map((attachment) => {
const link = attachment.nice || attachment.source || "";
return `
<a class="attachment" href="/api/download?link=${encodeURIComponent(link)}" download>
<span>[&darr;]</span>
<span>${esc(attachment.name || link || "attachment")}</span>
<span class="size-hint">download</span>
</a>`;
})
.join("");
const readState = detail.read
? `<span class="tag tag-dim">read: ${esc(detail.read)}</span>`
: "";
reader.innerHTML = `
<div class="panel-title">
<span>reader</span>
<span class="title-note">${index + 1} / ${state.messages.length}</span>
</div>
<div class="reader-head">
<h2 class="reader-subject">${esc(detail.subject || "(no subject)")}</h2>
<div class="reader-meta">
<span>from <b>${esc(detail.from || "unknown")}</b></span>
<span>${esc(detail.date || "")}</span>
${readState}
</div>
</div>
<div class="message-body">${detail.content || `<div class="empty">message body is empty</div>`}</div>
${
attachments
? `<div class="attachments"><div class="attachments-title">attachments</div>${attachments}</div>`
: ""
}
`;
reader.scrollTop = 0;
}
/* ----------------------------------------------------------------- free days */
async function renderFreedays(token) {
const [free, conferences] = await Promise.all([
api("/api/freedays"),
api("/api/conferences").catch(() => null),
]);
if (token !== state.loadToken) return;
const school = (free.school || [])
.slice()
.sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0));
const teachers = (free.teachers || [])
.slice()
.sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0));
const conf = (conferences && conferences.conferences) || [];
const schoolRows = school.length
? school
.map((item) => {
const distance = daysUntil(item.DateFrom);
const past = distance !== null && distance < 0;
return `
<div class="free-item" ${past ? 'style="opacity:.55"' : ""}>
<div>
<div class="free-name">${esc(item.Name || "free day")}</div>
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}</div>
</div>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
</div>`;
})
.join("")
: `<div class="empty">no free days</div>`;
const teacherRows = teachers.length
? teachers
.map((item) => {
const teacher = personName(item.Teacher) || "teacher";
const times = item.TimeFrom
? ` · ${esc(shortTime(item.TimeFrom))}–${esc(shortTime(item.TimeTo))}`
: "";
return `
<div class="free-item">
<div>
<div class="free-name">${esc(teacher)}</div>
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}${times}</div>
</div>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
</div>`;
})
.join("")
: `<div class="empty">no teacher absences</div>`;
const confRows = conf.length
? conf
.map(
(item) => `
<div class="conf-item">
<div class="conf-name">${esc(item.Name || "conference")}</div>
<div class="conf-meta">${esc(item.Topic || "")}</div>
<div class="conf-meta">${esc(fmtDate(item.Date))} · ${esc(shortTime(item.Time))}${item.Room ? ` · room ${esc(item.Room)}` : ""}</div>
</div>`
)
.join("")
: `<div class="empty">no scheduled conferences</div>`;
$("#view").innerHTML = `
<div class="view-inner">
<div class="split-2">
<section class="panel">
<div class="panel-title"><span>school free days</span><span class="title-note">${school.length}</span></div>
<div class="panel-body tight">${schoolRows}</div>
</section>
<section class="panel">
<div class="panel-title"><span>teacher free days</span><span class="title-note">${teachers.length}</span></div>
<div class="panel-body tight">${teacherRows}</div>
</section>
<section class="panel span-2">
<div class="panel-title"><span>parent-teacher conferences</span><span class="title-note">${conf.length}</span></div>
<div class="panel-body tight">${confRows}</div>
</section>
</div>
</div>
`;
}
+493
View File
@@ -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);