Crank dat!
This commit is contained in:
17 files changed
+6624
No files matched your search
@@ -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 & 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>[↓]</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>
|
||||
`;
|
||||
}
|
||||
Reference in new issue
Block a user