Match Maxi task board to Nexus design
This commit is contained in:
+314
-243
@@ -1,12 +1,17 @@
|
||||
const states = ["Backlog", "In Progress", "Review", "Done"];
|
||||
const priorities = ["Low", "Normal", "Medium", "High", "Urgent"];
|
||||
const columns = [
|
||||
{ key: "offen", state: "Backlog", label: "Offen", color: "var(--st-queue)" },
|
||||
{ key: "inProgress", state: "In progress", label: "In Bearbeitung", color: "var(--st-work)" },
|
||||
{ key: "review", state: "Review", label: "Review", color: "#fb923c" },
|
||||
{ key: "done", state: "Done", label: "Erledigt", color: "var(--st-work)" },
|
||||
{ key: "blocked", state: "Blocked", label: "Blockiert", color: "var(--st-block)", blocked: true }
|
||||
];
|
||||
const states = columns.map((column) => column.state);
|
||||
const priorities = ["High", "Medium", "Low"];
|
||||
const assignees = [
|
||||
{ value: "bao", label: "Bao" },
|
||||
{ value: "maxi", label: "Maxi" },
|
||||
{ value: "luna", label: "Luna" },
|
||||
{ value: "iris", label: "Iris" }
|
||||
{ value: "iris", label: "Iris" },
|
||||
{ value: "maxi", label: "Maxi" }
|
||||
];
|
||||
const createTaskDefaultAssignee = "bao";
|
||||
|
||||
const app = document.querySelector("#app");
|
||||
const state = {
|
||||
@@ -15,6 +20,10 @@ const state = {
|
||||
settings: null,
|
||||
openClaw: null,
|
||||
view: "board",
|
||||
dialogMode: null,
|
||||
selectedTaskId: null,
|
||||
draggedTaskId: null,
|
||||
dragOverKey: null,
|
||||
error: ""
|
||||
};
|
||||
|
||||
@@ -43,17 +52,21 @@ async function init() {
|
||||
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 class="login-card" id="login-form">
|
||||
<div class="login-brand">
|
||||
<div class="brand-mark">MC</div>
|
||||
<div><strong>MAXI</strong><span>Mission Control</span></div>
|
||||
</div>
|
||||
<div class="login-heading">
|
||||
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
|
||||
<h1>Anmelden</h1>
|
||||
<p>Maxis geschuetzte Kommandozentrale fuer Aufgaben und OpenClaw-Status.</p>
|
||||
</div>
|
||||
<label><span>E-Mail</span><input name="email" type="email" autocomplete="username" required autofocus></label>
|
||||
<label><span>Passwort</span><input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Anmelden</button>
|
||||
<p class="login-error" id="login-error" role="alert"></p>
|
||||
<footer>Gesicherte Sitzung - Maxi Branding bleibt erhalten.</footer>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
@@ -76,7 +89,7 @@ function renderLogin() {
|
||||
|
||||
async function loadTasks() {
|
||||
const result = await api("/api/tasks");
|
||||
state.tasks = result.tasks;
|
||||
state.tasks = result.tasks.map(normalizeTask);
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -85,28 +98,49 @@ async function loadSettings() {
|
||||
state.openClaw = result.openClaw;
|
||||
}
|
||||
|
||||
function normalizeTask(task) {
|
||||
return {
|
||||
...task,
|
||||
state: task.state === "In Progress" ? "In progress" : task.state,
|
||||
priority: task.priority === "Normal" ? "Medium" : task.priority === "Urgent" ? "High" : task.priority,
|
||||
assignee: task.assignee || "bao"
|
||||
};
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
app.innerHTML = `
|
||||
<section class="app-shell">
|
||||
<section class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<strong>Maxi Mission Control</strong>
|
||||
<span>mc.texisoft.com</span>
|
||||
<div class="brand-mark">MC</div>
|
||||
<div><strong>MAXI</strong><span>mc.texisoft.com</span></div>
|
||||
</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>
|
||||
<button data-view="board" class="${state.view === "board" ? "active" : ""}">Task Board <i>${state.tasks.length}</i></button>
|
||||
<button data-view="settings" class="${state.view === "settings" ? "active" : ""}">Settings</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 class="sidebar-bottom">
|
||||
<button id="logout">Abmelden</button>
|
||||
<div class="owner">
|
||||
<div class="avatar">MX</div>
|
||||
<div><strong>${escapeHtml(state.user?.name || "Maxi")}</strong><span>${escapeHtml(state.user?.role || "Admin")}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
${state.view === "settings" ? settingsView() : boardView()}
|
||||
</section>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<button class="mobile-menu">Menu</button>
|
||||
<div class="search">Mission Control <kbd>/</kbd></div>
|
||||
<div class="top-actions">
|
||||
<span class="connection ${state.openClaw?.ok ? "live" : "preview"}"><span class="status-dot ${state.openClaw?.ok ? "online" : "offline"}"></span>OpenClaw ${state.openClaw?.ok ? "live" : "pruefen"}</span>
|
||||
<button class="refresh" id="refresh-board">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="content">${state.view === "settings" ? settingsView() : boardView()}</section>
|
||||
</main>
|
||||
</section>
|
||||
${taskDialog()}
|
||||
${createModal()}
|
||||
${detailModal()}
|
||||
`;
|
||||
|
||||
document.querySelectorAll("[data-view]").forEach((button) => {
|
||||
@@ -116,247 +150,253 @@ function renderApp() {
|
||||
renderApp();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#logout").addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST" });
|
||||
state.user = null;
|
||||
renderLogin();
|
||||
});
|
||||
|
||||
document.querySelector("#refresh-board").addEventListener("click", async () => {
|
||||
await Promise.all([loadTasks(), loadSettings()]);
|
||||
renderApp();
|
||||
});
|
||||
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 class="board-wrap">
|
||||
<div class="board-header">
|
||||
<div>
|
||||
<h1><span class="grad-text">Aufgaben</span></h1>
|
||||
<p class="board-subtitle">Task Board - Uebersicht aller Arbeitspakete</p>
|
||||
<div class="board-live-row">
|
||||
<span class="live-pill ${state.openClaw?.ok ? "live-pill-connected" : "live-pill-degraded"}">${state.openClaw?.ok ? "Live" : "Polling"}</span>
|
||||
<span class="live-meta">${state.tasks.length} Aufgaben</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="board-header-actions">
|
||||
<button class="create-btn" id="new-task"><span class="plus-icon">+</span>Neue Aufgabe</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="status-pill"><span class="dot ${connected ? "ok" : ""}"></span>OpenClaw ${connected ? "verbunden" : "pruefen"}</span>
|
||||
<div class="board-columns">
|
||||
${columns.map(column).join("")}
|
||||
</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);
|
||||
function column(columnDef) {
|
||||
const tasks = state.tasks.filter((task) => task.state === columnDef.state);
|
||||
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 class="col ${columnDef.blocked ? "col-blocked" : ""} ${state.dragOverKey === columnDef.key ? "drag-over" : ""}" data-state="${columnDef.state}" data-column="${columnDef.key}">
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background:${columnDef.color};box-shadow:0 0 0 2px color-mix(in srgb, ${columnDef.color}, transparent 72%);"></span>
|
||||
<span class="col-name">${columnDef.label}</span>
|
||||
<span class="col-count">${tasks.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="col-cards">
|
||||
${tasks.map(taskCard).join("") || `<div class="empty-col">${columnDef.blocked ? "Keine Blockierer" : "Keine Aufgaben"}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function taskCard(task) {
|
||||
const blockedClass = task.state === "Blocked" ? "card-blocked" : "";
|
||||
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>
|
||||
<button type="button" class="card ${blockedClass}" data-card="${task.id}" draggable="true">
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" style="color:${priorityColor(task.priority)};border-color:${priorityColor(task.priority)}">${priorityLabel(task.priority)}</span>
|
||||
<span class="assignee ${assigneeClass(task.assignee)}">${assigneeLabel(task.assignee)}</span>
|
||||
</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>
|
||||
<div class="card-title">${escapeHtml(task.title)}</div>
|
||||
${task.detail ? `<div class="card-preview">${escapeHtml(task.detail)}</div>` : ""}
|
||||
<div class="card-meta">Update ${relativeTime(task.updatedAt || task.createdAt)}</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function taskDialog() {
|
||||
function createModal() {
|
||||
if (state.dialogMode !== "create") return "";
|
||||
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 class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header"><h2>Neue Aufgabe</h2><button class="modal-close" type="button" id="close-modal">×</button></div>
|
||||
<form class="modal-form" id="create-form">
|
||||
<div class="field"><label for="task-title">Titel <span class="req">*</span></label><input id="task-title" name="title" class="field-input" placeholder="Aufgabe beschreiben..." required autofocus></div>
|
||||
<div class="field"><label for="task-detail">Beschreibung</label><textarea id="task-detail" name="detail" class="field-input field-textarea" rows="3" placeholder="Details zur Aufgabe..."></textarea></div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label for="task-priority">Prioritaet</label><select id="task-priority" name="priority" class="field-input field-select">${priorities.map((p) => `<option ${p === "Medium" ? "selected" : ""}>${p}</option>`).join("")}</select></div>
|
||||
<div class="field"><label for="task-assignee">Zugewiesen an</label><select id="task-assignee" name="assignee" class="field-input field-select">${assignees.map((a) => `<option value="${a.value}" ${a.value === "bao" ? "selected" : ""}>${a.label}</option>`).join("")}</select></div>
|
||||
</div>
|
||||
<p class="form-error" id="form-error"></p>
|
||||
<div class="modal-actions"><button type="button" class="btn-cancel" id="cancel-modal">Abbrechen</button><button type="submit" class="btn-submit">Aufgabe erstellen</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function detailModal() {
|
||||
if (state.dialogMode !== "detail") return "";
|
||||
const task = selectedTask();
|
||||
if (!task) return "";
|
||||
return `
|
||||
<div class="detail-overlay" id="detail-overlay">
|
||||
<aside class="detail-panel">
|
||||
<div class="detail-topbar"><div class="detail-breadcrumb">Task Board / ${stateLabel(task.state)}</div><button class="detail-close" id="close-detail" type="button">x</button></div>
|
||||
<div class="detail-content">
|
||||
<section class="detail-main">
|
||||
<div class="detail-title-block">
|
||||
<span class="detail-state-pill ${statusTone(task.state)}">${stateLabel(task.state)}</span>
|
||||
<input class="detail-title-input" name="title" form="detail-form" maxlength="240" value="${escapeAttr(task.title)}">
|
||||
<div class="detail-meta-row"><span>#${escapeHtml(task.id.slice(0, 8))}</span><span>Aktualisiert ${formatDate(task.updatedAt, true)}</span><span>Erstellt ${formatDate(task.createdAt)}</span></div>
|
||||
</div>
|
||||
<div class="detail-section"><div class="detail-section-header">Beschreibung</div><textarea class="detail-textarea" name="detail" form="detail-form" rows="8" placeholder="Mehr Kontext, Akzeptanzkriterien oder Notizen...">${escapeHtml(task.detail || "")}</textarea></div>
|
||||
<div class="detail-grid">
|
||||
<section class="detail-section"><div class="detail-section-header">Unteraufgaben</div><div class="detail-empty">Noch keine Unteraufgaben verknuepft.</div></section>
|
||||
<section class="detail-section"><div class="detail-section-header">Aktivitaet</div><div class="activity-list"><article class="activity-item"><div class="activity-dot"></div><div><div class="activity-message">Task in Maxi Mission Control aktualisiert.</div><div class="activity-time">${formatDate(task.updatedAt, true)}</div></div></article></div></section>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="detail-sidebar">
|
||||
<form id="detail-form" class="sidebar-card">
|
||||
<div class="sidebar-heading">Eigenschaften</div>
|
||||
<label class="sidebar-field"><span>Status</span><select name="state" class="field-input field-select slim">${states.map((s) => `<option value="${s}" ${s === task.state ? "selected" : ""}>${stateLabel(s)}</option>`).join("")}</select></label>
|
||||
<label class="sidebar-field"><span>Prioritaet</span><select name="priority" class="field-input field-select slim">${priorities.map((p) => `<option ${p === task.priority ? "selected" : ""}>${p}</option>`).join("")}</select></label>
|
||||
<label class="sidebar-field"><span>Zustaendig</span><select name="assignee" class="field-input field-select slim">${assignees.map((a) => `<option value="${a.value}" ${a.value === (task.assignee || "bao") ? "selected" : ""}>${a.label}</option>`).join("")}</select></label>
|
||||
</form>
|
||||
<section class="sidebar-card subtle"><div class="sidebar-heading">Snapshot</div><dl class="snapshot-list"><div><dt>Quelle</dt><dd>Maxi</dd></div><div><dt>Prioritaet</dt><dd>${escapeHtml(task.priority)}</dd></div><div><dt>Zustaendig</dt><dd>${assigneeLabel(task.assignee)}</dd></div></dl></section>
|
||||
<p class="detail-flash error" id="detail-error"></p>
|
||||
<div class="detail-actions"><button type="button" class="btn-cancel" id="cancel-detail">Schliessen</button><button type="button" class="btn-submit" id="save-detail">Speichern</button><button type="button" class="btn-danger" id="delete-detail">Loeschen</button></div>
|
||||
</aside>
|
||||
</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>
|
||||
</aside>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = createTaskDefaultAssignee;
|
||||
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("#new-task").addEventListener("click", () => {
|
||||
state.dialogMode = "create";
|
||||
renderApp();
|
||||
});
|
||||
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;
|
||||
document.querySelectorAll(".col").forEach((columnEl) => {
|
||||
columnEl.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
const task = state.tasks.find((x) => x.id === card.dataset.taskId);
|
||||
if (task) openTaskDialog(task);
|
||||
state.dragOverKey = columnEl.dataset.column;
|
||||
columnEl.classList.add("drag-over");
|
||||
});
|
||||
columnEl.addEventListener("dragleave", () => columnEl.classList.remove("drag-over"));
|
||||
columnEl.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
columnEl.classList.remove("drag-over");
|
||||
const id = event.dataTransfer?.getData("text/plain") || state.draggedTaskId;
|
||||
state.dragOverKey = null;
|
||||
if (!id) return;
|
||||
await moveTask(id, columnEl.dataset.state);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-card]").forEach((card) => {
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
draggedTaskId = card.dataset.taskId;
|
||||
card.classList.add("dragging");
|
||||
state.draggedTaskId = card.dataset.card;
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", draggedTaskId);
|
||||
event.dataTransfer.setData("text/plain", card.dataset.card);
|
||||
card.classList.add("dragging");
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
draggedTaskId = null;
|
||||
state.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();
|
||||
card.addEventListener("click", () => {
|
||||
state.selectedTaskId = card.dataset.card;
|
||||
state.dialogMode = "detail";
|
||||
renderApp();
|
||||
});
|
||||
});
|
||||
bindCreateModal();
|
||||
bindDetailModal();
|
||||
}
|
||||
|
||||
function bindCreateModal() {
|
||||
const form = document.querySelector("#create-form");
|
||||
if (!form) return;
|
||||
document.querySelector("#close-modal").addEventListener("click", closeModal);
|
||||
document.querySelector("#cancel-modal").addEventListener("click", closeModal);
|
||||
document.querySelector("#modal-overlay").addEventListener("click", (event) => {
|
||||
if (event.target.id === "modal-overlay") closeModal();
|
||||
});
|
||||
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();
|
||||
try {
|
||||
await api("/api/tasks", { method: "POST", body: JSON.stringify({ ...data, state: "Backlog" }) });
|
||||
closeModal();
|
||||
await loadTasks();
|
||||
renderApp();
|
||||
} catch (error) {
|
||||
document.querySelector("#form-error").textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDetailModal() {
|
||||
const form = document.querySelector("#detail-form");
|
||||
if (!form) return;
|
||||
document.querySelector("#close-detail").addEventListener("click", closeModal);
|
||||
document.querySelector("#cancel-detail").addEventListener("click", closeModal);
|
||||
document.querySelector("#detail-overlay").addEventListener("click", (event) => {
|
||||
if (event.target.id === "detail-overlay") closeModal();
|
||||
});
|
||||
document.querySelector("#save-detail").addEventListener("click", async () => {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
data.title = document.querySelector(".detail-title-input").value;
|
||||
data.detail = document.querySelector(".detail-textarea").value;
|
||||
try {
|
||||
await api(`/api/tasks/${state.selectedTaskId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
closeModal();
|
||||
await loadTasks();
|
||||
renderApp();
|
||||
} catch (error) {
|
||||
document.querySelector("#detail-error").textContent = error.message;
|
||||
}
|
||||
});
|
||||
document.querySelector("#delete-detail").addEventListener("click", async () => {
|
||||
await api(`/api/tasks/${state.selectedTaskId}`, { method: "DELETE" });
|
||||
closeModal();
|
||||
await loadTasks();
|
||||
renderApp();
|
||||
});
|
||||
}
|
||||
|
||||
async function moveTask(id, nextState) {
|
||||
await api(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify({ state: nextState }) });
|
||||
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>
|
||||
<div class="page-heading">
|
||||
<div><span class="eyebrow">MAXI</span><h1>Settings</h1><p>Admin-Konfiguration und Maxi-OpenClaw-Verbindung.</p></div>
|
||||
<button class="refresh" id="refresh-settings">Aktualisieren</button>
|
||||
</div>
|
||||
<section class="settings-grid">
|
||||
<article class="module-card">
|
||||
<div class="module-card-head"><span class="eyebrow">OPENCLAW</span><span class="badge ${openClaw.ok ? "positive" : "warning"}">${openClaw.ok ? "Live" : "Check"}</span></div>
|
||||
<h3>Gateway</h3>
|
||||
<p>${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}</p>
|
||||
<p>Health ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} - ${escapeHtml(String(openClaw.latencyMs || 0))} ms</p>
|
||||
</article>
|
||||
<article class="module-card">
|
||||
<div class="module-card-head"><span class="eyebrow">BOARD</span></div>
|
||||
<form id="settings-form">
|
||||
<div class="field"><label>Default-Assignee</label><input class="field-input" name="defaultAssignee" value="${escapeAttr(state.settings?.defaultAssignee || "bao")}"></div>
|
||||
<div class="field"><label>Zeitzone</label><input class="field-input" name="timezone" value="${escapeAttr(state.settings?.timezone || "Europe/Berlin")}"></div>
|
||||
<div class="modal-actions"><button class="btn-submit" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -375,6 +415,64 @@ function bindSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
state.dialogMode = null;
|
||||
state.selectedTaskId = null;
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function selectedTask() {
|
||||
return state.tasks.find((task) => task.id === state.selectedTaskId);
|
||||
}
|
||||
|
||||
function priorityLabel(priority) {
|
||||
return priority === "High" ? "High" : priority === "Low" ? "Low" : "Med";
|
||||
}
|
||||
|
||||
function priorityColor(priority) {
|
||||
return priority === "High" ? "#f87171" : priority === "Low" ? "#60a5fa" : "#facc15";
|
||||
}
|
||||
|
||||
function assigneeLabel(assignee) {
|
||||
const match = assignees.find((item) => item.value === String(assignee || "").toLowerCase());
|
||||
return match?.label || escapeHtml(assignee || "Bao");
|
||||
}
|
||||
|
||||
function assigneeClass(assignee) {
|
||||
const lower = String(assignee || "").toLowerCase();
|
||||
if (lower === "iris") return "assignee-iris";
|
||||
if (lower === "bao") return "assignee-bao";
|
||||
return "assignee-agent";
|
||||
}
|
||||
|
||||
function stateLabel(value) {
|
||||
const found = columns.find((column) => column.state === value);
|
||||
return found?.label || value;
|
||||
}
|
||||
|
||||
function statusTone(value) {
|
||||
if (value === "Done") return "is-done";
|
||||
if (value === "Blocked") return "is-blocked";
|
||||
if (value === "Review") return "is-review";
|
||||
if (value === "In progress") return "is-progress";
|
||||
return "is-backlog";
|
||||
}
|
||||
|
||||
function relativeTime(date) {
|
||||
if (!date) return "keine Updates";
|
||||
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000));
|
||||
if (mins < 1) return "gerade eben";
|
||||
if (mins < 60) return `vor ${mins} min`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `vor ${hours} h`;
|
||||
return `vor ${Math.round(hours / 24)} d`;
|
||||
}
|
||||
|
||||
function formatDate(date, withTime = false) {
|
||||
if (!date) return "-";
|
||||
return new Date(date).toLocaleString("de-DE", withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
@@ -384,35 +482,8 @@ function escapeHtml(value) {
|
||||
.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));
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("`", "`");
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user