iobroker.kanban 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/admin/i18n/de.json +100 -0
  4. package/admin/i18n/en.json +100 -0
  5. package/admin/i18n/es.json +100 -0
  6. package/admin/i18n/fr.json +100 -0
  7. package/admin/i18n/it.json +100 -0
  8. package/admin/i18n/nl.json +100 -0
  9. package/admin/i18n/pl.json +100 -0
  10. package/admin/i18n/pt.json +100 -0
  11. package/admin/i18n/ru.json +100 -0
  12. package/admin/i18n/uk.json +100 -0
  13. package/admin/i18n/zh-cn.json +100 -0
  14. package/admin/jsonConfig.json +668 -0
  15. package/admin/kanban.png +0 -0
  16. package/io-package.json +299 -0
  17. package/lib/cron.js +203 -0
  18. package/lib/dueat.js +77 -0
  19. package/lib/events.js +24 -0
  20. package/lib/holidays.js +167 -0
  21. package/lib/i18n-server.js +301 -0
  22. package/lib/notify.js +627 -0
  23. package/lib/scheduler.js +198 -0
  24. package/lib/server.js +799 -0
  25. package/lib/store.js +1500 -0
  26. package/main.js +382 -0
  27. package/package.json +58 -0
  28. package/www/css/app.css +1022 -0
  29. package/www/i18n/de.json +238 -0
  30. package/www/i18n/en.json +238 -0
  31. package/www/i18n/es.json +238 -0
  32. package/www/i18n/fr.json +238 -0
  33. package/www/i18n/it.json +238 -0
  34. package/www/i18n/nl.json +238 -0
  35. package/www/i18n/pl.json +238 -0
  36. package/www/i18n/pt.json +238 -0
  37. package/www/i18n/ru.json +238 -0
  38. package/www/i18n/uk.json +238 -0
  39. package/www/i18n/zh-cn.json +238 -0
  40. package/www/index.html +171 -0
  41. package/www/js/api.js +98 -0
  42. package/www/js/app.js +411 -0
  43. package/www/js/board.js +983 -0
  44. package/www/js/colorpicker.js +165 -0
  45. package/www/js/dialogs.js +1419 -0
  46. package/www/js/i18n.js +43 -0
  47. package/www/vendor/marked.min.js +69 -0
  48. package/www/vendor/purify.min.js +3 -0
  49. package/www/vendor/sortable.min.js +2 -0
@@ -0,0 +1,983 @@
1
+ // Board-Rendering + SortableJS-Drag&Drop
2
+
3
+ import { t, currentLang } from './i18n.js';
4
+ import { api } from './api.js';
5
+
6
+ function el(tag, cls, text) {
7
+ const e = document.createElement(tag);
8
+ if (cls) e.className = cls;
9
+ if (text !== undefined) e.textContent = text;
10
+ return e;
11
+ }
12
+
13
+ // MDI-Icons (offizielle Pfaddaten) als Inline-SVG, faerbt sich per currentColor
14
+ const MDI_EYE = 'M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z';
15
+ const MDI_EYE_CLOSED = 'M12 17.5C8.2 17.5 4.8 15.4 3.2 12H1C2.7 16.4 7 19.5 12 19.5S21.3 16.4 23 12H20.8C19.2 15.4 15.8 17.5 12 17.5Z';
16
+ const MDI_NOTE = 'M15 3H5A2 2 0 0 0 3 5V19A2 2 0 0 0 5 21H19A2 2 0 0 0 21 19V9L15 3M19 19H5V5H14V10H19M17 14H7V12H17M14 17H7V15H14';
17
+ const MDI = {
18
+ calendar: 'M19,19H5V8H19M16,1V3H8V1H6V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H18V1M17,12H12V17H17V12Z',
19
+ check: 'M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z',
20
+ sync: 'M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z',
21
+ mapMarker: 'M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z',
22
+ email: 'M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C22,4.89 21.1,4 20,4Z',
23
+ phone: 'M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z',
24
+ youtube: 'M10,15L15.19,12L10,9V15M21.56,7.17C21.69,7.64 21.78,8.27 21.84,9.07C21.91,9.87 21.94,10.56 21.94,11.16L22,12C22,14.19 21.84,15.8 21.56,16.83C21.31,17.73 20.73,18.31 19.83,18.56C19.36,18.69 18.5,18.78 17.18,18.84C15.88,18.91 14.69,18.94 13.59,18.94L12,19C7.81,19 5.2,18.84 4.17,18.56C3.27,18.31 2.69,17.73 2.44,16.83C2.31,16.36 2.22,15.73 2.16,14.93C2.09,14.13 2.06,13.44 2.06,12.84L2,12C2,9.81 2.16,8.2 2.44,7.17C2.69,6.27 3.27,5.69 4.17,5.44C4.64,5.31 5.5,5.22 6.82,5.16C8.12,5.09 9.31,5.06 10.41,5.06L12,5C16.19,5 18.8,5.16 19.83,5.44C20.73,5.69 21.31,6.27 21.56,7.17Z',
25
+ pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z',
26
+ image: 'M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z',
27
+ navigation: 'M21 3L3 10.53V11.5L9.84 14.16L12.5 21H13.46L21 3Z',
28
+ link: 'M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z',
29
+ web: 'M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z',
30
+ chevronUp: 'M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z',
31
+ chevronDown: 'M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z',
32
+ chevronRight: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z',
33
+ plus: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z',
34
+ // Sortiermodi (Feature 1)
35
+ sortManual: 'M10,9A1,1 0 0,1 11,8A1,1 0 0,1 12,9V13.47L13.21,13.6L18.15,15.79C18.68,16.03 19,16.56 19,17.14V21.5C18.97,22.32 18.32,22.97 17.5,23H11C10.62,23 10.26,22.85 10,22.57L5.1,18.37L5.84,17.6C6.03,17.39 6.3,17.28 6.58,17.28H6.8L10,19V9M11,5A4,4 0 0,1 15,9C15,10.5 14.2,11.77 13,12.46V11.24C13.61,10.69 14,9.89 14,9A3,3 0 0,0 11,6A3,3 0 0,0 8,9C8,9.89 8.39,10.69 9,11.24V12.46C7.8,11.77 7,10.5 7,9A4,4 0 0,1 11,5Z',
36
+ sortGrid: 'M9,3H11V5H9V3M13,3H15V5H13V3M9,7H11V9H9V7M13,7H15V9H13V7M9,11H11V13H9V11M13,11H15V13H13V11M9,15H11V17H9V15M13,15H15V17H13V15M9,19H11V21H9V19M13,19H15V21H13V19Z',
37
+ // Faelligkeit = Kalender, Alter in Spalte = Uhr (Pfeile bleiben dem Richtungsumschalter vorbehalten)
38
+ sortDue: 'M19,19H5V8H19M16,1V3H8V1H6V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H18V1M17,12H12V17H17V12Z',
39
+ sortPrio: 'M14.4,6L14,4H5V21H7V14H12.6L13,16H20V6H14.4Z',
40
+ sortAge: 'M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z',
41
+ // Richtungsumschalter im Spaltenkopf
42
+ dirDown: 'M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z',
43
+ dirUp: 'M19 17H22L18 21L14 17H17V3H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z',
44
+ // Papierkorb-Aktionen (Feature 5)
45
+ restore: 'M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z',
46
+ deleteForever: 'M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8.46,11.88L9.87,10.47L12,12.59L14.12,10.47L15.53,11.88L13.41,14L15.53,16.12L14.12,17.53L12,15.41L9.88,17.53L8.47,16.12L10.59,14L8.46,11.88M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z',
47
+ broom: 'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
48
+ // Karten-Editor / Übertragen (Feature 3+6); contentCopy = mdi:file-restore-outline
49
+ contentCopy: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13L18 9V20M17 13.24C17 15.86 14.87 18 12.24 18C10.29 18 8.61 16.82 7.88 15.14H9.5C10.11 16 11.11 16.57 12.24 16.57C14.08 16.57 15.57 15.07 15.57 13.24S14.08 9.9 12.24 9.9C10.95 9.9 9.86 10.65 9.29 11.71L10.81 13.24H7V9.43L8.24 10.67C9.09 9.35 10.55 8.5 12.24 8.5C14.87 8.47 17 10.61 17 13.24Z',
50
+ transfer: 'M8 4A2 2 0 0 0 6 6V10H8V6H16V9H13.5L17 12.5L20.5 9H18V6A2 2 0 0 0 16 4H8M3 12V14H11V12H3M3 15V17H11V15H3M13 15V17H21V15H13M3 18V20H11V18H3M13 18V20H21V18H13Z',
51
+ arrowRightCircle: 'M22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12M6,13H14L10.5,16.5L11.92,17.92L17.84,12L11.92,6.08L10.5,7.5L14,11H6V13Z',
52
+ // Editor oeffnen (mdi:invoice-text-edit-outline) - sitzt hinter dem Kartentitel
53
+ invoiceTextEdit: 'M9.86 21.43L9 22L6 20L3 22V3H21V10.2C20.37 9.93 19.64 9.93 19 10.22V5H5V18.26L6 17.6L9 19.6L9.86 19V21.43M11.86 19.96L18 13.83L20.03 15.87L13.9 22H11.86V19.96M21.71 14.19L20.73 15.17L18.69 13.13L19.67 12.15L19.68 12.14L19.69 12.13C19.86 11.97 20.12 11.96 20.31 12.09C20.34 12.1 20.37 12.13 20.39 12.15L21.71 13.47C21.91 13.67 21.91 14 21.71 14.19M17 9V7H7V9H17M15 13V11H7V13H15Z',
54
+ };
55
+
56
+ export { MDI };
57
+
58
+
59
+ export function mdiIcon(pathData) {
60
+ const NS = 'http://www.w3.org/2000/svg';
61
+ const svg = document.createElementNS(NS, 'svg');
62
+ svg.setAttribute('viewBox', '0 0 24 24');
63
+ svg.setAttribute('width', '18');
64
+ svg.setAttribute('height', '18');
65
+ svg.setAttribute('aria-hidden', 'true');
66
+ const path = document.createElementNS(NS, 'path');
67
+ path.setAttribute('d', pathData);
68
+ path.setAttribute('fill', 'currentColor');
69
+ svg.appendChild(path);
70
+ return svg;
71
+ }
72
+
73
+ // ---- Kontextmenue waehrend des Ziehens unterdruecken ---------------------
74
+ // Ziehen heisst auf dem Touchscreen aufliegen und halten — fuer Android ist das
75
+ // zugleich ein langer Druck. Liegt der Finger dabei auf einem Symbol
76
+ // (Prioritaet, Faelligkeit und Ort sind SVGs) oder einem Link, oeffnet die
77
+ // WebView ihr Kontextmenue, schickt pointercancel und der Zug ist vorbei.
78
+ // Gemessen auf einem Galaxy Tab S5e: contextmenu nach 251 ms, pointercancel
79
+ // nach 749 ms, danach lag die Karte wieder an ihrem Platz. Auf dem Titel
80
+ // passierte das nicht — deshalb ging es mal und mal nicht, je nachdem wo man
81
+ // die Karte angefasst hat.
82
+ let dragActive = false;
83
+ let ctxGuardReady = false;
84
+
85
+ function initCtxGuard() {
86
+ if (ctxGuardReady || typeof document === 'undefined') return;
87
+ ctxGuardReady = true;
88
+ document.addEventListener('contextmenu', ev => {
89
+ const onCard = ev.target && ev.target.closest && ev.target.closest('.card');
90
+ if (onCard || dragActive) ev.preventDefault();
91
+ }, true);
92
+ }
93
+
94
+ // ---- Schnellablage-Ziele beim Ziehen (v.a. schmale Screens) --------------
95
+ // Beim Aufnehmen einer Karte erscheinen direkt darunter die übrigen Spalten
96
+ // als Drop-Zonen mit gestricheltem Rand, damit man ohne Quer-Scrollen ablegen
97
+ // kann. Jede Zone ist eine eigene SortableJS-Liste (group 'cards').
98
+ let quickMoveEl = null;
99
+ let quickTarget = null; // Landezone unter dem Zeiger (mobiles Verschieben)
100
+ // Karten-IDs mit aufgeklappter Checkliste. Bleibt über Re-Renders erhalten und
101
+ // wird je Board im localStorage gemerkt - genau wie die Sortierung der Spalten.
102
+ const checkExpanded = new Set();
103
+ let checkExpandedBoard = null;
104
+
105
+ function checkExpandedKey(boardId) { return 'kanban.checkExpanded.' + boardId; }
106
+
107
+ /** Merkliste auf das gerade angezeigte Board umstellen (nur beim Boardwechsel). */
108
+ function loadCheckExpanded(boardId) {
109
+ if (checkExpandedBoard === boardId) return;
110
+ checkExpandedBoard = boardId;
111
+ checkExpanded.clear();
112
+ try {
113
+ const raw = JSON.parse(localStorage.getItem(checkExpandedKey(boardId)) || '[]');
114
+ if (Array.isArray(raw)) for (const id of raw) checkExpanded.add(String(id));
115
+ } catch (e) { /* ignore */ }
116
+ }
117
+
118
+ function saveCheckExpanded() {
119
+ if (!checkExpandedBoard) return;
120
+ try { localStorage.setItem(checkExpandedKey(checkExpandedBoard), JSON.stringify([...checkExpanded])); } catch (e) { /* ignore */ }
121
+ }
122
+
123
+ /**
124
+ * Landezonen beim Ziehen anbieten?
125
+ *
126
+ * Nur auf schmalen Schirmen, wo die Spalten untereinander stehen und ein Ziel
127
+ * sonst ausserhalb des Bildes laege. Auf grossen Schirmen liegen die Spalten
128
+ * nebeneinander und lassen sich direkt anziehen — auch mit dem Finger, seit das
129
+ * Kontextmenue der WebView den Zug nicht mehr abbricht (siehe initDragTrace).
130
+ * Dort waeren die Zonen nur im Weg.
131
+ */
132
+ function wantsQuickMove() {
133
+ return window.matchMedia('(max-width: 820px)').matches;
134
+ }
135
+
136
+ function buildQuickMove(evt, sourceCol, board) {
137
+ removeQuickMove();
138
+ const others = board.columns.filter(c => c.id !== sourceCol.id);
139
+ if (!others.length) return;
140
+
141
+ // Waagerechte Leiste am oberen Rand: Sie verdeckt die aufgenommene Karte
142
+ // nicht (die liegt darunter), der Weg dorthin ist kurz, und die Zonen stehen
143
+ // in derselben Reihenfolge wie die Spalten. Bei vielen Spalten bricht die
144
+ // Leiste dank flex-wrap in eine zweite Reihe um, statt aus dem Bild zu laufen.
145
+ const bar = el('div', 'quick-move');
146
+ bar.style.left = '8px';
147
+ bar.style.right = '8px';
148
+ bar.style.top = '8px';
149
+
150
+ for (const c of others) {
151
+ const qt = el('div', 'quick-target' + (c.isTrash ? ' quick-trash' : ''));
152
+ // Der Name liegt in einer eigenen Ebene ueber der Zone, damit ihn nichts
153
+ // verschieben kann.
154
+ qt.appendChild(el('span', 'quick-label', c.isTrash ? t('col.trash') : c.title));
155
+ qt.dataset.colId = c.id;
156
+ bar.appendChild(qt);
157
+ }
158
+ document.body.appendChild(bar);
159
+
160
+ // Die Zonen werten den Zeiger selbst aus, statt eigene Sortable-Container zu
161
+ // sein: auf Touch erkennt SortableJS diese kleinen Ziele nicht zuverlaessig.
162
+ document.addEventListener('pointermove', onQuickPointer, true);
163
+ document.addEventListener('touchmove', onQuickPointer, { capture: true, passive: true });
164
+ quickMoveEl = bar;
165
+ }
166
+
167
+ // Zone unter dem Zeiger merken und hervorheben
168
+ function onQuickPointer(ev) {
169
+ if (!quickMoveEl) return;
170
+ const p = ev.touches && ev.touches.length ? ev.touches[0] : ev;
171
+ if (p.clientX === undefined) return;
172
+ const hit = document.elementFromPoint(p.clientX, p.clientY);
173
+ const zone = hit && hit.closest ? hit.closest('.quick-target') : null;
174
+ quickTarget = zone && quickMoveEl.contains(zone) ? zone : null;
175
+ for (const z of quickMoveEl.children) z.classList.toggle('sortable-over', z === quickTarget);
176
+ }
177
+
178
+ function removeQuickMove() {
179
+ document.removeEventListener('pointermove', onQuickPointer, true);
180
+ document.removeEventListener('touchmove', onQuickPointer, { capture: true });
181
+ if (quickMoveEl) { quickMoveEl.remove(); quickMoveEl = null; }
182
+ quickTarget = null;
183
+ }
184
+
185
+ function initials(name) {
186
+ return String(name || '?').split(/[\s_-]+/).map(p => p[0] || '').join('').slice(0, 2).toUpperCase();
187
+ }
188
+
189
+ export function userAvatar(state, name) {
190
+ const u = state.users.find(x => x.name === name);
191
+ const label = u ? u.displayName : name;
192
+ if (u && u.avatar) {
193
+ const img = el('img', 'avatar avatar-img');
194
+ img.src = `avatars/${encodeURIComponent(u.name)}?v=${state.avatarVer || 0}`;
195
+ img.alt = label;
196
+ img.title = label;
197
+ if (u.color) img.style.setProperty('--uc', u.color);
198
+ return img;
199
+ }
200
+ const a = el('span', 'avatar', initials(label));
201
+ a.style.background = (u && u.color) || '#888';
202
+ a.style.color = contrastText((u && u.color) || '#888');
203
+ if (u && u.color) a.style.setProperty('--uc', u.color);
204
+ a.title = label;
205
+ return a;
206
+ }
207
+
208
+ function todayStr(offsetDays = 0) {
209
+ const d = new Date();
210
+ d.setDate(d.getDate() + offsetDays);
211
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
212
+ }
213
+
214
+ function fmtDate(iso, fmt) {
215
+ const [Y, M, D] = String(iso).split('-');
216
+ if (!Y || !M || !D) return String(iso);
217
+ const dt = new Date(+Y, +M - 1, +D);
218
+ const loc = currentLang() || 'en';
219
+ const nm = (opt) => { try { return dt.toLocaleDateString(loc, opt); } catch (e) { return dt.toLocaleDateString('en', opt); } };
220
+ const map = {
221
+ YYYY: Y, YY: Y.slice(-2),
222
+ MMMM: nm({ month: 'long' }), MMM: nm({ month: 'short' }), MM: M, M: String(+M),
223
+ DD: D, D: String(+D),
224
+ dddd: nm({ weekday: 'long' }), ddd: nm({ weekday: 'short' }),
225
+ };
226
+ // Moment/Day.js-Tokens, laengste Alternative zuerst; .replace scannt Ersetztes nicht erneut
227
+ return String(fmt || 'DD.MM.').replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd/g, (tok) => map[tok]);
228
+ }
229
+ function fmtTime(hhmm, fmt) {
230
+ if (fmt !== '12h') return String(hhmm);
231
+ const parts = String(hhmm).split(':'); let h = +parts[0]; const m = parts[1] || '00';
232
+ const ap = h < 12 ? 'AM' : 'PM'; h = h % 12 || 12;
233
+ return `${h}:${m} ${ap}`;
234
+ }
235
+ function p2(n) { return String(n).padStart(2, '0'); }
236
+ // ISO-Zeitstempel (z.B. doneAt/trashedAt) in lokale Datum-/Uhrzeit-Teile zerlegen.
237
+ function isoLocalParts(iso) {
238
+ const d = new Date(iso);
239
+ if (isNaN(d.getTime())) return null;
240
+ return { D: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`, T: `${p2(d.getHours())}:${p2(d.getMinutes())}` };
241
+ }
242
+
243
+ // ---- Sortiermodi je Spalte (Feature 1) ----
244
+ const SORT_MODES = ['manual', 'grid', 'due', 'priority', 'age'];
245
+ const AUTO_SORT_MODES = ['due', 'priority', 'age']; // eigenes Umsortieren wirkungslos
246
+ function sortModeIcon(mode) {
247
+ return ({
248
+ manual: MDI.sortManual, grid: MDI.sortGrid, due: MDI.sortDue,
249
+ priority: MDI.sortPrio, age: MDI.sortAge,
250
+ })[mode] || MDI.sortManual;
251
+ }
252
+ function cardDueKey(c) { return c.due ? c.due + 'T' + (c.dueTime || '00:00') : ''; }
253
+ function byTitle(a, b) { return String(a.title || '').localeCompare(String(b.title || '')); }
254
+ // rev dreht immer nur das Hauptkriterium um. Karten ohne Wert bleiben unten,
255
+ // Gleichstand entscheidet weiterhin der Titel.
256
+ function cmpDue(a, b, rev) {
257
+ const ka = cardDueKey(a), kb = cardDueKey(b);
258
+ if (!ka && !kb) return byTitle(a, b);
259
+ if (!ka) return 1;
260
+ if (!kb) return -1;
261
+ if (ka !== kb) return (rev ? (ka > kb) : (ka < kb)) ? -1 : 1;
262
+ return byTitle(a, b);
263
+ }
264
+ function cmpPrio(a, b, rev) {
265
+ const pa = Number(a.priority) || 0, pb = Number(b.priority) || 0;
266
+ if (pa !== pb) return rev ? pa - pb : pb - pa; // Standard: höhere Priorität zuerst
267
+ return cmpDue(a, b, false);
268
+ }
269
+ // Wann kam die Karte in diese Spalte? movedAt wird nur bei echtem Spaltenwechsel
270
+ // gesetzt; nie verschobene Karten liegen seit ihrer Erstellung dort. In einer
271
+ // Erledigt-Spalte ist das also der Zeitpunkt des Erledigens.
272
+ function inColumnSince(c) { return c.movedAt || c.createdAt || ''; }
273
+ // Standard: zuletzt hinzugekommene Karte oben (in Erledigt also das zuletzt
274
+ // Erledigte). rev dreht auf aelteste zuerst = laengste Liegezeit oben.
275
+ function cmpAge(a, b, rev) {
276
+ const ka = inColumnSince(a), kb = inColumnSince(b);
277
+ if (!ka && !kb) return byTitle(a, b);
278
+ if (!ka) return 1; // ohne Zeitstempel nach unten
279
+ if (!kb) return -1;
280
+ if (ka !== kb) return (rev ? (ka < kb) : (ka > kb)) ? -1 : 1;
281
+ return byTitle(a, b);
282
+ }
283
+ function applySort(cards, mode, rev) {
284
+ if (mode === 'due') return cards.slice().sort((a, b) => cmpDue(a, b, rev));
285
+ if (mode === 'priority') return cards.slice().sort((a, b) => cmpPrio(a, b, rev));
286
+ if (mode === 'age') return cards.slice().sort((a, b) => cmpAge(a, b, rev));
287
+ return cards.slice().sort((a, b) => a.order - b.order); // manual + grid: eigene Reihenfolge
288
+ }
289
+ function colSortKey(board, col) { return `${board.id}:${col.id}`; }
290
+ // Gespeichert wird "<modus>" oder "<modus>:rev" (umgekehrte Richtung).
291
+ function getSortMode(state, board, col) {
292
+ const raw = String((state.sortModes && state.sortModes[colSortKey(board, col)]) || '');
293
+ const [m, flag] = raw.split(':');
294
+ return SORT_MODES.includes(m) ? { mode: m, rev: flag === 'rev' } : { mode: 'manual', rev: false };
295
+ }
296
+ function trashDaysLeft(card, retention) {
297
+ const ts = Date.parse(card.trashedAt || '');
298
+ if (!ts) return retention;
299
+ // Auf [0, retention] begrenzen: geht die Serveruhr minimal vor, ergaebe das
300
+ // Aufrunden sonst retention+1 Tage.
301
+ const left = Math.ceil((ts + retention * 86400000 - Date.now()) / 86400000);
302
+ return Math.max(0, Math.min(retention, left));
303
+ }
304
+
305
+ // Kontextmenü des Sortier-Umschalters
306
+ let sortMenuEl = null;
307
+ function closeSortMenu() {
308
+ if (sortMenuEl) { sortMenuEl.remove(); sortMenuEl = null; document.removeEventListener('click', onDocClickSort, true); }
309
+ }
310
+ function onDocClickSort(e) { if (sortMenuEl && !sortMenuEl.contains(e.target)) closeSortMenu(); }
311
+ function openSortMenu(btn, state, board, col, actions) {
312
+ closeSortMenu();
313
+ const menu = el('div', 'sort-menu');
314
+ menu.appendChild(el('div', 'sort-menu-title', t('sort.mode')));
315
+ const cur = getSortMode(state, board, col).mode;
316
+ const labels = { manual: t('sort.manual'), grid: t('sort.grid'), due: t('sort.due'), priority: t('sort.priority'), age: t('sort.age') };
317
+ for (const mode of SORT_MODES) {
318
+ const item = el('button', 'sort-item' + (mode === cur ? ' active' : ''));
319
+ item.type = 'button';
320
+ item.appendChild(mdiIcon(sortModeIcon(mode)));
321
+ item.appendChild(el('span', null, labels[mode]));
322
+ // Moduswechsel startet immer in der Standardrichtung
323
+ item.addEventListener('click', (e) => { e.stopPropagation(); closeSortMenu(); actions.setSortMode(colSortKey(board, col), mode); });
324
+ menu.appendChild(item);
325
+ }
326
+ document.body.appendChild(menu);
327
+ const r = btn.getBoundingClientRect();
328
+ menu.style.top = (r.bottom + 4) + 'px';
329
+ menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - menu.offsetWidth - 4)) + 'px';
330
+ sortMenuEl = menu;
331
+ setTimeout(() => document.addEventListener('click', onDocClickSort, true), 0);
332
+ }
333
+
334
+ function dueBadge(due, dueTime, done, cfg) {
335
+ const b = el('span', 'badge date-badge');
336
+ b.appendChild(mdiIcon(MDI.calendar));
337
+ b.appendChild(document.createTextNode(' ' + fmtDate(due, cfg && cfg.dateFormat) + (dueTime ? ' ' + fmtTime(dueTime, cfg && cfg.timeFormat) : '')));
338
+ if (done) b.classList.add('due-done'); // erledigt → grün, keine Überfällig-Warnung
339
+ else if (due < todayStr()) b.classList.add('due-overdue');
340
+ else if (due <= todayStr(1)) b.classList.add('due-soon');
341
+ return b;
342
+ }
343
+
344
+ /** Icon je Linkart (Muster: Link-Button der Lovelace-ToDo-Karte,
345
+ * hier mit typabhängigen Icons im Emoji-Stil der App) */
346
+ function linkIcon(url) {
347
+ const u = String(url || '').toLowerCase();
348
+ if (u.startsWith('mailto:')) return MDI.email;
349
+ if (u.startsWith('tel:')) return MDI.phone;
350
+ if (/youtube\.com|youtu\.be/.test(u)) return MDI.youtube;
351
+ if (/\.pdf(\?|#|$)/.test(u)) return MDI.pdf;
352
+ if (/\.(jpe?g|png|gif|webp|svg)(\?|#|$)/.test(u)) return MDI.image;
353
+ if (/waze\.com|\/maps\/dir\/|[?&]daddr=/.test(u)) return MDI.navigation;
354
+ if (/maps\.google|google\.[a-z.]+\/maps|maps\.apple\.com|openstreetmap|^geo:/.test(u)) return MDI.mapMarker;
355
+ // LAN: private Bereiche (RFC1918), Loopback, Link-Local und typische lokale Hostnamen
356
+ if (/^https?:\/\/(10\.\d|127\.\d|169\.254\.\d|192\.168\.\d|172\.(1[6-9]|2\d|3[01])\.\d|(localhost|fritz\.box|[\w-]+\.(local|lan|home|internal|fritz\.box))([:/]|$))/.test(u)) return MDI.link;
357
+ return MDI.web;
358
+ }
359
+
360
+ /** Nur sichere Schemata als klickbaren Link zulassen. Wehrt javascript:/data: u.ä. ab
361
+ * (Karten sind auch über die API beschreibbar → Link-Inhalt ist nicht vertrauenswürdig). */
362
+ function safeHref(url) {
363
+ const u = String(url || '').trim();
364
+ if (/^(https?:|mailto:|tel:|geo:)/i.test(u)) return u; // erlaubte Schemata
365
+ if (/^(\/|\.\/|\.\.\/)/.test(u)) return u; // relative Pfade
366
+ if (/^[\w.-]+\.[a-z]{2,}([/:?#]|$)/i.test(u)) return 'https://' + u; // host.tld ohne Schema
367
+ return null; // z.B. javascript:, data:, file: verwerfen
368
+ }
369
+
370
+ function renderCard(state, board, card, actions, opts = {}) {
371
+ const inTrash = !!opts.inTrash;
372
+ const c = el('div', 'card' + (inTrash ? ' card-trash' : '') + (opts.grip ? ' has-grip' : ''));
373
+ c.dataset.cardId = card.id;
374
+ if (card.color && !inTrash) { c.style.setProperty('--card-color', card.color); c.classList.add('has-color'); }
375
+
376
+ // Grid-Modus: Anfasser links an der Karte, nur darueber laesst sie sich ziehen
377
+ if (opts.grip) {
378
+ const grip = el('span', 'card-grip');
379
+ grip.appendChild(mdiIcon(MDI.sortGrid));
380
+ grip.title = t('sort.gripTitle');
381
+ c.appendChild(grip);
382
+ }
383
+
384
+ const col = (board.columns || []).find(x => x.id === card.columnId);
385
+ const isDone = !!(col && col.isDone);
386
+
387
+ // Titel als eigener Textknoten, damit Zusatz-Buttons (Kopieren) daneben passen
388
+ // und die Durchstreichung nur den Text trifft.
389
+ const titleEl = el('div', 'title' + (isDone && !inTrash ? ' title-done' : ''));
390
+ titleEl.appendChild(el('span', 'title-text', card.title));
391
+ c.appendChild(titleEl);
392
+
393
+ // Erledigt-Zeitstempel unter dem Titel (Feature 4), im eingestellten Datums-/Zeitformat
394
+ if (isDone && !inTrash && card.doneAt) {
395
+ const parts = isoLocalParts(card.doneAt);
396
+ if (parts) {
397
+ const stamp = `(${t('card.doneLabel')}: ${fmtDate(parts.D, state.cfg && state.cfg.dateFormat)} ${fmtTime(parts.T, state.cfg && state.cfg.timeFormat)})`;
398
+ c.appendChild(el('div', 'done-stamp', stamp));
399
+ }
400
+ }
401
+
402
+ const badges = el('div', 'badges');
403
+ if (card.priority > 0) {
404
+ badges.appendChild(el('span', `badge prio-${card.priority}`, card.priority === 2 ? '!!' : '!'));
405
+ }
406
+ if (card.due) badges.appendChild(dueBadge(card.due, card.dueTime, isDone, state.cfg));
407
+ if (card.location) {
408
+ const short = card.location.length > 24 ? card.location.slice(0, 23) + '…' : card.location;
409
+ const loc = el('span', 'badge');
410
+ loc.appendChild(mdiIcon(MDI.mapMarker));
411
+ loc.appendChild(document.createTextNode(' ' + short));
412
+ loc.title = card.location;
413
+ badges.appendChild(loc);
414
+ }
415
+ if (badges.children.length) c.appendChild(badges);
416
+
417
+ // Labels in einer eigenen Zeile, damit sie immer unter Prioritaet,
418
+ // Faelligkeit und Ort stehen und nicht dazwischenrutschen.
419
+ const labelRow = el('div', 'badges labels-row');
420
+ for (const lid of card.labels || []) {
421
+ const label = (board.labels || []).find(l => l.id === lid);
422
+ if (!label) continue;
423
+ const pill = el('span', 'label-pill', label.title);
424
+ pill.style.background = label.color || '#888';
425
+ pill.style.color = contrastText(label.color || '#888');
426
+ labelRow.appendChild(pill);
427
+ }
428
+ if (labelRow.children.length) c.appendChild(labelRow);
429
+
430
+ // Zustaendige stehen oben rechts im Titel und werden vom Titeltext umflossen
431
+ // (float), damit sie auf jeder Karte an derselben Stelle sitzen, ohne dem
432
+ // Titel pauschal Breite wegzunehmen.
433
+ if (card.assignees && card.assignees.length) {
434
+ const av = el('span', 'avatars');
435
+ card.assignees.forEach((a, i) => {
436
+ const one = userAvatar(state, a);
437
+ one.style.setProperty('--i', i); // Position im Stapel, fuer das Ausfahren
438
+ av.appendChild(one);
439
+ });
440
+ av.style.setProperty('--n', card.assignees.length);
441
+ // Eng gestapelt; Hover oder Klick faechert sie nach links auf (nur transform,
442
+ // damit sich der umflossene Bereich und der Titelumbruch nicht aendern).
443
+ av.addEventListener('mouseenter', () => av.classList.add('open'));
444
+ av.addEventListener('mouseleave', () => av.classList.remove('open'));
445
+ av.addEventListener('click', ev => { ev.stopPropagation(); av.classList.toggle('open'); });
446
+ c.classList.add('has-avatars');
447
+ titleEl.insertBefore(av, titleEl.firstChild);
448
+ }
449
+
450
+ const stopDrag = (b) => { for (const ev of ['pointerdown', 'mousedown', 'touchstart']) b.addEventListener(ev, e => e.stopPropagation()); };
451
+
452
+ // Kartenfuss: Checklisten-Zaehler links, Aufklapp-Chevron mittig, Icons fuer
453
+ // Beschreibung, Link und Wiederholung rechts (in dieser Reihenfolge).
454
+ const foot = el('div', 'card-foot');
455
+
456
+ if (card.checklist && card.checklist.length) {
457
+ c.classList.add('has-check');
458
+ const clist = el('div', 'card-checklist');
459
+ clist.hidden = !checkExpanded.has(card.id);
460
+ for (const item of card.checklist) {
461
+ const row = el('label', 'card-check-item' + (item.done ? ' done' : ''));
462
+ const cb = document.createElement('input');
463
+ cb.type = 'checkbox';
464
+ cb.checked = !!item.done;
465
+ cb.addEventListener('change', async ev => {
466
+ ev.stopPropagation();
467
+ const updated = card.checklist.map(i => (i === item ? { ...i, done: cb.checked } : i));
468
+ await actions.updateCard(card.id, { checklist: updated });
469
+ });
470
+ row.append(cb, el('span', 'card-check-text', item.text));
471
+ row.addEventListener('click', ev => ev.stopPropagation()); // Editor nicht öffnen
472
+ clist.appendChild(row);
473
+ }
474
+ const toggle = el('button', 'card-check-toggle');
475
+ const setChkIcon = (open) => { toggle.textContent = ''; toggle.appendChild(mdiIcon(open ? MDI.chevronUp : MDI.chevronDown)); };
476
+ setChkIcon(checkExpanded.has(card.id));
477
+ toggle.type = 'button';
478
+ toggle.title = t('card.checklistToggle');
479
+ toggle.addEventListener('click', ev => {
480
+ ev.stopPropagation();
481
+ const expand = clist.hidden;
482
+ clist.hidden = !expand;
483
+ setChkIcon(expand);
484
+ if (expand) checkExpanded.add(card.id); else checkExpanded.delete(card.id);
485
+ saveCheckExpanded();
486
+ });
487
+ const done = card.checklist.filter(i => i.done).length;
488
+ const count = el('span', 'badge check-count');
489
+ count.appendChild(mdiIcon(MDI.check));
490
+ count.appendChild(document.createTextNode(` ${done}/${card.checklist.length}`));
491
+
492
+ foot.append(count, toggle);
493
+ c.appendChild(clist);
494
+ }
495
+
496
+ const footIcons = el('span', 'card-foot-icons');
497
+ if (card.description) {
498
+ const nb = el('span', 'badge card-desc-btn');
499
+ nb.appendChild(mdiIcon(MDI_NOTE));
500
+ nb.title = t('badge.description');
501
+ nb.addEventListener('click', ev => { ev.stopPropagation(); actions.openDescription(card.id); });
502
+ stopDrag(nb);
503
+ footIcons.appendChild(nb);
504
+ }
505
+ if (card.link) {
506
+ const href = safeHref(card.link);
507
+ // klickbares Link-Badge nur bei sicherem Schema; sonst nicht-klickbarer Hinweis
508
+ const lb = href ? el('a', 'badge link-badge') : el('span', 'badge link-badge');
509
+ lb.appendChild(mdiIcon(linkIcon(card.link)));
510
+ if (href) {
511
+ lb.href = href;
512
+ if (!/^(mailto:|tel:)/i.test(href)) { lb.target = '_blank'; lb.rel = 'noopener'; }
513
+ }
514
+ lb.title = card.link;
515
+ lb.addEventListener('click', e => e.stopPropagation());
516
+ stopDrag(lb);
517
+ footIcons.appendChild(lb);
518
+ }
519
+ if (card.recurrence && card.recurrence.type && card.recurrence.type !== 'none') {
520
+ const rb = el('span', 'badge');
521
+ rb.appendChild(mdiIcon(MDI.sync));
522
+ rb.title = t('badge.recurring');
523
+ footIcons.appendChild(rb);
524
+ }
525
+ // Ohne Checkliste braucht es keine eigene Fusszeile: die Icons setzen sich
526
+ // rechtsbuendig in die Label- bzw. Badge-Zeile, also auf gleiche Hoehe.
527
+ const inlineRow = labelRow.children.length ? labelRow : (badges.children.length ? badges : null);
528
+ if (footIcons.children.length && !foot.children.length && inlineRow) {
529
+ footIcons.classList.add('inline-icons');
530
+ inlineRow.appendChild(footIcons);
531
+ } else if (footIcons.children.length) {
532
+ foot.appendChild(footIcons);
533
+ }
534
+ if (foot.children.length) c.appendChild(foot);
535
+
536
+ // Editor oeffnen - kleines Icon direkt hinter dem Titel. Ein Klick irgendwo
537
+ // auf die Karte oeffnet den Editor bewusst nicht mehr: das passierte zu leicht
538
+ // versehentlich beim Scrollen oder Antippen.
539
+ if (!inTrash) {
540
+ const ed = el('button', 'card-action card-edit');
541
+ ed.type = 'button';
542
+ ed.title = t('card.edit');
543
+ ed.setAttribute('aria-label', ed.title);
544
+ const eic = mdiIcon(MDI.invoiceTextEdit);
545
+ eic.setAttribute('width', '16');
546
+ eic.setAttribute('height', '16');
547
+ ed.appendChild(eic);
548
+ ed.addEventListener('click', e => { e.stopPropagation(); actions.openCard(card.id); });
549
+ stopDrag(ed);
550
+ titleEl.appendChild(ed);
551
+ }
552
+
553
+ // Kopieren erledigter Karten (Feature 3) - kleines Icon direkt hinter dem Titel
554
+ if (isDone && !inTrash) {
555
+ const cp = el('button', 'card-action card-copy');
556
+ cp.type = 'button';
557
+ cp.title = t('card.copy');
558
+ cp.setAttribute('aria-label', cp.title);
559
+ const ic = mdiIcon(MDI.contentCopy);
560
+ ic.setAttribute('width', '14');
561
+ ic.setAttribute('height', '14');
562
+ cp.appendChild(ic);
563
+ cp.addEventListener('click', e => { e.stopPropagation(); actions.copyCard(card.id); });
564
+ stopDrag(cp);
565
+ titleEl.appendChild(cp);
566
+ }
567
+
568
+ // Papierkorb: Resthinweis + Wiederherstellen/Endgültig-löschen (Feature 5)
569
+ if (inTrash) {
570
+ const retention = (state.cfg && state.cfg.trashRetentionDays) || 30;
571
+ c.appendChild(el('div', 'trash-info', t('trash.daysLeft', { n: trashDaysLeft(card, retention) })));
572
+ const row = el('div', 'trash-actions');
573
+ const rb = el('button', 'card-action trash-restore');
574
+ rb.type = 'button'; rb.title = t('trash.restore'); rb.setAttribute('aria-label', rb.title);
575
+ rb.appendChild(mdiIcon(MDI.restore));
576
+ rb.addEventListener('click', e => { e.stopPropagation(); actions.restoreCard(card.id); });
577
+ const pb = el('button', 'card-action trash-purge');
578
+ pb.type = 'button'; pb.title = t('trash.purge'); pb.setAttribute('aria-label', pb.title);
579
+ pb.appendChild(mdiIcon(MDI.deleteForever));
580
+ pb.addEventListener('click', async e => {
581
+ e.stopPropagation();
582
+ if (await actions.confirm({ title: t('trash.purge'), message: t('confirm.purgeCard'), danger: true, ok: t('trash.purge') })) {
583
+ actions.purgeCard(card.id);
584
+ }
585
+ });
586
+ stopDrag(rb); stopDrag(pb);
587
+ row.append(rb, pb);
588
+ c.appendChild(row);
589
+ }
590
+
591
+ return c;
592
+ }
593
+
594
+ // Lesbare Schriftfarbe je nach Hintergrundhelligkeit (YIQ): hell -> schwarz, dunkel -> weiss
595
+ export function contrastText(bg) {
596
+ const c = String(bg || '').trim();
597
+ let r, g, b, m;
598
+ if ((m = /^#([0-9a-f]{3})$/i.exec(c))) { r = parseInt(m[1][0] + m[1][0], 16); g = parseInt(m[1][1] + m[1][1], 16); b = parseInt(m[1][2] + m[1][2], 16); }
599
+ else if ((m = /^#([0-9a-f]{6})$/i.exec(c))) { const n = parseInt(m[1], 16); r = (n >> 16) & 255; g = (n >> 8) & 255; b = n & 255; }
600
+ else return '#fff';
601
+ const lin = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
602
+ const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); // WCAG relative Luminanz
603
+ return L > 0.31 ? '#000' : '#fff';
604
+ }
605
+
606
+ // Zuweisbare Benutzer des aktiven Boards: Mitglieder, sonst (leer) alle.
607
+ /**
608
+ * Wirksame Mitglieder eines beliebigen Boards. Spiegelt die Regel des Servers
609
+ * (lib/store.js, transferCard): Zeigt die Mitgliederliste ins Leere — etwa weil
610
+ * die Benutzer-IDs in den Instanz-Einstellungen umbenannt wurden —, gelten alle
611
+ * bekannten Benutzer. Sonst haette das Board keine Zustaendigen mehr und es
612
+ * liesse sich keine Karte mehr anlegen (Zustaendig ist Pflichtfeld).
613
+ *
614
+ * Gilt fuer jedes Board, nicht nur das gerade angezeigte: Der Uebertragen-Dialog
615
+ * las die Liste frueher roh und hielt ein Board mit leerer Liste faelschlich
616
+ * fuer "niemand zuweisbar".
617
+ */
618
+ /**
619
+ * Sichtbaren Ablage-Index in die Ordnung der vollstaendigen Spalte uebersetzen.
620
+ *
621
+ * Der Server versteht `order` als Index in der **vollstaendigen**, nach `order`
622
+ * sortierten Spalte; die Oberflaeche kennt beim Ziehen aber nur die **sichtbaren**
623
+ * Karten. Sobald Personenfilter, `?label=`/`?onlyLabel=`, `doneLimit` oder das
624
+ * Anzeige-Limit einer Spalte etwas ausblenden, meinen beide etwas anderes, und
625
+ * die Karte lag nach dem Neuladen an einer anderen Stelle als abgelegt.
626
+ *
627
+ * Massgeblich ist die Nachbarschaft: Die Karte gehoert vor die naechste sichtbare
628
+ * Karte, sonst hinter die vorherige.
629
+ *
630
+ * @param board Board aus dem State
631
+ * @param columnId Zielspalte
632
+ * @param movedId ID der gezogenen Karte
633
+ * @param listEl DOM-Liste der Zielspalte (enthaelt die Karte bereits)
634
+ * @param visibleIndex Position der Karte in dieser Liste (evt.newIndex)
635
+ */
636
+ export function absoluteOrder(board, columnId, movedId, listEl, visibleIndex) {
637
+ const full = (board.cards || [])
638
+ .filter(c => c.columnId === columnId && c.id !== movedId)
639
+ .sort((a, b) => a.order - b.order);
640
+ if (!full.length) return 0;
641
+
642
+ const domIds = [...listEl.children]
643
+ .map(node => node && node.dataset && node.dataset.cardId)
644
+ .filter(Boolean);
645
+ const indexOf = id => full.findIndex(c => c.id === id);
646
+
647
+ const nextId = domIds[visibleIndex + 1];
648
+ if (nextId) {
649
+ const i = indexOf(nextId);
650
+ if (i !== -1) return i;
651
+ }
652
+ const prevId = visibleIndex > 0 ? domIds[visibleIndex - 1] : null;
653
+ if (prevId) {
654
+ const i = indexOf(prevId);
655
+ if (i !== -1) return i + 1;
656
+ }
657
+ // Keine sichtbaren Nachbarn: oben einsortieren, wenn ganz oben abgelegt,
658
+ // sonst ans Ende.
659
+ return visibleIndex === 0 ? 0 : full.length;
660
+ }
661
+
662
+ export function boardMembers(board, users) {
663
+ const all = (users || []).map(u => u && u.name).filter(Boolean);
664
+ const listed = (board && Array.isArray(board.members) ? board.members : []).filter(n => all.includes(n));
665
+ return listed.length ? listed : all;
666
+ }
667
+
668
+ export function boardUsers(state) {
669
+ const names = new Set(boardMembers(state.board, state.users));
670
+ return (state.users || []).filter(u => names.has(u.name));
671
+ }
672
+
673
+ /**
674
+ * Kartentitel auf zwei Zeilen kuerzen und mit "…" enden lassen.
675
+ * CSS line-clamp scheidet aus, weil der Titel die Avatare umfliesst (float) und
676
+ * ein -webkit-box das Umfliessen aufheben wuerde. Darum per Messung kuerzen.
677
+ */
678
+ function clampCardTitles(root) {
679
+ for (const titleEl of root.querySelectorAll('.card .title')) {
680
+ const span = titleEl.querySelector('.title-text');
681
+ if (!span) continue;
682
+ const full = span.dataset.full || span.textContent;
683
+ span.dataset.full = full;
684
+ span.textContent = full;
685
+ titleEl.style.minHeight = ''; // Angleich-Hoehe darf die Messung nicht verfaelschen
686
+ const lh = parseFloat(getComputedStyle(titleEl).lineHeight) || 20;
687
+ const max = lh * 2 + 1; // zwei Zeilen plus Rundungsreserve
688
+ if (titleEl.clientHeight <= max) { titleEl.removeAttribute('title'); continue; }
689
+ let lo = 0, hi = full.length; // binaere Suche nach der laengsten passenden Kuerzung
690
+ while (lo < hi) {
691
+ const mid = Math.ceil((lo + hi) / 2);
692
+ span.textContent = full.slice(0, mid).trimEnd() + '…';
693
+ if (titleEl.clientHeight <= max) lo = mid; else hi = mid - 1;
694
+ }
695
+ span.textContent = full.slice(0, lo).trimEnd() + '…';
696
+ titleEl.title = full; // voller Titel im Tooltip
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Titelhoehe je Spalte angleichen: alle Karten einer Spalte bekommen die Hoehe
702
+ * ihres laengsten Titels. Spalten mit lauter einzeiligen Titeln bleiben dadurch
703
+ * kompakt, gemischte Spalten fluchten trotzdem.
704
+ */
705
+ function alignCardTitles(root) {
706
+ for (const col of root.querySelectorAll('.column')) {
707
+ const titles = [...col.querySelectorAll('.card .title')];
708
+ if (!titles.length) continue;
709
+ for (const t of titles) t.style.minHeight = '';
710
+ const max = Math.max(...titles.map(t => t.clientHeight));
711
+ for (const t of titles) t.style.minHeight = `${max}px`;
712
+ }
713
+ }
714
+
715
+ let _clampTimer = null;
716
+ function reflowTitles(delay = 150) {
717
+ clearTimeout(_clampTimer);
718
+ _clampTimer = setTimeout(() => {
719
+ const b = document.getElementById('board');
720
+ if (b) { clampCardTitles(b); alignCardTitles(b); }
721
+ }, delay);
722
+ }
723
+ // wie beim ResizeObserver unten: nur registrieren, wenn es ein Fenster gibt
724
+ if (typeof window !== 'undefined') window.addEventListener('resize', () => reflowTitles());
725
+
726
+ /* Spaltenbreite kann sich auch ohne Fensteraenderung aendern, z.B. wenn eine
727
+ * Spalte einen Scrollbalken bekommt. Dann muessen die Titel neu vermessen
728
+ * werden, sonst laufen sie ueber zwei Zeilen hinaus. Nur auf echte
729
+ * Breitenaenderungen reagieren, sonst loest das Kuerzen sich selbst aus. */
730
+ const _lastWidth = new WeakMap();
731
+ const _widthWatcher = typeof ResizeObserver === 'function' ? new ResizeObserver(entries => {
732
+ let changed = false;
733
+ for (const e of entries) {
734
+ const w = Math.round(e.contentRect.width);
735
+ if (_lastWidth.get(e.target) !== w) { _lastWidth.set(e.target, w); changed = true; }
736
+ }
737
+ if (changed) reflowTitles(60);
738
+ }) : null;
739
+
740
+ export function renderBoard(container, state, actions) {
741
+ initCtxGuard();
742
+ // Scrollpositionen merken: das Board wird bei jeder Änderung komplett neu
743
+ // aufgebaut (z. B. nach dem Abhaken eines Checklisten-Punkts), sonst springt
744
+ // die Ansicht dabei an den Anfang.
745
+ const prevScroll = new Map();
746
+ for (const colEl of container.querySelectorAll('.column')) {
747
+ const list = colEl.querySelector('.cards');
748
+ if (colEl.dataset.colId && list) prevScroll.set(colEl.dataset.colId, list.scrollTop);
749
+ }
750
+ const prevLeft = container.scrollLeft;
751
+ const prevPageY = window.scrollY;
752
+ container.textContent = '';
753
+ const board = state.board;
754
+ if (!board) {
755
+ container.appendChild(el('div', 'empty', t('board.empty')));
756
+ return;
757
+ }
758
+
759
+ // Aufgeklappte Checklisten dieses Boards laden und dabei Karten-IDs
760
+ // aussortieren, die es nicht mehr gibt (sonst waechst der Eintrag endlos).
761
+ loadCheckExpanded(board.id);
762
+ if (checkExpanded.size) {
763
+ const alive = new Set((board.cards || []).map(c => c.id));
764
+ let dropped = false;
765
+ for (const id of [...checkExpanded]) if (!alive.has(id)) { checkExpanded.delete(id); dropped = true; }
766
+ if (dropped) saveCheckExpanded();
767
+ }
768
+
769
+ // Personen-Filter (Kopf-Chips) greift nur bei Teilauswahl:
770
+ // alle aktiv (Standard) oder keiner aktiv => alle Karten sichtbar.
771
+ const _allMembers = boardUsers(state).map(u => u.name);
772
+ const _sel = (state.usersFilter || []).filter(n => _allMembers.includes(n));
773
+ const userSel = (_sel.length > 0 && _sel.length < _allMembers.length) ? _sel : null;
774
+
775
+ for (const col of board.columns) {
776
+ if (state.columnsFilter && !state.columnsFilter.includes(col.id)) continue;
777
+ if (col.isTrash && !state.showTrash) continue; // Papierkorb nur wenn eingeblendet (pro Gerät)
778
+ const colEl = el('div', 'column' + (col.isTrash ? ' trash-col' : ''));
779
+ colEl.dataset.colId = col.id;
780
+
781
+ let cards = board.cards
782
+ .filter(c => c.columnId === col.id)
783
+ .sort((a, b) => a.order - b.order);
784
+ if (userSel) {
785
+ cards = cards.filter(c => (c.assignees || []).some(a => userSel.includes(a)));
786
+ }
787
+ if (state.labelOnly && state.labelOnly.length) {
788
+ // Whitelist (?onlyLabel=): nur Karten mit mindestens einem dieser Labels
789
+ cards = cards.filter(c => (c.labels || []).some(l => state.labelOnly.includes(l)));
790
+ }
791
+ if (state.labelFilter && state.labelFilter.length) {
792
+ // Blacklist (?label=): Karten mit einem dieser Labels ausblenden (neue Labels bleiben sichtbar)
793
+ cards = cards.filter(c => !(c.labels || []).some(l => state.labelFilter.includes(l)));
794
+ }
795
+ // Zähler = sichtbare Karten des aktiven Filters (vor der doneLimit-Kürzung)
796
+ const matchedCount = cards.length;
797
+ if (col.isTrash) {
798
+ // Papierkorb: fest nach trashedAt (älteste zuerst = am nächsten zur endgültigen Löschung)
799
+ cards = cards.slice().sort((a, b) => String(a.trashedAt || '').localeCompare(String(b.trashedAt || '')));
800
+ } else {
801
+ // In Erledigt-Spalten optional nur die zuletzt erledigten N Karten zeigen
802
+ if (col.isDone && state.doneLimit != null && cards.length > state.doneLimit) {
803
+ cards = cards.slice()
804
+ .sort((a, b) => (b.doneAt || b.movedAt || '').localeCompare(a.doneAt || a.movedAt || ''))
805
+ .slice(0, state.doneLimit);
806
+ }
807
+ const sm = getSortMode(state, board, col); // Sortiermodus je Spalte
808
+ cards = applySort(cards, sm.mode, sm.rev);
809
+ }
810
+ // Optionales Anzeige-Limit je Spalte (0 = alle); im Papierkorb nicht anwenden
811
+ let hiddenByMax = 0;
812
+ if (!col.isTrash && col.maxVisible > 0 && cards.length > col.maxVisible) {
813
+ hiddenByMax = cards.length - col.maxVisible;
814
+ cards = cards.slice(0, col.maxVisible);
815
+ }
816
+
817
+ const collapsed = state.collapsedCols && state.collapsedCols.has(col.id);
818
+ if (collapsed) colEl.classList.add('collapsed');
819
+ const head = el('div', 'column-head');
820
+ const chev = el('span', 'col-chevron'); // nur mobil sichtbar (CSS)
821
+ const setChev = (c) => { chev.textContent = ''; chev.appendChild(mdiIcon(c ? MDI.chevronRight : MDI.chevronDown)); };
822
+ setChev(collapsed);
823
+ head.appendChild(chev);
824
+ head.appendChild(el('span', null, col.isTrash ? t('col.trash') : col.title));
825
+ const allInCol = board.cards.filter(c => c.columnId === col.id).length;
826
+ // Bei aktivem Personen-/Label-Filter zählt die Kopfzeile die gefilterten (sichtbaren) Karten
827
+ const anyFilter = userSel || (state.labelFilter && state.labelFilter.length) || (state.labelOnly && state.labelOnly.length);
828
+ const count = el('span', 'count', (!anyFilter && col.wipLimit > 0) ? `${allInCol}/${col.wipLimit}` : String(anyFilter ? matchedCount : allInCol));
829
+ head.appendChild(count);
830
+ if (col.wipLimit > 0 && allInCol > col.wipLimit) colEl.classList.add('over-wip');
831
+
832
+ // Erledigt-Spalte: Auge-Toggle rechts oben (blendet erledigte Karten ein/aus)
833
+ const isDoneCol = !!col.isDone;
834
+ if (isDoneCol) {
835
+ const eye = el('button', 'col-toggle' + (state.showDone ? '' : ' off'));
836
+ eye.appendChild(mdiIcon(state.showDone ? MDI_EYE : MDI_EYE_CLOSED));
837
+ eye.title = state.showDone ? t('col.hideDone') : t('col.showDone');
838
+ eye.setAttribute('aria-label', eye.title);
839
+ eye.addEventListener('click', () => actions.toggleShowDone());
840
+ head.appendChild(eye);
841
+ }
842
+ // Sortier-Umschalter (nicht im Papierkorb; in Erledigt-Spalten rechts vom Auge).
843
+ // Bei den automatischen Modi steht links davon ein Richtungsumschalter.
844
+ if (!col.isTrash) {
845
+ const { mode: curMode, rev: curRev } = getSortMode(state, board, col);
846
+ if (AUTO_SORT_MODES.includes(curMode)) {
847
+ const dirBtn = el('button', 'col-sort col-sort-dir');
848
+ dirBtn.appendChild(mdiIcon(curRev ? MDI.dirUp : MDI.dirDown));
849
+ dirBtn.title = t('sort.reverse');
850
+ dirBtn.setAttribute('aria-label', dirBtn.title);
851
+ dirBtn.addEventListener('click', (e) => {
852
+ e.stopPropagation();
853
+ actions.setSortMode(colSortKey(board, col), curRev ? curMode : curMode + ':rev');
854
+ });
855
+ head.appendChild(dirBtn);
856
+ }
857
+ const sortBtn = el('button', 'col-sort');
858
+ sortBtn.appendChild(mdiIcon(sortModeIcon(curMode)));
859
+ sortBtn.title = t('sort.mode');
860
+ sortBtn.setAttribute('aria-label', sortBtn.title);
861
+ sortBtn.addEventListener('click', (e) => { e.stopPropagation(); openSortMenu(sortBtn, state, board, col, actions); });
862
+ head.appendChild(sortBtn);
863
+ }
864
+ // Papierkorb: Leeren-Button
865
+ if (col.isTrash) {
866
+ const empt = el('button', 'col-toggle');
867
+ empt.appendChild(mdiIcon(MDI.broom));
868
+ empt.title = t('trash.empty');
869
+ empt.setAttribute('aria-label', empt.title);
870
+ empt.addEventListener('click', async (e) => {
871
+ e.stopPropagation();
872
+ if (!board.cards.some(c => c.columnId === col.id)) return;
873
+ if (await actions.confirm({ title: t('trash.empty'), message: t('confirm.emptyTrash'), danger: true, ok: t('trash.empty') })) {
874
+ actions.emptyTrash();
875
+ }
876
+ });
877
+ head.appendChild(empt);
878
+ }
879
+ // Mobil: Spaltenkopf antippen klappt die Spalte ein/aus
880
+ head.addEventListener('click', (e) => {
881
+ if (!window.matchMedia('(max-width: 600px)').matches) return; // nur bei gestapelten Spalten
882
+ if (e.target.closest('.col-toggle, button, a')) return; // Buttons/Links im Kopf nicht abfangen
883
+ const nowCollapsed = colEl.classList.toggle('collapsed');
884
+ setChev(nowCollapsed);
885
+ if (state.collapsedCols) {
886
+ if (nowCollapsed) state.collapsedCols.add(col.id); else state.collapsedCols.delete(col.id);
887
+ try { localStorage.setItem('kanban.collapsedCols', [...state.collapsedCols].join(',')); } catch (e2) { /* ignore */ }
888
+ }
889
+ });
890
+ colEl.appendChild(head);
891
+
892
+ const list = el('div', 'cards');
893
+ const sortMode = col.isTrash ? 'manual' : getSortMode(state, board, col).mode;
894
+ const withGrip = sortMode === 'grid';
895
+ const hideCards = isDoneCol && !state.showDone;
896
+ if (!hideCards) {
897
+ for (const card of cards) {
898
+ list.appendChild(renderCard(state, board, card, actions, { inTrash: !!col.isTrash, grip: withGrip }));
899
+ }
900
+ }
901
+ colEl.appendChild(list);
902
+ if (hiddenByMax > 0 && !hideCards) {
903
+ const more = el('div', 'col-more', t('board.moreCards', { n: hiddenByMax }));
904
+ more.title = t('board.moreCardsTitle');
905
+ colEl.appendChild(more);
906
+ }
907
+
908
+ const canAdd = !col.isTrash && ((typeof col.allowAdd === 'boolean') ? col.allowAdd : (board.columns[0] && board.columns[0].id === col.id));
909
+ if (canAdd) {
910
+ // Der Button haengt in der Kartenliste, damit er exakt so breit ist
911
+ // wie die Karten (auch wenn die Spalte einen Scrollbalken hat).
912
+ const addBtn = el('button', 'add-card-btn');
913
+ addBtn.type = 'button';
914
+ addBtn.appendChild(mdiIcon(MDI.plus));
915
+ addBtn.title = t('board.addCard');
916
+ addBtn.setAttribute('aria-label', t('board.addCard'));
917
+ addBtn.addEventListener('click', () => actions.openCard(null, col.id));
918
+ list.appendChild(addBtn);
919
+ }
920
+
921
+ container.appendChild(colEl);
922
+ if (_widthWatcher) _widthWatcher.observe(list);
923
+
924
+ // eslint-disable-next-line no-undef
925
+ Sortable.create(list, {
926
+ group: 'cards',
927
+ draggable: '.card', // der Plus-Button bleibt liegen
928
+ animation: 150,
929
+ delay: 150, // Touch: kurz halten zum Ziehen, damit Scrollen möglich bleibt
930
+ delayOnTouchOnly: true,
931
+ // Grid-Modus: nur ueber den Anfasser ziehen
932
+ ...(withGrip ? { handle: '.card-grip' } : {}),
933
+ // Automatische Modi: eigenes Umsortieren waere wirkungslos, Verschieben
934
+ // in andere Spalten bleibt moeglich
935
+ ...(AUTO_SORT_MODES.includes(sortMode) ? { sort: false } : {}),
936
+ ghostClass: 'sortable-ghost',
937
+ filter: '.link-badge, .card-check-toggle, .card-checklist, .card-action, .card-foot-icons, .title .avatars', // lösen kein Ziehen aus
938
+ preventOnFilter: false, // damit deren Klick normal durchkommt
939
+
940
+ onStart: evt => {
941
+ dragActive = true;
942
+ if (wantsQuickMove()) buildQuickMove(evt, col, board);
943
+ },
944
+ onEnd: evt => {
945
+ dragActive = false;
946
+ const cardId = evt.item.dataset.cardId;
947
+ // Ueber einer Landezone losgelassen? Dann zaehlt deren Spalte,
948
+ // egal wohin SortableJS die Karte gelegt hat.
949
+ const zoneCol = quickTarget && quickTarget.dataset.colId;
950
+ removeQuickMove();
951
+ if (zoneCol) { actions.moveCard(cardId, zoneCol, 0); return; }
952
+ const toEl = evt.to;
953
+ const toCol = toEl.dataset.colId || (toEl.closest('.column') && toEl.closest('.column').dataset.colId);
954
+ if (!toCol) return;
955
+ // evt.newIndex zaehlt nur die sichtbaren Karten — der Server rechnet
956
+ // mit der vollstaendigen Spalte. Ohne Umrechnung landet die Karte in
957
+ // gefilterten Ansichten woanders als abgelegt.
958
+ actions.moveCard(cardId, toCol, absoluteOrder(state.board, toCol, cardId, toEl, evt.newIndex));
959
+ },
960
+ });
961
+ }
962
+
963
+ clampCardTitles(container); // Titel erst messen, wenn die Karten im DOM haengen
964
+ alignCardTitles(container);
965
+ restoreScroll();
966
+ // Nach dem Ausmessen der Titel koennen sich Hoehen aendern -> noch einmal setzen
967
+ requestAnimationFrame(restoreScroll);
968
+ // Nachmessen, sobald Schriften geladen sind: vorher koennen Zeilenhoehen
969
+ // abweichen und Titel faelschlich gekuerzt werden.
970
+ if (document.fonts && document.fonts.status !== 'loaded') {
971
+ document.fonts.ready.then(() => { clampCardTitles(container); alignCardTitles(container); restoreScroll(); }).catch(() => {});
972
+ }
973
+
974
+ function restoreScroll() {
975
+ for (const colEl of container.querySelectorAll('.column')) {
976
+ const y = prevScroll.get(colEl.dataset.colId);
977
+ const list = colEl.querySelector('.cards');
978
+ if (y && list) list.scrollTop = y;
979
+ }
980
+ if (prevLeft) container.scrollLeft = prevLeft;
981
+ if (prevPageY) window.scrollTo(0, prevPageY);
982
+ }
983
+ }