eis-website/js/kanban.js

503 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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