Add AMOLED theme, fullscreen, settings tab, and NTFY notifications

This commit is contained in:
purplebored committed 2026-11-08 19:38:17 +01:00
1 parent fe13a4d989
commit bbf92bb478
6 files changed
+429 -4

No files matched your search

+319
View File
@@ -383,3 +383,322 @@ async function renderFreedays(token) {
</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 */
}
}