Files
librustui/static/js/26-more-views.js

705 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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>
`;
}
/* ==========================================================================
settings + ntfy
========================================================================== */
const SETTINGS_KEY = "librustui.settings.v1";
const SETTINGS_LOG_KEY = "librustui.settings.log.v1";
function defaultSettings() {
return {
enabled: false,
server: "https://ntfy.sh",
topic: "",
token: "",
grades: true,
messages: true,
exams: true,
attendance: false,
pollMinutes: 5,
};
}
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return defaultSettings();
const parsed = JSON.parse(raw);
return Object.assign(defaultSettings(), parsed || {});
} catch (error) {
return defaultSettings();
}
}
function saveSettings(settings) {
const next = Object.assign(defaultSettings(), settings || {});
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
} catch (error) {
/* ignore storage errors */
}
return next;
}
function loadSettingsLog() {
try {
const raw = localStorage.getItem(SETTINGS_LOG_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
function appendSettingsLog(message) {
const log = loadSettingsLog();
log.unshift({ at: new Date().toISOString(), message: String(message || "") });
if (log.length > 40) log.length = 40;
try {
localStorage.setItem(SETTINGS_LOG_KEY, JSON.stringify(log));
} catch (error) {
/* ignore */
}
const container = $("#settings-log");
if (container) renderSettingsLog(container);
}
function renderSettingsLog(container) {
const log = loadSettingsLog();
if (!log.length) {
container.innerHTML = `<div class="empty">no activity yet</div>`;
return;
}
container.innerHTML = log
.map((entry) => {
const when = fmtDateShort ? fmtDateShort(entry.at) : entry.at;
return `<div class="freeday-line">
<span class="name">${esc(entry.message)}</span>
<span class="countdown">${esc(when)}</span>
</div>`;
})
.join("");
}
function collectSettingsFromForm() {
const get = (id) => $(`#${id}`);
const checkbox = (id) => !!(get(id) && get(id).checked);
const value = (id) => (get(id)?.value || "").trim();
return {
enabled: checkbox("set-enabled"),
server: value("set-server") || "https://ntfy.sh",
topic: value("set-topic"),
token: value("set-token"),
grades: checkbox("set-grades"),
messages: checkbox("set-messages"),
exams: checkbox("set-exams"),
attendance: checkbox("set-attendance"),
pollMinutes: Math.max(1, Number(value("set-poll")) || 5),
};
}
function fillSettingsForm(settings) {
const set = (id, value) => {
const element = $(`#${id}`);
if (element) element.value = value;
};
const check = (id, value) => {
const element = $(`#${id}`);
if (element) element.checked = !!value;
};
const s = settings || defaultSettings();
check("set-enabled", s.enabled);
set("set-server", s.server);
set("set-topic", s.topic);
set("set-token", s.token);
check("set-grades", s.grades);
check("set-messages", s.messages);
check("set-exams", s.exams);
check("set-attendance", s.attendance);
set("set-poll", s.pollMinutes);
}
async function sendNtfy(settings, title, message, priority) {
const cfg = settings || loadSettings();
if (!cfg.enabled) throw new Error("NTFY is disabled in settings.");
if (!cfg.topic) throw new Error("NTFY topic is empty.");
const response = await api("/api/ntfy", {
method: "POST",
body: {
server: cfg.server,
topic: cfg.topic,
token: cfg.token,
title: title || "librus-tui",
message: message || "",
priority: priority || null,
},
});
if (!response || !response.ok) throw new Error("NTFY push failed.");
return true;
}
async function renderSettings() {
const settings = loadSettings();
$("#view").innerHTML = `
<div class="view-inner">
<div class="split-2">
<section class="panel">
<div class="panel-title"><span>ntfy notifications</span><span class="title-note">push alerts</span></div>
<div class="panel-body">
<label class="field">
<span class="field-label">enabled</span>
<span class="field-input-wrap">
<input id="set-enabled" type="checkbox">
<span class="dim">send pushes when new items appear</span>
</span>
</label>
<label class="field">
<span class="field-label">server</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="set-server" type="url" placeholder="https://ntfy.sh" spellcheck="false" autocapitalize="off">
</span>
</label>
<label class="field">
<span class="field-label">topic</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="set-topic" type="text" placeholder="librus-yourname" spellcheck="false" autocapitalize="off">
</span>
</label>
<label class="field">
<span class="field-label">access token (optional)</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="set-token" type="password" placeholder="tk_..." autocomplete="off">
</span>
</label>
<label class="field">
<span class="field-label">poll interval (minutes)</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="set-poll" type="number" min="1" max="60" step="1">
</span>
</label>
<div class="toolbar">
<button class="btn btn-primary" type="button" data-action="settings-save">[ save ]</button>
<button class="btn" type="button" data-action="settings-test">[ test push ]</button>
<button class="btn btn-warn" type="button" data-action="settings-clear">[ reset ]</button>
</div>
<div id="settings-status" class="dim" style="margin-top:8px;"></div>
</div>
</section>
<section class="panel">
<div class="panel-title"><span>notification categories</span><span class="title-note">what to push</span></div>
<div class="panel-body">
<label class="field"><span class="field-label">new grades</span><span class="field-input-wrap"><input id="set-grades" type="checkbox"><span class="dim">grade added or changed</span></span></label>
<label class="field"><span class="field-label">new messages</span><span class="field-input-wrap"><input id="set-messages" type="checkbox"><span class="dim">inbox count increases</span></span></label>
<label class="field"><span class="field-label">exams / homework</span><span class="field-input-wrap"><input id="set-exams" type="checkbox"><span class="dim">new tests or assignments</span></span></label>
<label class="field"><span class="field-label">absences</span><span class="field-input-wrap"><input id="set-attendance" type="checkbox"><span class="dim">new absence entries</span></span></label>
<p class="dim" style="margin-top:10px; font-size:10.5px; line-height:1.5;">
Polling runs while this tab is open. The first poll only records the
baseline and does not send pushes.
</p>
</div>
</section>
<section class="panel span-2">
<div class="panel-title"><span>activity log</span><span class="title-note">recent pushes</span></div>
<div class="panel-body tight" id="settings-log"></div>
</section>
</div>
</div>
`;
fillSettingsForm(settings);
renderSettingsLog($("#settings-log"));
const status = $("#settings-status");
if (status) {
status.textContent = settings.enabled && settings.topic
? `enabled · topic ${settings.topic} · poll every ${settings.pollMinutes} min`
: "disabled · configure a topic to enable pushes";
}
}
function stopNotificationPolling() {
if (state.notificationPoller) {
clearInterval(state.notificationPoller);
state.notificationPoller = null;
}
}
function startNotificationPolling() {
stopNotificationPolling();
const settings = loadSettings();
state.lastNotifySnapshot = null;
if (!settings.enabled) return;
const minutes = Math.max(1, Number(settings.pollMinutes) || 5);
pollNotifications();
state.notificationPoller = setInterval(pollNotifications, minutes * 60 * 1000);
}
async function pollNotifications() {
if (!state.session || !state.session.authenticated) return;
const settings = loadSettings();
if (!settings.enabled) return;
try {
const notif = await api("/api/notifications");
let messageCount = 0;
if (settings.messages) {
try {
const messages = await api("/api/messages");
messageCount = Array.isArray(messages)
? messages.length
: Array.isArray(messages?.items)
? messages.items.length
: 0;
} catch (error) {
messageCount = state.lastNotifySnapshot?.messages || 0;
}
} else if (state.lastNotifySnapshot) {
messageCount = state.lastNotifySnapshot.messages;
}
const snapshot = {
grades: Number(notif?.grades) || 0,
exams: Number(notif?.exams) || 0,
absences: Number(notif?.absences) || 0,
conferences: Number(notif?.conferences) || 0,
messages: messageCount,
};
if (state.lastNotifySnapshot) {
const previous = state.lastNotifySnapshot;
const pushes = [];
if (settings.grades && snapshot.grades > previous.grades) {
pushes.push({
title: "New grades",
message: `${snapshot.grades - previous.grades} new grade(s). Total: ${snapshot.grades}.`,
priority: 3,
});
}
if (settings.exams && snapshot.exams > previous.exams) {
pushes.push({
title: "New exams / homework",
message: `${snapshot.exams - previous.exams} new item(s). Total: ${snapshot.exams}.`,
priority: 4,
});
}
if (settings.messages && snapshot.messages > previous.messages) {
pushes.push({
title: "New messages",
message: `${snapshot.messages - previous.messages} new message(s). Total: ${snapshot.messages}.`,
priority: 3,
});
}
if (settings.attendance && snapshot.absences > previous.absences) {
pushes.push({
title: "New absence",
message: `${snapshot.absences - previous.absences} new absence entry. Total: ${snapshot.absences}.`,
priority: 4,
});
}
for (const push of pushes) {
toast(`[ntfy] ${push.title}`, "ok", 2200);
try {
await sendNtfy(settings, push.title, push.message, push.priority);
appendSettingsLog(`pushed: ${push.title}`);
} catch (error) {
appendSettingsLog(`push failed: ${push.title} — ${error.message}`);
toast(`[ntfy fail] ${error.message}`, "warn");
}
}
}
state.lastNotifySnapshot = snapshot;
} catch (error) {
/* polling failures are non-fatal */
}
}