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

+3
View File
@@ -105,6 +105,9 @@
<button class="nav-item" data-view="freedays" type="button">
<span class="nav-key">7</span><span class="nav-label">free days</span>
</button>
<button class="nav-item" data-view="settings" type="button">
<span class="nav-key">8</span><span class="nav-label">settings</span>
</button>
<div class="side-sep"></div>
<button class="nav-item nav-muted" data-action="refresh" type="button">
+9 -2
View File
@@ -7,8 +7,7 @@
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 VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays", "settings"];const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const VIEW_TITLES = {
@@ -19,6 +18,7 @@ const VIEW_TITLES = {
attendance: "attendance",
messages: "messages",
freedays: "free days",
settings: "settings",
};
const VIEW_ALIASES = {
@@ -47,6 +47,9 @@ const VIEW_ALIASES = {
free: "freedays",
freedays: "freedays",
dni: "freedays",
s: "settings",
settings: "settings",
config: "settings",
};
const state = {
@@ -56,6 +59,9 @@ const state = {
loadToken: 0,
messageToken: 0,
loginBusy: false,
notificationPoller: null,
lastNotifySnapshot: null,
settingsLog: [],
timetableWeek: 0,
gradeFilter: "",
gradeGroups: [],
@@ -424,6 +430,7 @@ function openHelpModal() {
<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>:settings</dt><dd>open notification settings</dd>
<dt>:logout</dt><dd>end the Synergia session</dd>
</dl>
<div class="help-section">about</div>
+4
View File
@@ -141,6 +141,8 @@ function enterApp(login, demo) {
toast(demo ? "Demo session started." : "Signed in to Synergia.", "ok");
startNotificationPolling();
const requested = location.hash.replace(/^#/, "");
const target = VIEWS.includes(requested) ? requested : "dashboard";
navigate(target);
@@ -255,10 +257,12 @@ async function doLogout() {
/* the local session is dropped regardless */
}
}
stopNotificationPolling();
state.session = null;
state.messages = [];
state.messageIndex = -1;
state.messageCache.clear();
state.lastNotifySnapshot = null;
showLogin("session closed.");
toast("Logged out.", "ok");
}
+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 */
}
}
+41 -1
View File
@@ -55,6 +55,9 @@ async function loadView(view) {
case "freedays":
await renderFreedays(token);
break;
case "settings":
await renderSettings(token);
break;
case "dashboard":
default:
await renderDashboard(token);
@@ -212,7 +215,7 @@ function runCommand(raw) {
/* ------------------------------------------------------------------- actions */
function handleAction(action) {
async function handleAction(action) {
switch (action) {
case "refresh":
refreshCurrent();
@@ -245,6 +248,43 @@ function handleAction(action) {
case "retry":
loadView(state.view);
break;
case "settings-save": {
const next = collectSettingsFromForm();
saveSettings(next);
const status = $("#settings-status");
if (status) {
status.textContent = next.enabled && next.topic
? `saved · topic ${next.topic} · poll every ${next.pollMinutes} min`
: "saved · disabled (no topic)";
}
appendSettingsLog(next.enabled ? "settings saved · ntfy enabled" : "settings saved · ntfy disabled");
toast(next.enabled ? "Settings saved. NTFY enabled." : "Settings saved.", "ok", 1800);
startNotificationPolling();
break;
}
case "settings-test": {
const current = collectSettingsFromForm();
const status = $("#settings-status");
try {
await sendNtfy(current, "librus-tui test", "Test notification from librus-tui settings.");
if (status) status.textContent = "test push sent";
appendSettingsLog("test push sent");
toast("Test notification sent.", "ok", 1800);
} catch (error) {
if (status) status.textContent = `test failed: ${error.message}`;
appendSettingsLog(`test push failed: ${error.message}`);
toast(`[ntfy fail] ${error.message}`, "warn");
}
break;
}
case "settings-clear": {
const defaults = defaultSettings();
saveSettings(defaults);
renderSettings();
startNotificationPolling();
toast("Settings reset to defaults.", "ok", 1800);
break;
}
default:
break;
}