Initial slim mission control

This commit is contained in:
2026-06-26 14:15:24 +02:00
commit f8855279f1
10 changed files with 1170 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
const states = ["Backlog", "In Progress", "Review", "Done"];
const priorities = ["Low", "Normal", "High", "Urgent"];
const app = document.querySelector("#app");
const state = {
user: null,
tasks: [],
settings: null,
openClaw: null,
view: "board",
error: ""
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function init() {
try {
const session = await api("/api/session");
state.user = session.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch {
renderLogin();
}
}
function renderLogin() {
app.innerHTML = `
<section class="login-shell">
<form class="login" id="login-form">
<h1>Maxi Mission Control</h1>
<p>Schlanke Kommandozentrale fuer Aufgaben und OpenClaw-Status.</p>
<label class="field">E-Mail
<input name="email" type="email" autocomplete="username" required autofocus>
</label>
<label class="field">Passwort
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="primary field" type="submit">Anmelden</button>
<div class="error" id="login-error" role="alert"></div>
</form>
</section>
`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const result = await api("/api/login", {
method: "POST",
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
});
state.user = result.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch (error) {
document.querySelector("#login-error").textContent = error.message;
}
});
}
async function loadTasks() {
const result = await api("/api/tasks");
state.tasks = result.tasks;
}
async function loadSettings() {
const result = await api("/api/settings");
state.settings = result.settings;
state.openClaw = result.openClaw;
}
function renderApp() {
app.innerHTML = `
<section class="app-shell">
<aside class="sidebar">
<div class="brand">
<strong>Maxi Mission Control</strong>
<span>mc.texisoft.com</span>
</div>
<nav class="nav">
<button data-view="board" class="${state.view === "board" ? "active" : ""}" title="Taskboard">▦ Taskboard</button>
<button data-view="settings" class="${state.view === "settings" ? "active" : ""}" title="Einstellungen">⚙ Einstellungen</button>
</nav>
<div class="user">
<div><strong>${escapeHtml(state.user?.name || "Maxi")}</strong><br><small>${escapeHtml(state.user?.role || "Admin")}</small></div>
<button class="ghost" id="logout">Abmelden</button>
</div>
</aside>
<section class="content">
${state.view === "settings" ? settingsView() : boardView()}
</section>
</section>
${taskDialog()}
`;
document.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", async () => {
state.view = button.dataset.view;
if (state.view === "settings") await loadSettings();
renderApp();
});
});
document.querySelector("#logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
state.user = null;
renderLogin();
});
if (state.view === "board") bindBoard();
if (state.view === "settings") bindSettings();
}
function boardView() {
const connected = state.openClaw?.ok;
return `
<header class="topbar">
<div class="view-title">
<h1>Taskboard</h1>
<p>${state.tasks.length} Aufgaben · Assignee ${escapeHtml(state.settings?.defaultAssignee || "luna")}</p>
</div>
<div class="actions">
<span class="status-pill"><span class="dot ${connected ? "ok" : ""}"></span>OpenClaw ${connected ? "verbunden" : "pruefen"}</span>
<button class="primary" id="new-task">+ Aufgabe</button>
</div>
</header>
<section class="board">
${states.map((name) => column(name)).join("")}
</section>
`;
}
function column(name) {
const tasks = state.tasks.filter((task) => task.state === name);
return `
<section class="column" data-state="${name}">
<h2>${name}<span class="count">${tasks.length}</span></h2>
${tasks.map(taskCard).join("") || `<p class="meta">Keine Aufgaben</p>`}
</section>
`;
}
function taskCard(task) {
const index = states.indexOf(task.state);
return `
<article class="task">
<div>
<h3>${escapeHtml(task.title)}</h3>
${task.detail ? `<p>${escapeHtml(task.detail)}</p>` : ""}
</div>
<div class="task-footer">
<span class="badge">${escapeHtml(task.priority)}</span>
<div class="task-controls">
<button class="icon-button" data-move="${task.id}" data-state="${states[Math.max(0, index - 1)]}" title="Zurueck"></button>
<button class="icon-button" data-edit="${task.id}" title="Bearbeiten">✎</button>
<button class="icon-button" data-move="${task.id}" data-state="${states[Math.min(states.length - 1, index + 1)]}" title="Weiter"></button>
</div>
</div>
<span class="meta">${escapeHtml(task.assignee || "luna")}</span>
</article>
`;
}
function taskDialog() {
return `
<dialog id="task-dialog">
<form id="task-form">
<h2 id="dialog-title">Aufgabe</h2>
<input type="hidden" name="id">
<label class="field">Titel
<input name="title" required>
</label>
<label class="field">Details
<textarea name="detail"></textarea>
</label>
<div class="grid">
<label class="field">Status
<select name="state">${states.map((x) => `<option>${x}</option>`).join("")}</select>
</label>
<label class="field">Prioritaet
<select name="priority">${priorities.map((x) => `<option>${x}</option>`).join("")}</select>
</label>
</div>
<label class="field">Assignee
<input name="assignee" value="${escapeHtml(state.settings?.defaultAssignee || "luna")}">
</label>
<div class="dialog-actions">
<button class="ghost" type="button" id="cancel-task">Abbrechen</button>
<button class="primary" type="submit">Speichern</button>
</div>
</form>
</dialog>
`;
}
function bindBoard() {
const dialog = document.querySelector("#task-dialog");
const form = document.querySelector("#task-form");
document.querySelector("#new-task").addEventListener("click", () => {
form.reset();
form.elements.assignee.value = state.settings?.defaultAssignee || "luna";
dialog.showModal();
});
document.querySelector("#cancel-task").addEventListener("click", () => dialog.close());
document.querySelectorAll("[data-move]").forEach((button) => {
button.addEventListener("click", async () => {
await api(`/api/tasks/${button.dataset.move}`, { method: "PATCH", body: JSON.stringify({ state: button.dataset.state }) });
await loadTasks();
renderApp();
});
});
document.querySelectorAll("[data-edit]").forEach((button) => {
button.addEventListener("click", () => {
const task = state.tasks.find((x) => x.id === button.dataset.edit);
form.elements.id.value = task.id;
form.elements.title.value = task.title;
form.elements.detail.value = task.detail || "";
form.elements.state.value = task.state;
form.elements.priority.value = task.priority;
form.elements.assignee.value = task.assignee || "";
dialog.showModal();
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
const path = data.id ? `/api/tasks/${data.id}` : "/api/tasks";
await api(path, { method: data.id ? "PATCH" : "POST", body: JSON.stringify(data) });
dialog.close();
await loadTasks();
renderApp();
});
}
function settingsView() {
const openClaw = state.openClaw || {};
return `
<header class="topbar">
<div class="view-title">
<h1>Einstellungen</h1>
<p>Admin-Konfiguration und Maxi-OpenClaw-Verbindung.</p>
</div>
<button class="ghost" id="refresh-settings">Aktualisieren</button>
</header>
<section class="panel">
<h2>OpenClaw</h2>
<p class="status-pill"><span class="dot ${openClaw.ok ? "ok" : ""}"></span>${openClaw.ok ? "Verbunden" : "Nicht verbunden"}</p>
<p class="meta">Gateway: ${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}</p>
<p class="meta">Health: ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} · Tool: ${escapeHtml(String(openClaw.toolResult || "nicht geprueft"))} · ${escapeHtml(String(openClaw.latencyMs || 0))} ms</p>
</section>
<section class="panel">
<h2>Board</h2>
<form id="settings-form">
<div class="grid">
<label class="field">Default-Assignee
<input name="defaultAssignee" value="${escapeHtml(state.settings?.defaultAssignee || "luna")}">
</label>
<label class="field">Zeitzone
<input name="timezone" value="${escapeHtml(state.settings?.timezone || "Europe/Berlin")}">
</label>
</div>
<div class="dialog-actions">
<button class="primary" type="submit">Speichern</button>
</div>
</form>
</section>
`;
}
function bindSettings() {
document.querySelector("#refresh-settings").addEventListener("click", async () => {
await loadSettings();
renderApp();
});
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
await api("/api/settings", { method: "PATCH", body: JSON.stringify(data) });
await loadSettings();
renderApp();
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
init();