503 lines
20 KiB
JavaScript
503 lines
20 KiB
JavaScript
/**
|
||
* kanban.js
|
||
* ──────────────────────────────────────────────────────────────────────────────
|
||
* Voll funktionsfähiges Kanban-Board (Trello-Style)
|
||
* • Spalten anlegen / umbenennen / löschen
|
||
* • Karten anlegen (contenteditable) / löschen
|
||
* • Drag & Drop zwischen Spalten + Reihenfolge innerhalb einer Spalte
|
||
* • Drop-Zonen-Visualisierung (dragenter / dragleave)
|
||
* • Farbige Labels per Rechtsklick-Kontextmenü
|
||
* • Vollständige Persistenz in localStorage
|
||
* • Export als JSON-Datei (Blob + createObjectURL)
|
||
* • Reset auf leeres Board
|
||
* ──────────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Konstanten & State
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
const STORAGE_KEY = 'kanban_board_v2';
|
||
|
||
const DEFAULT_BOARD = {
|
||
columns: [
|
||
{
|
||
id: uid(),
|
||
title: '📝 To Do',
|
||
cards: [
|
||
{ id: uid(), text: 'Aufgabe 1 – Beispiel', label: '#60a5fa' },
|
||
{ id: uid(), text: 'Aufgabe 2 – Beispiel', label: 'none' },
|
||
],
|
||
},
|
||
{
|
||
id: uid(),
|
||
title: '⚙️ In Arbeit',
|
||
cards: [
|
||
{ id: uid(), text: 'fetch & API integrieren', label: '#a78bfa' },
|
||
],
|
||
},
|
||
{
|
||
id: uid(),
|
||
title: '✅ Erledigt',
|
||
cards: [
|
||
{ id: uid(), text: 'Dark Mode implementieren', label: '#4ade80' },
|
||
{ id: uid(), text: 'Hamburger-Menü Mobile', label: '#4ade80' },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
/* Laufender State (wird aus localStorage oder Default geladen) */
|
||
let state = loadState();
|
||
|
||
/* Drag-Kontext */
|
||
let dragCardId = null; // ID der gezogenen Karte
|
||
let dragColumnId = null; // Quell-Spalte
|
||
|
||
/* Label-Picker Kontext */
|
||
let labelTargetCardId = null;
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Utility
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
function uid () {
|
||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||
}
|
||
|
||
function loadState () {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) return JSON.parse(raw);
|
||
} catch (_) {}
|
||
// Deep-clone default so UIDs werden nur einmal erzeugt
|
||
return JSON.parse(JSON.stringify(DEFAULT_BOARD));
|
||
}
|
||
|
||
function saveState () {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||
}
|
||
|
||
function findColumn (colId) {
|
||
return state.columns.find(c => c.id === colId);
|
||
}
|
||
|
||
function findCard (cardId) {
|
||
for (const col of state.columns) {
|
||
const card = col.cards.find(c => c.id === cardId);
|
||
if (card) return { card, column: col };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Board rendern
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
const board = document.getElementById('kanban-board');
|
||
|
||
function renderBoard () {
|
||
board.innerHTML = '';
|
||
state.columns.forEach(col => board.appendChild(buildColumn(col)));
|
||
// "Spalte hinzufügen"-Ghost am Ende (für Drop-Hint, optional)
|
||
}
|
||
|
||
/* ── Spalte bauen ────────────────────────────────────────────────────── */
|
||
function buildColumn (colData) {
|
||
const col = document.createElement('div');
|
||
col.className = 'kanban-column';
|
||
col.dataset.colId = colData.id;
|
||
|
||
/* Header */
|
||
const header = document.createElement('div');
|
||
header.className = 'kanban-col-header';
|
||
|
||
const titleEl = document.createElement('h2');
|
||
titleEl.className = 'kanban-col-title';
|
||
titleEl.textContent = colData.title;
|
||
titleEl.title = 'Doppelklick zum Umbenennen';
|
||
titleEl.addEventListener('dblclick', () => renameColumn(colData.id, titleEl));
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'kanban-col-badge';
|
||
badge.textContent = colData.cards.length;
|
||
|
||
const deleteColBtn = document.createElement('button');
|
||
deleteColBtn.type = 'button';
|
||
deleteColBtn.className = 'kanban-col-delete';
|
||
deleteColBtn.title = 'Spalte löschen';
|
||
deleteColBtn.innerHTML = '✕';
|
||
deleteColBtn.addEventListener('click', () => deleteColumn(colData.id));
|
||
|
||
header.appendChild(titleEl);
|
||
header.appendChild(badge);
|
||
header.appendChild(deleteColBtn);
|
||
|
||
/* Karten-Liste */
|
||
const cardList = document.createElement('ul');
|
||
cardList.className = 'kanban-card-list';
|
||
cardList.dataset.colId = colData.id;
|
||
|
||
colData.cards.forEach(cardData => {
|
||
cardList.appendChild(buildCard(cardData, colData.id));
|
||
});
|
||
|
||
/* Drop-Placeholder (sichtbar beim Drag) */
|
||
const placeholder = document.createElement('li');
|
||
placeholder.className = 'kanban-drop-placeholder';
|
||
placeholder.dataset.placeholder = 'true';
|
||
|
||
/* Drag-Events auf cardList */
|
||
cardList.addEventListener('dragover', e => onCardListDragOver(e, cardList));
|
||
cardList.addEventListener('dragenter', e => onColumnDragEnter(e, col));
|
||
cardList.addEventListener('dragleave', e => onColumnDragLeave(e, col));
|
||
cardList.addEventListener('drop', e => onCardListDrop(e, colData.id, cardList));
|
||
|
||
/* Footer: + Karte Button */
|
||
const footer = document.createElement('div');
|
||
footer.className = 'kanban-col-footer';
|
||
const addCardBtn = document.createElement('button');
|
||
addCardBtn.type = 'button';
|
||
addCardBtn.className = 'btn-add-card';
|
||
addCardBtn.textContent = '+ Karte';
|
||
addCardBtn.addEventListener('click', () => addCard(colData.id, cardList, badge, colData));
|
||
footer.appendChild(addCardBtn);
|
||
|
||
col.appendChild(header);
|
||
col.appendChild(cardList);
|
||
col.appendChild(footer);
|
||
return col;
|
||
}
|
||
|
||
/* ── Karte bauen ─────────────────────────────────────────────────────── */
|
||
function buildCard (cardData, colId) {
|
||
const li = document.createElement('li');
|
||
li.className = 'kanban-card';
|
||
li.draggable = true;
|
||
li.dataset.cardId = cardData.id;
|
||
li.dataset.colId = colId;
|
||
|
||
/* Label-Streifen */
|
||
const labelBar = document.createElement('div');
|
||
labelBar.className = 'kanban-card-label';
|
||
if (cardData.label && cardData.label !== 'none') {
|
||
labelBar.style.background = cardData.label;
|
||
} else {
|
||
labelBar.style.display = 'none';
|
||
}
|
||
|
||
/* Text (contenteditable) */
|
||
const textEl = document.createElement('div');
|
||
textEl.className = 'kanban-card-text';
|
||
textEl.contentEditable = 'true';
|
||
textEl.spellcheck = false;
|
||
textEl.textContent = cardData.text;
|
||
textEl.setAttribute('aria-label', 'Kartentext bearbeiten');
|
||
|
||
textEl.addEventListener('blur', () => {
|
||
const result = findCard(cardData.id);
|
||
if (result) {
|
||
result.card.text = textEl.textContent.trim() || result.card.text;
|
||
saveState();
|
||
}
|
||
});
|
||
// Enter → Blur statt Zeilenumbruch
|
||
textEl.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); textEl.blur(); }
|
||
});
|
||
|
||
/* Löschen-Button */
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.type = 'button';
|
||
deleteBtn.className = 'kanban-card-delete';
|
||
deleteBtn.title = 'Karte löschen';
|
||
deleteBtn.innerHTML = '✕';
|
||
deleteBtn.addEventListener('click', () => deleteCard(cardData.id));
|
||
|
||
li.appendChild(labelBar);
|
||
li.appendChild(textEl);
|
||
li.appendChild(deleteBtn);
|
||
|
||
/* Drag Events */
|
||
li.addEventListener('dragstart', e => onCardDragStart(e, cardData.id, colId, li));
|
||
li.addEventListener('dragend', () => onCardDragEnd());
|
||
|
||
/* Rechtsklick → Label Picker */
|
||
li.addEventListener('contextmenu', e => {
|
||
e.preventDefault();
|
||
openLabelPicker(e, cardData.id);
|
||
});
|
||
|
||
return li;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Spalten-Operationen
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
function addColumn () {
|
||
const name = prompt('Name der neuen Spalte:');
|
||
if (!name || !name.trim()) return;
|
||
const colData = { id: uid(), title: name.trim(), cards: [] };
|
||
state.columns.push(colData);
|
||
saveState();
|
||
renderBoard();
|
||
}
|
||
|
||
function renameColumn (colId, titleEl) {
|
||
const col = findColumn(colId);
|
||
if (!col) return;
|
||
const oldTitle = col.title;
|
||
titleEl.contentEditable = 'true';
|
||
titleEl.focus();
|
||
// Cursor ans Ende
|
||
const range = document.createRange();
|
||
range.selectNodeContents(titleEl);
|
||
range.collapse(false);
|
||
window.getSelection().removeAllRanges();
|
||
window.getSelection().addRange(range);
|
||
|
||
function finish () {
|
||
titleEl.contentEditable = 'false';
|
||
col.title = titleEl.textContent.trim() || oldTitle;
|
||
titleEl.textContent = col.title;
|
||
saveState();
|
||
}
|
||
titleEl.addEventListener('blur', finish, { once: true });
|
||
titleEl.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); }
|
||
if (e.key === 'Escape') { titleEl.textContent = oldTitle; titleEl.blur(); }
|
||
}, { once: true });
|
||
}
|
||
|
||
function deleteColumn (colId) {
|
||
const col = findColumn(colId);
|
||
if (!col) return;
|
||
const count = col.cards.length;
|
||
const msg = count > 0
|
||
? `Spalte "${col.title}" mit ${count} Karte(n) löschen?`
|
||
: `Spalte "${col.title}" löschen?`;
|
||
if (!confirm(msg)) return;
|
||
state.columns = state.columns.filter(c => c.id !== colId);
|
||
saveState();
|
||
renderBoard();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Karten-Operationen
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
function addCard (colId, cardList, badge, colData) {
|
||
const cardData = { id: uid(), text: 'Neue Karte…', label: 'none' };
|
||
colData.cards.push(cardData);
|
||
saveState();
|
||
|
||
const li = buildCard(cardData, colId);
|
||
// Vor dem Placeholder (falls vorhanden), sonst ans Ende
|
||
const ph = cardList.querySelector('[data-placeholder]');
|
||
if (ph) cardList.insertBefore(li, ph);
|
||
else cardList.appendChild(li);
|
||
|
||
badge.textContent = colData.cards.length;
|
||
|
||
// Direkt zum Bearbeiten fokussieren
|
||
const textEl = li.querySelector('.kanban-card-text');
|
||
textEl.focus();
|
||
// Alles selektieren
|
||
document.execCommand('selectAll', false, null);
|
||
}
|
||
|
||
function deleteCard (cardId) {
|
||
for (const col of state.columns) {
|
||
const idx = col.cards.findIndex(c => c.id === cardId);
|
||
if (idx !== -1) {
|
||
col.cards.splice(idx, 1);
|
||
saveState();
|
||
renderBoard();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Drag & Drop
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
function onCardDragStart (e, cardId, colId, li) {
|
||
dragCardId = cardId;
|
||
dragColumnId = colId;
|
||
li.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', cardId); // Firefox fix
|
||
}
|
||
|
||
function onCardDragEnd () {
|
||
dragCardId = null;
|
||
dragColumnId = null;
|
||
// Alle Highlights entfernen
|
||
document.querySelectorAll('.kanban-column.drag-over').forEach(c => c.classList.remove('drag-over'));
|
||
document.querySelectorAll('.kanban-card.dragging').forEach(c => c.classList.remove('dragging'));
|
||
document.querySelectorAll('.kanban-drop-placeholder').forEach(p => p.remove());
|
||
}
|
||
|
||
function onColumnDragEnter (e, colEl) {
|
||
e.preventDefault();
|
||
colEl.classList.add('drag-over');
|
||
}
|
||
|
||
function onColumnDragLeave (e, colEl) {
|
||
// Nur entfernen, wenn wirklich die Spalte verlassen wird (nicht ein Kind)
|
||
if (!colEl.contains(e.relatedTarget)) {
|
||
colEl.classList.remove('drag-over');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bestimmt, vor welchem Element die Karte eingesetzt werden soll.
|
||
* Gibt null zurück → ans Ende der Liste.
|
||
*/
|
||
function getDragAfterElement (cardList, clientY) {
|
||
const draggableEls = [...cardList.querySelectorAll('.kanban-card:not(.dragging)')];
|
||
let closest = { offset: Number.NEGATIVE_INFINITY, element: null };
|
||
for (const el of draggableEls) {
|
||
const box = el.getBoundingClientRect();
|
||
const offset = clientY - box.top - box.height / 2;
|
||
if (offset < 0 && offset > closest.offset) {
|
||
closest = { offset, element: el };
|
||
}
|
||
}
|
||
return closest.element;
|
||
}
|
||
|
||
function onCardListDragOver (e, cardList) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
|
||
// Placeholder bewegen
|
||
let ph = cardList.querySelector('.kanban-drop-placeholder');
|
||
if (!ph) {
|
||
ph = document.createElement('li');
|
||
ph.className = 'kanban-drop-placeholder';
|
||
ph.dataset.placeholder = 'true';
|
||
}
|
||
const after = getDragAfterElement(cardList, e.clientY);
|
||
if (after) cardList.insertBefore(ph, after);
|
||
else cardList.appendChild(ph);
|
||
}
|
||
|
||
function onCardListDrop (e, targetColId, cardList) {
|
||
e.preventDefault();
|
||
if (!dragCardId) return;
|
||
|
||
const targetCol = findColumn(targetColId);
|
||
const result = findCard(dragCardId);
|
||
if (!result || !targetCol) return;
|
||
|
||
const { card, column: sourceCol } = result;
|
||
|
||
// Aus Quell-Spalte entfernen
|
||
sourceCol.cards = sourceCol.cards.filter(c => c.id !== dragCardId);
|
||
|
||
// Einfügeposition bestimmen (anhand Placeholder)
|
||
const ph = cardList.querySelector('.kanban-drop-placeholder');
|
||
let insertIndex = targetCol.cards.length; // Default: ans Ende
|
||
|
||
if (ph) {
|
||
// Placeholder-Position im DOM → entsprechende Index im State
|
||
const siblings = [...cardList.querySelectorAll('.kanban-card')];
|
||
const phIndex = [...cardList.children].indexOf(ph);
|
||
// Karten vor dem Placeholder zählen
|
||
let count = 0;
|
||
for (const sib of cardList.children) {
|
||
if (sib === ph) break;
|
||
if (sib.classList.contains('kanban-card')) count++;
|
||
}
|
||
insertIndex = count;
|
||
}
|
||
|
||
targetCol.cards.splice(insertIndex, 0, card);
|
||
saveState();
|
||
renderBoard();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Label Picker
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
const labelPicker = document.getElementById('label-picker');
|
||
|
||
function openLabelPicker (e, cardId) {
|
||
labelTargetCardId = cardId;
|
||
labelPicker.removeAttribute('hidden');
|
||
|
||
// Positionieren
|
||
const x = Math.min(e.clientX, window.innerWidth - labelPicker.offsetWidth - 12);
|
||
const y = Math.min(e.clientY, window.innerHeight - labelPicker.offsetHeight - 12);
|
||
labelPicker.style.left = (x + window.scrollX) + 'px';
|
||
labelPicker.style.top = (y + window.scrollY) + 'px';
|
||
}
|
||
|
||
function closeLabelPicker () {
|
||
labelPicker.setAttribute('hidden', '');
|
||
labelTargetCardId = null;
|
||
}
|
||
|
||
labelPicker.querySelectorAll('.label-swatch').forEach(swatch => {
|
||
swatch.addEventListener('click', () => {
|
||
if (!labelTargetCardId) return;
|
||
const result = findCard(labelTargetCardId);
|
||
if (!result) return;
|
||
result.card.label = swatch.dataset.color;
|
||
saveState();
|
||
renderBoard();
|
||
closeLabelPicker();
|
||
});
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!labelPicker.hasAttribute('hidden') && !labelPicker.contains(e.target)) {
|
||
closeLabelPicker();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeLabelPicker();
|
||
});
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Toolbar-Aktionen
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
/* + Spalte */
|
||
document.getElementById('btn-add-column').addEventListener('click', addColumn);
|
||
|
||
/* Export JSON */
|
||
document.getElementById('btn-export').addEventListener('click', () => {
|
||
const json = JSON.stringify(state, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'kanban-board-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
/* Reset */
|
||
document.getElementById('btn-reset').addEventListener('click', () => {
|
||
if (!confirm('Board komplett zurücksetzen? Alle Daten gehen verloren!')) return;
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
state = JSON.parse(JSON.stringify(DEFAULT_BOARD));
|
||
// Neue IDs erzeugen damit UIDs nicht doppelt sind
|
||
state.columns.forEach(col => {
|
||
col.id = uid();
|
||
col.cards.forEach(card => { card.id = uid(); });
|
||
});
|
||
saveState();
|
||
renderBoard();
|
||
});
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
Init
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
renderBoard();
|
||
|
||
})();
|