Bring Nexus-style taskboard interactions to Maxi MC

This commit is contained in:
2026-06-26 15:07:50 +00:00
parent f8855279f1
commit 2d906b9e1e
2 changed files with 164 additions and 29 deletions
+91 -16
View File
@@ -142,9 +142,11 @@ function boardView() {
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 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>
`;
}
@@ -152,20 +154,23 @@ function column(name) {
function taskCard(task) {
const index = states.indexOf(task.state);
return `
<article class="task">
<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">
<span class="badge">${escapeHtml(task.priority)}</span>
<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>
<span class="meta">${escapeHtml(task.assignee || "luna")}</span>
</article>
`;
}
@@ -175,6 +180,7 @@ function taskDialog() {
<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>
@@ -205,29 +211,93 @@ function taskDialog() {
function bindBoard() {
const dialog = document.querySelector("#task-dialog");
const form = document.querySelector("#task-form");
document.querySelector("#new-task").addEventListener("click", () => {
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 () => {
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", () => {
button.addEventListener("click", (event) => {
event.stopPropagation();
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();
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) => {
@@ -299,4 +369,9 @@ function escapeHtml(value) {
.replaceAll("'", "&#039;");
}
function formatDate(value) {
if (!value) return "unbekannt";
return new Date(value).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
init();
+73 -13
View File
@@ -1,17 +1,18 @@
:root {
color-scheme: light;
--bg: #f5f7f8;
--bg: #f3f5f8;
--panel: #ffffff;
--panel-soft: #eef3f1;
--text: #17201c;
--muted: #66736d;
--line: #d9e1de;
--accent: #256f5b;
--accent-strong: #174c3e;
--warn: #a45b14;
--danger: #9d2f2f;
--ok: #1c7a4c;
--shadow: 0 10px 28px rgba(23, 32, 28, 0.08);
--panel-soft: #eef2f7;
--text: #18212f;
--muted: #667085;
--line: #d9e0ea;
--accent: #3268d8;
--accent-strong: #224fa8;
--accent-soft: #e8efff;
--warn: #b7791f;
--danger: #b42335;
--ok: #14845f;
--shadow: 0 12px 30px rgba(24, 33, 47, 0.09);
}
* {
@@ -236,7 +237,7 @@ input:focus, textarea:focus, select:focus {
.column {
min-width: 0;
background: #ecf1ef;
background: #edf1f6;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
@@ -263,6 +264,25 @@ input:focus, textarea:focus, select:focus {
color: var(--text);
}
.dropzone {
min-height: 456px;
border-radius: 8px;
transition: background 0.16s ease, box-shadow 0.16s ease;
}
.dropzone.drag-over {
background: rgba(50, 104, 216, 0.08);
box-shadow: inset 0 0 0 2px rgba(50, 104, 216, 0.28);
}
.empty-state {
margin: 0;
padding: 32px 6px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.task {
display: grid;
gap: 10px;
@@ -272,6 +292,28 @@ input:focus, textarea:focus, select:focus {
padding: 12px;
margin-bottom: 10px;
box-shadow: 0 4px 14px rgba(23, 32, 28, 0.04);
cursor: grab;
transition: border-color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease;
}
.task:hover,
.task:focus-visible {
border-color: rgba(50, 104, 216, 0.45);
box-shadow: 0 12px 24px rgba(24, 33, 47, 0.1);
outline: none;
transform: translateY(-1px);
}
.task.dragging {
opacity: 0.55;
cursor: grabbing;
}
.task-card-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.task h3 {
@@ -299,12 +341,23 @@ input:focus, textarea:focus, select:focus {
min-height: 24px;
padding: 3px 8px;
border-radius: 999px;
background: var(--panel-soft);
background: var(--accent-soft);
color: var(--accent-strong);
font-size: 12px;
font-weight: 750;
}
.priority-urgent .badge,
.priority-high .badge {
background: #fff3d8;
color: #8a4f00;
}
.priority-low .badge {
background: #e9f8f0;
color: #0d6b4d;
}
.task-controls {
display: flex;
gap: 4px;
@@ -348,6 +401,7 @@ dialog {
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 22px;
}
dialog::backdrop {
@@ -361,6 +415,12 @@ dialog::backdrop {
margin-top: 16px;
}
.dialog-meta {
min-height: 20px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;