/** * 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(); })();