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

+53 -1
View File
@@ -17,8 +17,9 @@ import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse
from urllib.parse import quote, urlparse
import aiohttp
from aiohttp import web
from bs4 import BeautifulSoup, Comment
@@ -421,6 +422,56 @@ async def api_conferences(request):
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
@@ -557,6 +608,7 @@ def create_app(demo=False):
app.router.add_get("/api/download", api_download)
app.router.add_get("/api/freedays", api_freedays)
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("/", index)
+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;
}