Add admin settings and subscriber management

This commit is contained in:
2026-07-23 09:23:16 +02:00
parent 5365f79fdc
commit e54ec0d69f
6 changed files with 231 additions and 8 deletions
+53 -2
View File
@@ -38,6 +38,17 @@ document.querySelector("#send-test").addEventListener("click", async () => {
await loadState();
});
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const sendTime = document.querySelector("#send-time").value;
await action("Einstellungen werden gespeichert...", () => api("/api/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sendTime })
}));
await loadState();
});
if (token) {
showApp();
loadState().catch(() => {
@@ -91,6 +102,8 @@ async function loadState() {
document.querySelector("#holiday-count").textContent = String(state.holidayCount);
document.querySelector("#active-count").textContent = String(state.activeSubscriberCount);
document.querySelector("#subscriber-count").textContent = `${state.subscriberCount} gesamt`;
document.querySelector("#send-time").value = state.settings.sendTime;
document.querySelector("#settings-timezone").textContent = state.settings.timezone;
document.querySelector("#today-label").textContent = new Intl.DateTimeFormat("de-DE", {
weekday: "long",
day: "2-digit",
@@ -137,12 +150,50 @@ function renderSubscribers(items) {
}
for (const item of items) {
const li = document.createElement("li");
li.className = "subscriber-row";
const main = document.createElement("div");
main.className = "subscriber-main";
const name = document.createElement("span");
name.className = "subscriber-name";
name.textContent = item.username ? `@${item.username}` : [item.firstName, item.lastName].filter(Boolean).join(" ") || item.chatId;
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = item.active ? "aktiv" : "inaktiv";
li.append(name, meta);
meta.textContent = `${item.active ? "aktiv" : "inaktiv"} · ${item.chatId}`;
main.append(name, meta);
const actions = document.createElement("div");
actions.className = "row-actions";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "secondary";
toggle.textContent = item.active ? "Deaktivieren" : "Aktivieren";
toggle.addEventListener("click", async () => {
await action("Nutzer wird aktualisiert...", () => api(`/api/subscribers/${encodeURIComponent(item.chatId)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ active: !item.active })
}));
await loadState();
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "danger";
remove.textContent = "Entfernen";
remove.addEventListener("click", async () => {
if (!confirm("Diesen Nutzer dauerhaft entfernen?")) return;
await action("Nutzer wird entfernt...", () => api(`/api/subscribers/${encodeURIComponent(item.chatId)}`, {
method: "DELETE"
}));
await loadState();
});
actions.append(toggle, remove);
li.append(main, actions);
list.append(li);
}
}
+15 -1
View File
@@ -50,6 +50,20 @@
</article>
</section>
<section class="panel settings-panel">
<div class="panel-head">
<h2>Einstellungen</h2>
<span id="settings-timezone">Europe/Berlin</span>
</div>
<form id="settings-form" class="settings-form">
<label>
Versandzeit
<input id="send-time" type="time" required>
</label>
<button type="submit">Speichern</button>
</form>
</section>
<section class="content-grid">
<article class="panel">
<div class="panel-head">
@@ -61,7 +75,7 @@
<article class="panel">
<div class="panel-head">
<h2>Abonnenten</h2>
<h2>Nutzerverwaltung</h2>
<span id="subscriber-count">0 gesamt</span>
</div>
<ul id="subscribers" class="item-list compact"></ul>
+61
View File
@@ -42,6 +42,15 @@ button:disabled {
cursor: wait;
}
button.secondary {
background: #e8e2d8;
color: var(--ink);
}
button.danger {
background: var(--danger);
}
.shell {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
@@ -178,6 +187,17 @@ input {
font-size: 18px;
}
.settings-form {
display: flex;
align-items: end;
gap: 12px;
max-width: 360px;
}
.settings-form label {
flex: 1;
}
.item-list {
list-style: none;
margin: 0;
@@ -209,6 +229,33 @@ input {
gap: 12px;
}
.subscriber-row {
align-items: center;
}
.subscriber-main {
display: grid;
gap: 2px;
min-width: 0;
}
.subscriber-name {
font-weight: 750;
overflow-wrap: anywhere;
}
.row-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.row-actions button {
min-height: 32px;
padding: 0 10px;
}
@media (max-width: 760px) {
.topbar {
align-items: stretch;
@@ -223,4 +270,18 @@ input {
.actions button {
flex: 1;
}
.settings-form,
.compact li {
align-items: stretch;
flex-direction: column;
}
.row-actions {
justify-content: stretch;
}
.row-actions button {
flex: 1;
}
}
+7
View File
@@ -9,6 +9,13 @@ export class DailyScheduler {
this.timer = null;
}
setTime({ hour, minute, timezone }) {
this.hour = hour;
this.minute = minute;
this.timezone = timezone ?? this.timezone;
this.lastKey = null;
}
start() {
this.timer = setInterval(() => this.tick(), 30_000);
this.tick();
+34
View File
@@ -5,6 +5,10 @@ import path from "node:path";
const initialState = {
subscribers: [],
holidays: [],
settings: {
sendTime: "09:00",
timezone: "Europe/Berlin"
},
imports: {
lastRunAt: null,
source: "https://www.kuriose-feiertage.de/kalender/",
@@ -34,6 +38,7 @@ export class Store {
...structuredClone(initialState),
...parsed,
imports: { ...initialState.imports, ...(parsed.imports ?? {}) },
settings: { ...initialState.settings, ...(parsed.settings ?? {}) },
subscribers: parsed.subscribers ?? [],
holidays: parsed.holidays ?? [],
deliveries: parsed.deliveries ?? []
@@ -112,6 +117,35 @@ export class Store {
return subscriber;
}
async setSubscriberActive(chatId, active) {
const subscriber = this.getSubscriber(chatId);
if (subscriber) {
subscriber.active = Boolean(active);
subscriber.updatedAt = new Date().toISOString();
await this.save();
}
return subscriber;
}
async deleteSubscriber(chatId) {
const before = this.state.subscribers.length;
this.state.subscribers = this.state.subscribers.filter((subscriber) => subscriber.chatId !== String(chatId));
if (this.state.subscribers.length !== before) {
await this.save();
return true;
}
return false;
}
async updateSettings(settings) {
this.state.settings = {
...this.state.settings,
...settings
};
await this.save();
return this.state.settings;
}
activeSubscribers() {
return this.state.subscribers.filter((subscriber) => subscriber.active);
}
+61 -5
View File
@@ -26,6 +26,7 @@ if (!config.adminPassword) {
const store = new Store(config.dataDir);
await store.load();
await store.updateSettings({ timezone: config.timezone });
const bot = new TelegramBot({
token: config.telegramToken,
@@ -85,13 +86,13 @@ if (store.snapshot().holidays.length === 0) {
bot.start({ getTodayMessage: todayMessage });
new DailyScheduler({
hour: 9,
minute: 0,
timezone: config.timezone,
const scheduler = new DailyScheduler({
...parseSendTime(store.snapshot().settings.sendTime),
timezone: store.snapshot().settings.timezone,
task: () => sendDaily("scheduled"),
logger: console
}).start();
});
scheduler.start();
const server = http.createServer(async (request, response) => {
try {
@@ -134,6 +135,7 @@ async function route(request, response) {
const snapshot = store.snapshot();
sendJson(response, 200, {
imports: snapshot.imports,
settings: snapshot.settings,
subscriberCount: snapshot.subscribers.length,
activeSubscriberCount: snapshot.subscribers.filter((subscriber) => subscriber.active).length,
holidayCount: snapshot.holidays.length,
@@ -144,6 +146,48 @@ async function route(request, response) {
return;
}
if (url.pathname === "/api/settings" && request.method === "PUT") {
const body = await readJson(request);
const sendTime = normalizeSendTime(body.sendTime);
if (!sendTime) {
sendJson(response, 400, { error: "Invalid send time" });
return;
}
const settings = await store.updateSettings({
sendTime,
timezone: store.snapshot().settings.timezone
});
scheduler.setTime({
...parseSendTime(settings.sendTime),
timezone: settings.timezone
});
sendJson(response, 200, { settings });
return;
}
const subscriberMatch = url.pathname.match(/^\/api\/subscribers\/([^/]+)$/);
if (subscriberMatch && request.method === "PUT") {
const body = await readJson(request);
const subscriber = await store.setSubscriberActive(decodeURIComponent(subscriberMatch[1]), body.active);
if (!subscriber) {
sendJson(response, 404, { error: "Subscriber not found" });
return;
}
sendJson(response, 200, { subscriber });
return;
}
if (subscriberMatch && request.method === "DELETE") {
const deleted = await store.deleteSubscriber(decodeURIComponent(subscriberMatch[1]));
if (!deleted) {
sendJson(response, 404, { error: "Subscriber not found" });
return;
}
sendJson(response, 200, { ok: true });
return;
}
if (url.pathname === "/api/import" && request.method === "POST") {
const imports = await runImport();
sendJson(response, 200, { imports, holidayCount: store.snapshot().holidays.length });
@@ -179,6 +223,18 @@ function isAuthorized(request) {
return Boolean(config.adminPassword) && header === expected;
}
function normalizeSendTime(value) {
if (typeof value !== "string") return null;
const match = value.trim().match(/^([01]\d|2[0-3]):([0-5]\d)$/);
return match ? `${match[1]}:${match[2]}` : null;
}
function parseSendTime(value) {
const normalized = normalizeSendTime(value) ?? "09:00";
const [hour, minute] = normalized.split(":").map(Number);
return { hour, minute };
}
async function readJson(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);