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
@@ -17,8 +17,9 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from bs4 import BeautifulSoup, Comment
|
from bs4 import BeautifulSoup, Comment
|
||||||
|
|
||||||
@@ -421,6 +422,56 @@ async def api_conferences(request):
|
|||||||
return json_response({"conferences": data or []})
|
return json_response({"conferences": data or []})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_ntfy(request):
|
||||||
|
"""Proxy a notification to an NTFY server (avoids browser CORS/CSP limits)."""
|
||||||
|
get_session(request) # require an authenticated session
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise ApiError(400, "Expected a JSON object.")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise ApiError(400, "Expected a JSON object.")
|
||||||
|
|
||||||
|
server = str(body.get("server") or "").strip()
|
||||||
|
topic = str(body.get("topic") or "").strip()
|
||||||
|
token = str(body.get("token") or "").strip()
|
||||||
|
title = str(body.get("title") or "librus-tui").strip()[:120]
|
||||||
|
message = str(body.get("message") or "").strip()[:4000]
|
||||||
|
priority = body.get("priority")
|
||||||
|
|
||||||
|
if not server or not topic:
|
||||||
|
raise ApiError(400, "NTFY server and topic are required.")
|
||||||
|
parsed = urlparse(server)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise ApiError(400, "Invalid NTFY server URL.")
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
if host in ("localhost", "127.0.0.1", "::1") or host.startswith("169.254."):
|
||||||
|
raise ApiError(400, "NTFY server host is not allowed.")
|
||||||
|
|
||||||
|
url = server.rstrip("/") + "/" + quote(topic, safe="")
|
||||||
|
headers = {
|
||||||
|
"Title": title or "librus-tui",
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
if priority is not None:
|
||||||
|
headers["Priority"] = str(priority)
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as client:
|
||||||
|
async with client.post(url, headers=headers, data=message.encode("utf-8")) as response:
|
||||||
|
text = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
raise ApiError(502, f"NTFY returned {response.status}: {text[:200].strip()}")
|
||||||
|
return json_response({"ok": True, "status": response.status})
|
||||||
|
except ApiError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ApiError(502, f"NTFY request failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- static
|
# --------------------------------------------------------------------------- static
|
||||||
|
|
||||||
|
|
||||||
@@ -557,6 +608,7 @@ def create_app(demo=False):
|
|||||||
app.router.add_get("/api/download", api_download)
|
app.router.add_get("/api/download", api_download)
|
||||||
app.router.add_get("/api/freedays", api_freedays)
|
app.router.add_get("/api/freedays", api_freedays)
|
||||||
app.router.add_get("/api/conferences", api_conferences)
|
app.router.add_get("/api/conferences", api_conferences)
|
||||||
|
app.router.add_post("/api/ntfy", api_ntfy)
|
||||||
app.router.add_get("/healthz", healthz)
|
app.router.add_get("/healthz", healthz)
|
||||||
|
|
||||||
app.router.add_get("/", index)
|
app.router.add_get("/", index)
|
||||||
|
|||||||
@@ -105,6 +105,9 @@
|
|||||||
<button class="nav-item" data-view="freedays" type="button">
|
<button class="nav-item" data-view="freedays" type="button">
|
||||||
<span class="nav-key">7</span><span class="nav-label">free days</span>
|
<span class="nav-key">7</span><span class="nav-label">free days</span>
|
||||||
</button>
|
</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>
|
<div class="side-sep"></div>
|
||||||
<button class="nav-item nav-muted" data-action="refresh" type="button">
|
<button class="nav-item nav-muted" data-action="refresh" type="button">
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
||||||
|
|
||||||
const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays"];
|
const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays", "settings"];const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||||
const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
||||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
const VIEW_TITLES = {
|
const VIEW_TITLES = {
|
||||||
@@ -19,6 +18,7 @@ const VIEW_TITLES = {
|
|||||||
attendance: "attendance",
|
attendance: "attendance",
|
||||||
messages: "messages",
|
messages: "messages",
|
||||||
freedays: "free days",
|
freedays: "free days",
|
||||||
|
settings: "settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEW_ALIASES = {
|
const VIEW_ALIASES = {
|
||||||
@@ -47,6 +47,9 @@ const VIEW_ALIASES = {
|
|||||||
free: "freedays",
|
free: "freedays",
|
||||||
freedays: "freedays",
|
freedays: "freedays",
|
||||||
dni: "freedays",
|
dni: "freedays",
|
||||||
|
s: "settings",
|
||||||
|
settings: "settings",
|
||||||
|
config: "settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
@@ -56,6 +59,9 @@ const state = {
|
|||||||
loadToken: 0,
|
loadToken: 0,
|
||||||
messageToken: 0,
|
messageToken: 0,
|
||||||
loginBusy: false,
|
loginBusy: false,
|
||||||
|
notificationPoller: null,
|
||||||
|
lastNotifySnapshot: null,
|
||||||
|
settingsLog: [],
|
||||||
timetableWeek: 0,
|
timetableWeek: 0,
|
||||||
gradeFilter: "",
|
gradeFilter: "",
|
||||||
gradeGroups: [],
|
gradeGroups: [],
|
||||||
@@ -424,6 +430,7 @@ function openHelpModal() {
|
|||||||
<dt>:status</dt><dd>print session information</dd>
|
<dt>:status</dt><dd>print session information</dd>
|
||||||
<dt>:clear</dt><dd>clear the screen</dd>
|
<dt>:clear</dt><dd>clear the screen</dd>
|
||||||
<dt>:fullscreen / :fs</dt><dd>toggle browser fullscreen</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>
|
<dt>:logout</dt><dd>end the Synergia session</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="help-section">about</div>
|
<div class="help-section">about</div>
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ function enterApp(login, demo) {
|
|||||||
|
|
||||||
toast(demo ? "Demo session started." : "Signed in to Synergia.", "ok");
|
toast(demo ? "Demo session started." : "Signed in to Synergia.", "ok");
|
||||||
|
|
||||||
|
startNotificationPolling();
|
||||||
|
|
||||||
const requested = location.hash.replace(/^#/, "");
|
const requested = location.hash.replace(/^#/, "");
|
||||||
const target = VIEWS.includes(requested) ? requested : "dashboard";
|
const target = VIEWS.includes(requested) ? requested : "dashboard";
|
||||||
navigate(target);
|
navigate(target);
|
||||||
@@ -255,10 +257,12 @@ async function doLogout() {
|
|||||||
/* the local session is dropped regardless */
|
/* the local session is dropped regardless */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stopNotificationPolling();
|
||||||
state.session = null;
|
state.session = null;
|
||||||
state.messages = [];
|
state.messages = [];
|
||||||
state.messageIndex = -1;
|
state.messageIndex = -1;
|
||||||
state.messageCache.clear();
|
state.messageCache.clear();
|
||||||
|
state.lastNotifySnapshot = null;
|
||||||
showLogin("session closed.");
|
showLogin("session closed.");
|
||||||
toast("Logged out.", "ok");
|
toast("Logged out.", "ok");
|
||||||
}
|
}
|
||||||
@@ -383,3 +383,322 @@ async function renderFreedays(token) {
|
|||||||
</div>
|
</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":
|
case "freedays":
|
||||||
await renderFreedays(token);
|
await renderFreedays(token);
|
||||||
break;
|
break;
|
||||||
|
case "settings":
|
||||||
|
await renderSettings(token);
|
||||||
|
break;
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
default:
|
default:
|
||||||
await renderDashboard(token);
|
await renderDashboard(token);
|
||||||
@@ -212,7 +215,7 @@ function runCommand(raw) {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------- actions */
|
/* ------------------------------------------------------------------- actions */
|
||||||
|
|
||||||
function handleAction(action) {
|
async function handleAction(action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "refresh":
|
case "refresh":
|
||||||
refreshCurrent();
|
refreshCurrent();
|
||||||
@@ -245,6 +248,43 @@ function handleAction(action) {
|
|||||||
case "retry":
|
case "retry":
|
||||||
loadView(state.view);
|
loadView(state.view);
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user