Mega-Bonus: Kanban-Board – Drag & Drop, Labels, localStorage, JSON-Export
This commit is contained in:
parent
d2b2132605
commit
dd7c36a14d
13 changed files with 998 additions and 1 deletions
4
api.html
4
api.html
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html" class="active">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -132,7 +133,8 @@ fetch('https://jsonplaceholder.typicode.com/users')
|
|||
<p>© 2026 – Made with 💕 & ✨ – David & Karo</p>
|
||||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
390
css/style.css
390
css/style.css
|
|
@ -3187,3 +3187,393 @@ body.dark .api-user-details a { color: #f9a8d4; }
|
|||
#api-search { flex: unset; width: 100%; }
|
||||
.user-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Kanban-Board – kanban.html
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Haupt-Layout */
|
||||
.kanban-main {
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Header-Leiste */
|
||||
.kanban-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.kanban-title h1 {
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
.kanban-title p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.kanban-toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-kanban {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.12s;
|
||||
background: linear-gradient(135deg, #ec4899, #be185d);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-kanban:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
.btn-kanban:active { transform: translateY(0); }
|
||||
|
||||
.btn-kanban.btn-secondary {
|
||||
background: linear-gradient(135deg, #a78bfa, #7c3aed);
|
||||
}
|
||||
|
||||
.btn-kanban.btn-danger {
|
||||
background: linear-gradient(135deg, #f87171, #dc2626);
|
||||
}
|
||||
|
||||
/* Board (horizontaler Scroll) */
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1rem;
|
||||
min-height: 60vh;
|
||||
/* custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #f9a8d4 #fce7f3;
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar { height: 8px; }
|
||||
.kanban-board::-webkit-scrollbar-track { background: #fce7f3; border-radius: 4px; }
|
||||
.kanban-board::-webkit-scrollbar-thumb { background: #f9a8d4; border-radius: 4px; }
|
||||
|
||||
/* Einzelne Spalte */
|
||||
.kanban-column {
|
||||
background: #fdf2f8;
|
||||
border: 2px solid #f9a8d4;
|
||||
border-radius: 16px;
|
||||
width: 280px;
|
||||
min-width: 260px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.kanban-column.drag-over {
|
||||
border-color: #ec4899;
|
||||
background: #fce7f3;
|
||||
box-shadow: 0 0 0 3px rgba(236,72,153,0.25);
|
||||
}
|
||||
|
||||
/* Spalten-Header */
|
||||
.kanban-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.85rem 0.85rem 0.6rem;
|
||||
border-bottom: 1px solid #f9a8d4;
|
||||
}
|
||||
|
||||
.kanban-col-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #be185d;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.kanban-col-title[contenteditable="true"] {
|
||||
cursor: text;
|
||||
background: #fff;
|
||||
outline: 2px solid #ec4899;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.kanban-col-badge {
|
||||
background: #ec4899;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kanban-col-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kanban-col-delete:hover { color: #dc2626; background: #fee2e2; }
|
||||
|
||||
/* Karten-Liste */
|
||||
.kanban-card-list {
|
||||
list-style: none;
|
||||
padding: 0.6rem;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
/* Drop-Placeholder */
|
||||
.kanban-drop-placeholder {
|
||||
height: 56px;
|
||||
border: 2px dashed #f9a8d4;
|
||||
border-radius: 10px;
|
||||
background: rgba(249,168,212,0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Karte */
|
||||
.kanban-card {
|
||||
background: #fff;
|
||||
border: 1px solid #fce7f3;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(236,72,153,0.08);
|
||||
cursor: grab;
|
||||
transition: box-shadow 0.2s, transform 0.15s, opacity 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
box-shadow: 0 5px 15px rgba(236,72,153,0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.kanban-card.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
transform: rotate(2deg) scale(1.02);
|
||||
}
|
||||
|
||||
/* Label-Streifen oben */
|
||||
.kanban-card-label {
|
||||
height: 6px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Karten-Text (contenteditable) */
|
||||
.kanban-card-text {
|
||||
padding: 0.6rem 2rem 0.6rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
outline: none;
|
||||
min-height: 2.2rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.kanban-card-text:focus {
|
||||
background: #fff9fc;
|
||||
}
|
||||
|
||||
/* Löschen-Button */
|
||||
.kanban-card-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kanban-card:hover .kanban-card-delete,
|
||||
.kanban-card-delete:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kanban-card-delete:hover { color: #dc2626; background: #fee2e2; }
|
||||
|
||||
/* Spalten-Footer */
|
||||
.kanban-col-footer {
|
||||
padding: 0.5rem 0.6rem 0.7rem;
|
||||
}
|
||||
|
||||
.btn-add-card {
|
||||
width: 100%;
|
||||
padding: 0.45rem;
|
||||
background: none;
|
||||
border: 2px dashed #f9a8d4;
|
||||
border-radius: 8px;
|
||||
color: #be185d;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-add-card:hover {
|
||||
background: #fce7f3;
|
||||
border-color: #ec4899;
|
||||
}
|
||||
|
||||
/* ── Label Picker (Kontext-Menü) ── */
|
||||
.label-picker {
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
background: #fff;
|
||||
border: 1px solid #f9a8d4;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.label-picker[hidden] { display: none; }
|
||||
|
||||
.label-picker-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.label-colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.label-swatch {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.12s, border-color 0.12s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.label-swatch:hover { transform: scale(1.2); border-color: rgba(0,0,0,0.3); }
|
||||
|
||||
/* ── Dark Mode ── */
|
||||
body.dark .kanban-column {
|
||||
background: #1a1a2e;
|
||||
border-color: #7c3aed55;
|
||||
}
|
||||
|
||||
body.dark .kanban-column.drag-over {
|
||||
background: #1e1b3a;
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 0 0 3px rgba(167,139,250,0.25);
|
||||
}
|
||||
|
||||
body.dark .kanban-col-header {
|
||||
border-bottom-color: #7c3aed44;
|
||||
}
|
||||
|
||||
body.dark .kanban-col-title { color: #f9a8d4; }
|
||||
body.dark .kanban-col-delete:hover { background: #3d1515; }
|
||||
|
||||
body.dark .kanban-card {
|
||||
background: #16213e;
|
||||
border-color: #7c3aed33;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
body.dark .kanban-card:hover {
|
||||
box-shadow: 0 5px 15px rgba(167,139,250,0.2);
|
||||
}
|
||||
|
||||
body.dark .kanban-card-text { color: #e0e0e0; }
|
||||
body.dark .kanban-card-text:focus { background: #1c1c2e; }
|
||||
|
||||
body.dark .kanban-drop-placeholder {
|
||||
border-color: #7c3aed88;
|
||||
background: rgba(167,139,250,0.08);
|
||||
}
|
||||
|
||||
body.dark .btn-add-card {
|
||||
border-color: #7c3aed55;
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
body.dark .btn-add-card:hover {
|
||||
background: #1e1b3a;
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
body.dark .label-picker {
|
||||
background: #1e1e2e;
|
||||
border-color: #7c3aed55;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
body.dark .label-picker-title { color: #f9a8d4; }
|
||||
|
||||
body.dark .kanban-title p { color: #777; }
|
||||
|
||||
body.dark .kanban-board {
|
||||
scrollbar-color: #7c3aed #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.kanban-header { flex-direction: column; }
|
||||
.kanban-toolbar { width: 100%; }
|
||||
.btn-kanban { flex: 1; text-align: center; }
|
||||
.kanban-column { width: 260px; min-width: 240px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -177,6 +178,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html" class="active">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -119,6 +120,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
503
js/kanban.js
Normal file
503
js/kanban.js
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
/**
|
||||
* 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();
|
||||
|
||||
})();
|
||||
88
kanban.html
Normal file
88
kanban.html
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Kanban-Board – Drag & Drop – David & Karo EIS Projekt">
|
||||
<title>Kanban-Board – David & Karo</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="icon" href="img/logo.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-container">
|
||||
<div class="logo">
|
||||
<img src="img/logo.png" alt="Logo">
|
||||
<div class="logo-text">
|
||||
<span class="institution">David & Karo</span>
|
||||
<span class="project">EIS Projekt</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="dark-mode-toggle" type="button" class="dark-mode-toggle" title="Dark Mode aktivieren">
|
||||
<span class="dark-mode-icon">🌙</span>
|
||||
</button>
|
||||
<button id="nav-toggle" type="button" class="nav-toggle" aria-label="Menü öffnen" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<nav id="main-nav">
|
||||
<a href="index.html">Start</a>
|
||||
<a href="ueber_uns.html">Über uns</a>
|
||||
<a href="eis_projekt.html">Projekt</a>
|
||||
<a href="team.html">Team</a>
|
||||
<a href="galerie.html">Galerie</a>
|
||||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html" class="active">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="kanban-main">
|
||||
<div class="kanban-header">
|
||||
<div class="kanban-title">
|
||||
<h1>📋 Kanban-Board</h1>
|
||||
<p>Drag & Drop · localStorage · Labels</p>
|
||||
</div>
|
||||
<div class="kanban-toolbar">
|
||||
<button type="button" id="btn-add-column" class="btn btn-kanban">➕ Spalte</button>
|
||||
<button type="button" id="btn-export" class="btn btn-kanban btn-secondary">⬇️ Export JSON</button>
|
||||
<button type="button" id="btn-reset" class="btn btn-kanban btn-danger">🗑️ Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farb-Label Picker (Kontext-Menü) -->
|
||||
<div id="label-picker" class="label-picker" hidden role="menu" aria-label="Label-Farbe wählen">
|
||||
<p class="label-picker-title">🏷️ Label-Farbe</p>
|
||||
<div class="label-colors">
|
||||
<button type="button" class="label-swatch" data-color="none" style="background:#e2e8f0" title="Kein Label">✕</button>
|
||||
<button type="button" class="label-swatch" data-color="#f87171" style="background:#f87171" title="Rot"></button>
|
||||
<button type="button" class="label-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
|
||||
<button type="button" class="label-swatch" data-color="#facc15" style="background:#facc15" title="Gelb"></button>
|
||||
<button type="button" class="label-swatch" data-color="#4ade80" style="background:#4ade80" title="Grün"></button>
|
||||
<button type="button" class="label-swatch" data-color="#60a5fa" style="background:#60a5fa" title="Blau"></button>
|
||||
<button type="button" class="label-swatch" data-color="#a78bfa" style="background:#a78bfa" title="Lila"></button>
|
||||
<button type="button" class="label-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
|
||||
<button type="button" class="label-swatch" data-color="#2dd4bf" style="background:#2dd4bf" title="Türkis"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Das Board selbst -->
|
||||
<div id="kanban-board" class="kanban-board"></div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 – Made with 💕 & ✨ – David & Karo</p>
|
||||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/script.js"></script>
|
||||
<script src="js/kanban.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -128,6 +128,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html" class="active">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html" class="active">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
<a href="notenrechner.html">Notenrechner</a>
|
||||
<a href="textanalyse.html">Textanalyse</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="impressum.html">Impressum</a>
|
||||
</nav>
|
||||
|
|
@ -110,6 +111,7 @@
|
|||
<div class="footer-links">
|
||||
<a href="impressum.html">Impressum</a>
|
||||
<a href="api.html">API Demo</a>
|
||||
<a href="kanban.html">Kanban</a>
|
||||
<a href="kontakt.html">Kontakt</a>
|
||||
<a href="#">Datenschutz</a>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue