Add admin settings and subscriber management
This commit is contained in:
+53
-2
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user