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.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/admin/i18n/de.json +100 -0
- package/admin/i18n/en.json +100 -0
- package/admin/i18n/es.json +100 -0
- package/admin/i18n/fr.json +100 -0
- package/admin/i18n/it.json +100 -0
- package/admin/i18n/nl.json +100 -0
- package/admin/i18n/pl.json +100 -0
- package/admin/i18n/pt.json +100 -0
- package/admin/i18n/ru.json +100 -0
- package/admin/i18n/uk.json +100 -0
- package/admin/i18n/zh-cn.json +100 -0
- package/admin/jsonConfig.json +668 -0
- package/admin/kanban.png +0 -0
- package/io-package.json +299 -0
- package/lib/cron.js +203 -0
- package/lib/dueat.js +77 -0
- package/lib/events.js +24 -0
- package/lib/holidays.js +167 -0
- package/lib/i18n-server.js +301 -0
- package/lib/notify.js +627 -0
- package/lib/scheduler.js +198 -0
- package/lib/server.js +799 -0
- package/lib/store.js +1500 -0
- package/main.js +382 -0
- package/package.json +58 -0
- package/www/css/app.css +1022 -0
- package/www/i18n/de.json +238 -0
- package/www/i18n/en.json +238 -0
- package/www/i18n/es.json +238 -0
- package/www/i18n/fr.json +238 -0
- package/www/i18n/it.json +238 -0
- package/www/i18n/nl.json +238 -0
- package/www/i18n/pl.json +238 -0
- package/www/i18n/pt.json +238 -0
- package/www/i18n/ru.json +238 -0
- package/www/i18n/uk.json +238 -0
- package/www/i18n/zh-cn.json +238 -0
- package/www/index.html +171 -0
- package/www/js/api.js +98 -0
- package/www/js/app.js +411 -0
- package/www/js/board.js +983 -0
- package/www/js/colorpicker.js +165 -0
- package/www/js/dialogs.js +1419 -0
- package/www/js/i18n.js +43 -0
- package/www/vendor/marked.min.js +69 -0
- package/www/vendor/purify.min.js +3 -0
- package/www/vendor/sortable.min.js +2 -0
package/www/js/board.js
ADDED
|
@@ -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
|
+
}
|