Bring Nexus-style taskboard interactions to Maxi MC
This commit is contained in:
+95
-20
@@ -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";
|
||||
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);
|
||||
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) => {
|
||||
@@ -299,4 +369,9 @@ function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "unbekannt";
|
||||
return new Date(value).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
+73
-13
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user