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

+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>
`;
}