Add AMOLED theme, fullscreen, settings tab, and NTFY notifications
This commit is contained in:
1 parent
fe13a4d989
commit
bbf92bb478
6 files changed
+429
-4
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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">></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">></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">></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">></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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user