418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
const states = ["Backlog", "In Progress", "Review", "Done"];
|
|
const priorities = ["Low", "Normal", "Medium", "High", "Urgent"];
|
|
const assignees = [
|
|
{ value: "bao", label: "Bao" },
|
|
{ value: "maxi", label: "Maxi" },
|
|
{ value: "luna", label: "Luna" },
|
|
{ value: "iris", label: "Iris" }
|
|
];
|
|
|
|
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>
|
|
</div>
|
|
</header>
|
|
<div class="quick-create">
|
|
<button class="primary" type="button" id="open-create-task">Create task</button>
|
|
</div>
|
|
<section class="kanban">
|
|
${states.map((name) => column(name)).join("")}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function column(name) {
|
|
const tasks = state.tasks.filter((task) => task.state === name);
|
|
return `
|
|
<section class="kanban-column" data-state="${name}" aria-label="${name}">
|
|
<header><span>${name}</span><b>${tasks.length}</b></header>
|
|
<div class="dropzone" data-state="${name}">
|
|
${tasks.map(taskCard).join("") || `<p class="empty-state">Keine Aufgaben</p>`}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function taskCard(task) {
|
|
return `
|
|
<article class="task-card 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="priority ${escapeHtml(task.priority).toLowerCase()}">${escapeHtml(task.priority)}</span>
|
|
<button class="task-edit-btn" data-edit="${task.id}" title="Bearbeiten" aria-label="Aufgabe bearbeiten">✎</button>
|
|
</div>
|
|
<div>
|
|
<h3>${escapeHtml(task.title)}</h3>
|
|
${task.detail ? `<p>${escapeHtml(task.detail)}</p>` : ""}
|
|
</div>
|
|
<select class="task-status-select" data-status-task="${task.id}" aria-label="Status fuer ${escapeHtml(task.title)}">
|
|
${states.map((x) => `<option value="${x}" ${x === task.state ? "selected" : ""}>${x}</option>`).join("")}
|
|
</select>
|
|
<div class="task-footer">
|
|
<time class="meta" datetime="${escapeHtml(task.updatedAt)}">${formatDate(task.updatedAt)}</time>
|
|
<span class="meta">${escapeHtml(task.assignee || "luna")}</span>
|
|
</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">Beschreibung
|
|
<textarea name="detail"></textarea>
|
|
</label>
|
|
<div class="grid">
|
|
<label class="field status-field">Status
|
|
<select name="state">${states.map((x) => `<option>${x}</option>`).join("")}</select>
|
|
</label>
|
|
<label class="field">Priorität
|
|
<select name="priority">${priorities.map((x) => `<option>${x}</option>`).join("")}</select>
|
|
</label>
|
|
</div>
|
|
<label class="field">Zuweisen an
|
|
<select name="assignee">${assigneeOptions(state.settings?.defaultAssignee || "luna")}</select>
|
|
</label>
|
|
<div class="dialog-actions">
|
|
<button class="ghost" type="button" id="cancel-task">Abbrechen</button>
|
|
<button class="primary" type="submit" id="save-task">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");
|
|
const saveTaskButton = document.querySelector("#save-task");
|
|
let draggedTaskId = null;
|
|
const resetDialog = () => {
|
|
form.reset();
|
|
form.elements.id.value = "";
|
|
form.elements.state.value = "Backlog";
|
|
form.elements.priority.value = "Medium";
|
|
form.elements.assignee.value = normalizeAssignee(state.settings?.defaultAssignee || "luna");
|
|
form.classList.add("task-form-create");
|
|
dialogTitle.textContent = "Neue Aufgabe";
|
|
dialogMeta.textContent = "Erstelle eine neue Aufgabe fuer das Mission-Control-Board.";
|
|
saveTaskButton.textContent = "Aufgabe erstellen";
|
|
};
|
|
const openTaskDialog = (task) => {
|
|
form.classList.remove("task-form-create");
|
|
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;
|
|
ensureAssigneeOption(form.elements.assignee, task.assignee || state.settings?.defaultAssignee || "luna");
|
|
form.elements.assignee.value = normalizeAssignee(task.assignee || state.settings?.defaultAssignee || "luna");
|
|
dialogTitle.textContent = task.title;
|
|
dialogMeta.textContent = `Status ${task.state} · Aktualisiert ${formatDate(task.updatedAt)} · Erstellt ${formatDate(task.createdAt)}`;
|
|
saveTaskButton.textContent = "Speichern";
|
|
dialog.showModal();
|
|
};
|
|
document.querySelector("#open-create-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-status-task]").forEach((select) => {
|
|
select.addEventListener("change", async (event) => {
|
|
event.stopPropagation();
|
|
await api(`/api/tasks/${select.dataset.statusTask}`, { method: "PATCH", body: JSON.stringify({ state: select.value }) });
|
|
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return "unbekannt";
|
|
return new Date(value).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
|
}
|
|
|
|
function normalizeAssignee(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function assigneeLabel(value) {
|
|
const normalized = normalizeAssignee(value);
|
|
const known = assignees.find((x) => x.value === normalized);
|
|
if (known) return known.label;
|
|
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Luna";
|
|
}
|
|
|
|
function assigneeOptions(selected) {
|
|
const normalized = normalizeAssignee(selected);
|
|
const options = [...assignees];
|
|
if (normalized && !options.some((x) => x.value === normalized)) {
|
|
options.push({ value: normalized, label: assigneeLabel(normalized) });
|
|
}
|
|
return options.map((x) => `<option value="${escapeHtml(x.value)}" ${x.value === normalized ? "selected" : ""}>${escapeHtml(x.label)}</option>`).join("");
|
|
}
|
|
|
|
function ensureAssigneeOption(select, value) {
|
|
const normalized = normalizeAssignee(value);
|
|
if (!normalized || Array.from(select.options).some((option) => option.value === normalized)) return;
|
|
select.add(new Option(assigneeLabel(normalized), normalized));
|
|
}
|
|
|
|
init();
|