Files
maxis-mission-control/public/app.js
T

378 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}" aria-label="${name}">
<h2><span>${name}</span><span class="count">${tasks.length}</span></h2>
<div class="dropzone" data-state="${name}">
${tasks.map(taskCard).join("") || `<p class="empty-state">Keine Aufgaben</p>`}
</div>
</section>
`;
}
function taskCard(task) {
const index = states.indexOf(task.state);
return `
<article class="task priority-${escapeHtml(task.priority).toLowerCase()}" draggable="true" data-task-id="${task.id}" tabindex="0" role="button" aria-label="Aufgabe ${escapeHtml(task.title)} oeffnen">
<div class="task-card-head">
<span class="badge">${escapeHtml(task.priority)}</span>
<span class="meta">${escapeHtml(task.assignee || "luna")}</span>
</div>
<div>
<h3>${escapeHtml(task.title)}</h3>
${task.detail ? `<p>${escapeHtml(task.detail)}</p>` : ""}
</div>
<div class="task-footer">
<time class="meta" datetime="${escapeHtml(task.updatedAt)}">${formatDate(task.updatedAt)}</time>
<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>
</article>
`;
}
function taskDialog() {
return `
<dialog id="task-dialog">
<form id="task-form">
<h2 id="dialog-title">Aufgabe</h2>
<div class="dialog-meta" id="dialog-meta"></div>
<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");
const dialogTitle = document.querySelector("#dialog-title");
const dialogMeta = document.querySelector("#dialog-meta");
let draggedTaskId = null;
const resetDialog = () => {
form.reset();
form.elements.id.value = "";
form.elements.assignee.value = state.settings?.defaultAssignee || "luna";
dialogTitle.textContent = "Neue Aufgabe";
dialogMeta.textContent = "";
};
const openTaskDialog = (task) => {
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 || "";
dialogTitle.textContent = task.title;
dialogMeta.textContent = `Status ${task.state} · Aktualisiert ${formatDate(task.updatedAt)} · Erstellt ${formatDate(task.createdAt)}`;
dialog.showModal();
};
document.querySelector("#new-task").addEventListener("click", () => {
resetDialog();
dialog.showModal();
});
document.querySelector("#cancel-task").addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
document.querySelectorAll("[data-move]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.stopPropagation();
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", (event) => {
event.stopPropagation();
const task = state.tasks.find((x) => x.id === button.dataset.edit);
if (task) openTaskDialog(task);
});
});
document.querySelectorAll("[data-task-id]").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest("button, input, textarea, select, a")) return;
const task = state.tasks.find((x) => x.id === card.dataset.taskId);
if (task) openTaskDialog(task);
});
card.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
const task = state.tasks.find((x) => x.id === card.dataset.taskId);
if (task) openTaskDialog(task);
});
card.addEventListener("dragstart", (event) => {
draggedTaskId = card.dataset.taskId;
card.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedTaskId);
});
card.addEventListener("dragend", () => {
draggedTaskId = null;
card.classList.remove("dragging");
document.querySelectorAll(".dropzone.drag-over").forEach((zone) => zone.classList.remove("drag-over"));
});
});
document.querySelectorAll(".dropzone").forEach((zone) => {
zone.addEventListener("dragover", (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
zone.classList.add("drag-over");
});
zone.addEventListener("dragleave", (event) => {
if (!zone.contains(event.relatedTarget)) zone.classList.remove("drag-over");
});
zone.addEventListener("drop", async (event) => {
event.preventDefault();
zone.classList.remove("drag-over");
const taskId = event.dataTransfer.getData("text/plain") || draggedTaskId;
const nextState = zone.dataset.state;
const task = state.tasks.find((x) => x.id === taskId);
if (!task || task.state === nextState) return;
await api(`/api/tasks/${taskId}`, { method: "PATCH", body: JSON.stringify({ state: nextState }) });
await loadTasks();
renderApp();
});
});
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;");
}
function formatDate(value) {
if (!value) return "unbekannt";
return new Date(value).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
init();