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
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
// Karten-Dialog + Board-/Spalten-Verwaltung
|
|
2
|
+
|
|
3
|
+
import { openColorPicker, closeColorPicker, colorPickerOpen } from './colorpicker.js';
|
|
4
|
+
import { api } from './api.js';
|
|
5
|
+
import { t } from './i18n.js';
|
|
6
|
+
import { boardUsers, boardMembers, contrastText, mdiIcon, MDI } from './board.js';
|
|
7
|
+
|
|
8
|
+
const CARD_COLORS = ['', '#e57373', '#ffb74d', '#fff176', '#aed581', '#4fc3f7', '#9575cd', '#f06292', '#a1887f'];
|
|
9
|
+
const WEEKDAYS = [['Mo', 1], ['Di', 2], ['Mi', 3], ['Do', 4], ['Fr', 5], ['Sa', 6], ['So', 7]];
|
|
10
|
+
|
|
11
|
+
function el(tag, cls, text) {
|
|
12
|
+
const e = document.createElement(tag);
|
|
13
|
+
if (cls) e.className = cls;
|
|
14
|
+
if (text !== undefined) e.textContent = text;
|
|
15
|
+
return e;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Klickbarer Farb-Swatch, der den eingebetteten Colorpicker öffnet.
|
|
19
|
+
// Aktuelle Farbe liegt in dataset.color; onChange(col) wird live aufgerufen.
|
|
20
|
+
function makeColorTrigger(initial, onChange, opts = {}) {
|
|
21
|
+
const t = el('span', 'cp-trigger');
|
|
22
|
+
t.tabIndex = 0;
|
|
23
|
+
const set = col => { t.dataset.color = col || ''; t.style.background = col || ''; t.classList.toggle('empty', !col); };
|
|
24
|
+
set(initial || '');
|
|
25
|
+
const open = () => openColorPicker(t, t.dataset.color, col => { set(col); if (onChange) onChange(col); },
|
|
26
|
+
{ presets: opts.presets || CARD_COLORS });
|
|
27
|
+
t.addEventListener('click', open);
|
|
28
|
+
t.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initials(name) {
|
|
33
|
+
return String(name || '?').split(/[\s_-]+/).map(p => p[0] || '').join('').slice(0, 2).toUpperCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Bilddatei quadratisch zuschneiden + auf `size` px verkleinern → PNG-Data-URL
|
|
37
|
+
function fileToSquareDataUrl(file, size) {
|
|
38
|
+
return loadImageSource(file).then(src => {
|
|
39
|
+
const c = document.createElement('canvas');
|
|
40
|
+
c.width = c.height = size;
|
|
41
|
+
const ctx = c.getContext('2d');
|
|
42
|
+
const dim = Math.min(src.width, src.height);
|
|
43
|
+
ctx.drawImage(src, (src.width - dim) / 2, (src.height - dim) / 2, dim, dim, 0, 0, size, size);
|
|
44
|
+
if (src.close) src.close();
|
|
45
|
+
return c.toDataURL('image/png');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Laedt eine Bilddatei OHNE blob:-URL (CSP-konform): bevorzugt createImageBitmap,
|
|
50
|
+
// Fallback ueber eine data:-URL (von der CSP erlaubt), da img-src kein blob: zulaesst.
|
|
51
|
+
function loadImageSource(file) {
|
|
52
|
+
if (window.createImageBitmap) return createImageBitmap(file);
|
|
53
|
+
return new Promise(function (resolve, reject) {
|
|
54
|
+
var fr = new FileReader();
|
|
55
|
+
fr.onload = function () {
|
|
56
|
+
var img = new Image();
|
|
57
|
+
img.onload = function () { resolve(img); };
|
|
58
|
+
img.onerror = function () { reject(new Error('Bild konnte nicht gelesen werden')); };
|
|
59
|
+
img.src = fr.result;
|
|
60
|
+
};
|
|
61
|
+
fr.onerror = function () { reject(new Error('Datei konnte nicht gelesen werden')); };
|
|
62
|
+
fr.readAsDataURL(file);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function slugify(text) {
|
|
67
|
+
return String(text || '').toLowerCase()
|
|
68
|
+
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
|
69
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'x';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function initDialogs(state, actions) {
|
|
73
|
+
const dlg = document.getElementById('cardDialog');
|
|
74
|
+
const form = document.getElementById('cardForm');
|
|
75
|
+
// Monats-Select einmalig lokalisiert befüllen (defensiv: bricht nicht die
|
|
76
|
+
// ganze App ab, falls veraltetes HTML gecacht wurde und das Feld fehlt)
|
|
77
|
+
const recMonthSel = form && form.elements ? form.elements.recMonth : null;
|
|
78
|
+
if (recMonthSel && !recMonthSel.options.length) {
|
|
79
|
+
for (let m = 1; m <= 12; m++) recMonthSel.appendChild(new Option(t('month.' + m), String(m)));
|
|
80
|
+
}
|
|
81
|
+
// Listener nur setzen, wenn das Feld existiert (robust gegen veraltetes HTML)
|
|
82
|
+
const on = (name, ev, fn) => { const e = form.elements[name]; if (e) e.addEventListener(ev, fn); };
|
|
83
|
+
let editingCardId = null;
|
|
84
|
+
let selAssignees = new Set();
|
|
85
|
+
let selLabels = new Set();
|
|
86
|
+
let selColor = '';
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------- Karten-Dialog
|
|
89
|
+
|
|
90
|
+
function renderAssigneePick() {
|
|
91
|
+
const box = document.getElementById('assigneePick');
|
|
92
|
+
box.textContent = '';
|
|
93
|
+
const list = boardUsers(state).slice();
|
|
94
|
+
for (const u of (state.users || [])) if (selAssignees.has(u.name) && !list.some(x => x.name === u.name)) list.push(u);
|
|
95
|
+
for (const u of list) {
|
|
96
|
+
const chip = el('span', 'pick-chip', u.displayName);
|
|
97
|
+
chip.style.borderColor = selAssignees.has(u.name) ? (u.color || '') : '';
|
|
98
|
+
if (selAssignees.has(u.name)) chip.classList.add('selected');
|
|
99
|
+
chip.addEventListener('click', () => {
|
|
100
|
+
selAssignees.has(u.name) ? selAssignees.delete(u.name) : selAssignees.add(u.name);
|
|
101
|
+
renderAssigneePick();
|
|
102
|
+
});
|
|
103
|
+
box.appendChild(chip);
|
|
104
|
+
}
|
|
105
|
+
updateAssigneeValidity();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pflicht: mindestens ein Zustaendiger. Proxy-Input traegt die (lokalisierte)
|
|
109
|
+
// Fehlermeldung, damit die native Browser-Blase erscheint - wie beim Titel.
|
|
110
|
+
function updateAssigneeValidity() {
|
|
111
|
+
const p = document.getElementById('assigneeValidity');
|
|
112
|
+
if (p) p.setCustomValidity(selAssignees.size ? '' : t('card.assigneeRequired'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderLabelPick() {
|
|
116
|
+
const box = document.getElementById('labelPick');
|
|
117
|
+
box.textContent = '';
|
|
118
|
+
for (const l of (state.board && state.board.labels) || []) {
|
|
119
|
+
const chip = el('span', 'pick-chip', l.title);
|
|
120
|
+
if (selLabels.has(l.id)) {
|
|
121
|
+
chip.classList.add('selected');
|
|
122
|
+
chip.style.background = l.color || '';
|
|
123
|
+
chip.style.color = contrastText(l.color || '#888');
|
|
124
|
+
}
|
|
125
|
+
chip.addEventListener('click', () => {
|
|
126
|
+
selLabels.has(l.id) ? selLabels.delete(l.id) : selLabels.add(l.id);
|
|
127
|
+
renderLabelPick();
|
|
128
|
+
});
|
|
129
|
+
box.appendChild(chip);
|
|
130
|
+
}
|
|
131
|
+
const add = el('span', 'pick-chip', t('label.new'));
|
|
132
|
+
add.addEventListener('click', () => showLabelCreator(box));
|
|
133
|
+
box.appendChild(add);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Inline-Mini-Form zum Anlegen eines Labels (Name + echter Colorpicker)
|
|
137
|
+
function showLabelCreator(box) {
|
|
138
|
+
if (box.querySelector('.label-new')) return;
|
|
139
|
+
const form = el('span', 'label-new');
|
|
140
|
+
const color = makeColorTrigger('#4CAF50');
|
|
141
|
+
const name = document.createElement('input');
|
|
142
|
+
name.type = 'text';
|
|
143
|
+
name.placeholder = t('label.namePlaceholder');
|
|
144
|
+
name.maxLength = 40;
|
|
145
|
+
const ok = el('button', 'mini', '✓'); ok.type = 'button';
|
|
146
|
+
const cancel = el('button', 'mini', '×'); cancel.type = 'button';
|
|
147
|
+
const submit = async () => {
|
|
148
|
+
const title = name.value.trim();
|
|
149
|
+
if (!title) { name.focus(); return; }
|
|
150
|
+
const id = slugify(title);
|
|
151
|
+
const col = color.dataset.color || '#4CAF50';
|
|
152
|
+
const existing = (state.board && state.board.labels) || [];
|
|
153
|
+
const labels = existing.some(l => l.id === id)
|
|
154
|
+
? existing.map(l => l.id === id ? { ...l, title, color: col } : l)
|
|
155
|
+
: [...existing, { id, title, color: col }];
|
|
156
|
+
await actions.patchBoard({ labels });
|
|
157
|
+
selLabels.add(id);
|
|
158
|
+
renderLabelPick();
|
|
159
|
+
};
|
|
160
|
+
ok.addEventListener('click', submit);
|
|
161
|
+
name.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
|
|
162
|
+
cancel.addEventListener('click', () => renderLabelPick());
|
|
163
|
+
form.append(color, name, ok, cancel);
|
|
164
|
+
box.insertBefore(form, box.lastChild); // vor den "+ Neu"-Chip
|
|
165
|
+
name.focus();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderColorPick() {
|
|
169
|
+
const box = document.getElementById('colorPick');
|
|
170
|
+
box.textContent = '';
|
|
171
|
+
const swatches = [];
|
|
172
|
+
for (const c of CARD_COLORS) {
|
|
173
|
+
const sw = el('span', 'color-swatch' + (c ? '' : ' none'));
|
|
174
|
+
if (c) sw.style.background = c;
|
|
175
|
+
sw.dataset.color = c;
|
|
176
|
+
sw.addEventListener('click', () => { selColor = c; updateSelection(); });
|
|
177
|
+
box.appendChild(sw);
|
|
178
|
+
swatches.push(sw);
|
|
179
|
+
}
|
|
180
|
+
// Freie Farbwahl (voller Farbraum) über den eigenen Colorpicker
|
|
181
|
+
const custom = el('span', 'color-swatch custom');
|
|
182
|
+
custom.title = t('color.own');
|
|
183
|
+
custom.tabIndex = 0;
|
|
184
|
+
const openCustom = () => openColorPicker(custom, selColor, col => { selColor = col; updateSelection(); }, { presets: CARD_COLORS });
|
|
185
|
+
custom.addEventListener('click', openCustom);
|
|
186
|
+
custom.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCustom(); } });
|
|
187
|
+
box.appendChild(custom);
|
|
188
|
+
|
|
189
|
+
function updateSelection() {
|
|
190
|
+
const isCustom = selColor && !CARD_COLORS.includes(selColor);
|
|
191
|
+
for (const sw of swatches) sw.classList.toggle('selected', sw.dataset.color === selColor);
|
|
192
|
+
custom.classList.toggle('selected', !!isCustom);
|
|
193
|
+
custom.style.background = isCustom ? selColor : '';
|
|
194
|
+
}
|
|
195
|
+
updateSelection();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Checklisten-Punkte lassen sich per Anfasser umsortieren (ab 2 Punkten sichtbar).
|
|
199
|
+
function ensureChecklistSortable(box) {
|
|
200
|
+
if (!box || box._sortable || typeof Sortable === 'undefined') return;
|
|
201
|
+
// Gleiche Konfiguration wie die Spaltenliste im Board-Dialog: natives
|
|
202
|
+
// Drag & Drop. (forceFallback hängt den Drag-Klon in den Container und
|
|
203
|
+
// verschiebt damit die Trefferberechnung – der Punkt landete immer unten.)
|
|
204
|
+
box._sortable = Sortable.create(box, {
|
|
205
|
+
handle: '.ck-grip',
|
|
206
|
+
draggable: '.check-item',
|
|
207
|
+
animation: 150,
|
|
208
|
+
ghostClass: 'ck-ghost',
|
|
209
|
+
delay: 150, // Touch: kurz halten zum Ziehen, Scrollen bleibt möglich
|
|
210
|
+
delayOnTouchOnly: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function updateCheckGrips() {
|
|
214
|
+
const box = document.getElementById('checklistEdit');
|
|
215
|
+
if (box) box.classList.toggle('multi', box.querySelectorAll('.check-item').length > 1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function addCheckRow(item) {
|
|
219
|
+
const box = document.getElementById('checklistEdit');
|
|
220
|
+
const row = el('div', 'check-item');
|
|
221
|
+
const grip = el('span', 'ck-grip');
|
|
222
|
+
const gicon = mdiIcon(MDI.sortGrid);
|
|
223
|
+
gicon.setAttribute('width', '14');
|
|
224
|
+
gicon.setAttribute('height', '14');
|
|
225
|
+
grip.appendChild(gicon);
|
|
226
|
+
grip.title = t('card.checkDrag');
|
|
227
|
+
const cb = document.createElement('input');
|
|
228
|
+
cb.type = 'checkbox';
|
|
229
|
+
cb.checked = !!(item && item.done);
|
|
230
|
+
const txt = document.createElement('input');
|
|
231
|
+
txt.type = 'text';
|
|
232
|
+
txt.value = (item && item.text) || '';
|
|
233
|
+
txt.placeholder = t('card.checkPlaceholder');
|
|
234
|
+
const rm = el('button', 'rm', '×');
|
|
235
|
+
rm.type = 'button';
|
|
236
|
+
rm.addEventListener('click', () => { row.remove(); updateCheckGrips(); });
|
|
237
|
+
row.append(grip, cb, txt, rm);
|
|
238
|
+
box.appendChild(row);
|
|
239
|
+
ensureChecklistSortable(box);
|
|
240
|
+
updateCheckGrips();
|
|
241
|
+
return row;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readChecklist() {
|
|
245
|
+
return [...document.querySelectorAll('#checklistEdit .check-item')]
|
|
246
|
+
.map(row => ({
|
|
247
|
+
text: row.querySelector('input[type=text]').value.trim(),
|
|
248
|
+
done: row.querySelector('input[type=checkbox]').checked,
|
|
249
|
+
}))
|
|
250
|
+
.filter(i => i.text);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fillColumnSelect(selected) {
|
|
254
|
+
const sel = form.elements.columnId;
|
|
255
|
+
sel.textContent = '';
|
|
256
|
+
for (const c of (state.board && state.board.columns) || []) {
|
|
257
|
+
if (c.isTrash) continue; // Papierkorb ist kein wählbares Ziel
|
|
258
|
+
const o = document.createElement('option');
|
|
259
|
+
o.value = c.id;
|
|
260
|
+
o.textContent = c.title;
|
|
261
|
+
sel.appendChild(o);
|
|
262
|
+
}
|
|
263
|
+
if (selected) sel.value = selected;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isoTodayStr() {
|
|
267
|
+
const d = new Date();
|
|
268
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Markdown rendern und sicher einfuegen. Links oeffnen immer in einem neuen
|
|
273
|
+
* Tab (target=_blank + rel), damit das Board nicht verlassen wird.
|
|
274
|
+
*/
|
|
275
|
+
function renderMarkdownInto(target, text) {
|
|
276
|
+
const txt = String(text || '').trim();
|
|
277
|
+
if (!txt) { target.textContent = ''; return; }
|
|
278
|
+
// eslint-disable-next-line no-undef
|
|
279
|
+
const html = marked.parse(txt);
|
|
280
|
+
// Markdown darf rohes HTML enthalten → vor dem Einfügen säubern (verhindert gespeichertes XSS).
|
|
281
|
+
// eslint-disable-next-line no-undef
|
|
282
|
+
if (window.DOMPurify) target.innerHTML = DOMPurify.sanitize(html);
|
|
283
|
+
else { target.textContent = txt; return; } // Fallback ohne Sanitizer: nur Text
|
|
284
|
+
for (const a of target.querySelectorAll('a[href]')) {
|
|
285
|
+
if (/^(mailto:|tel:)/i.test(a.getAttribute('href') || '')) continue;
|
|
286
|
+
a.target = '_blank';
|
|
287
|
+
a.rel = 'noopener noreferrer';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Beschreibung nur lesend anzeigen (Klick auf das Notiz-Icon der Karte).
|
|
292
|
+
function openDescription(cardId) {
|
|
293
|
+
const card = state.board && state.board.cards.find(c => c.id === cardId);
|
|
294
|
+
if (!card || !card.description) return;
|
|
295
|
+
const dd = document.getElementById('descDialog');
|
|
296
|
+
const body = document.getElementById('descDialogBody');
|
|
297
|
+
body.textContent = '';
|
|
298
|
+
body.appendChild(el('h3', 'desc-title', card.title || ''));
|
|
299
|
+
const md = el('div', 'md-preview desc-body');
|
|
300
|
+
renderMarkdownInto(md, card.description);
|
|
301
|
+
body.appendChild(md);
|
|
302
|
+
const foot = el('div', 'desc-foot');
|
|
303
|
+
const close = el('button', null, t('desc.close'));
|
|
304
|
+
close.type = 'button';
|
|
305
|
+
close.addEventListener('click', () => dd.close());
|
|
306
|
+
foot.appendChild(close);
|
|
307
|
+
body.appendChild(foot);
|
|
308
|
+
// Klick auf den Bereich ausserhalb (der Dialog selbst fuellt nur den
|
|
309
|
+
// Rahmen, der Inhalt liegt in #descDialogBody) schliesst das Fenster.
|
|
310
|
+
if (!dd.dataset.wired) {
|
|
311
|
+
dd.dataset.wired = '1';
|
|
312
|
+
dd.addEventListener('mousedown', ev => { if (ev.target === dd) dd.close(); });
|
|
313
|
+
}
|
|
314
|
+
dd.showModal();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Feature 3: erledigte Karte kopieren → Editor als NEUE Karte mit gleichen Inhalten.
|
|
318
|
+
function copyCard(cardId) {
|
|
319
|
+
const src = state.board && state.board.cards.find(c => c.id === cardId);
|
|
320
|
+
if (!src) return;
|
|
321
|
+
editingCardId = null;
|
|
322
|
+
document.getElementById('cardDialogTitle').textContent = t('card.titleNew');
|
|
323
|
+
document.getElementById('deleteCardBtn').hidden = true;
|
|
324
|
+
document.getElementById('transferCardBtn').hidden = true;
|
|
325
|
+
form.elements.title.value = src.title || '';
|
|
326
|
+
form.elements.description.value = src.description || '';
|
|
327
|
+
// Fälligkeit: heutiges Datum, wenn das Original eins hatte (Uhrzeit behalten), sonst leer
|
|
328
|
+
const hadDue = !!src.due;
|
|
329
|
+
form.elements.due.value = hadDue ? isoTodayStr() : '';
|
|
330
|
+
form.elements.dueTime.value = (hadDue && src.dueTime) ? src.dueTime : '';
|
|
331
|
+
form.elements.dueTimeEnabled.checked = !!(hadDue && src.dueTime);
|
|
332
|
+
updateDueTimeUI();
|
|
333
|
+
form.elements.priority.value = String(src.priority || 0);
|
|
334
|
+
form.elements.link.value = src.link || '';
|
|
335
|
+
form.elements.location.value = src.location || '';
|
|
336
|
+
form.elements.calendarInvite.checked = !!src.calendarInvite;
|
|
337
|
+
form.elements.calendarDuration.value = src.calendarDuration || '01:00';
|
|
338
|
+
updateCalDurUI();
|
|
339
|
+
// Zielspalte: erste „Neu"-Spalte, sonst erste normale Spalte
|
|
340
|
+
const cols = (state.board.columns || []).filter(c => !c.isTrash);
|
|
341
|
+
const target = cols.find(c => c.allowAdd) || cols[0];
|
|
342
|
+
fillColumnSelect(target && target.id);
|
|
343
|
+
selAssignees = new Set(src.assignees || []);
|
|
344
|
+
selLabels = new Set(src.labels || []);
|
|
345
|
+
selColor = src.color || '';
|
|
346
|
+
renderAssigneePick();
|
|
347
|
+
renderLabelPick();
|
|
348
|
+
renderColorPick();
|
|
349
|
+
const box = document.getElementById('checklistEdit');
|
|
350
|
+
box.textContent = '';
|
|
351
|
+
for (const item of (src.checklist || [])) addCheckRow({ text: item.text, done: false });
|
|
352
|
+
updateCheckGrips();
|
|
353
|
+
loadRecurrence(src.recurrence);
|
|
354
|
+
updatePreview();
|
|
355
|
+
dlg.showModal();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function openCard(cardId, defaultColumnId) {
|
|
359
|
+
const card = cardId && state.board ? state.board.cards.find(c => c.id === cardId) : null;
|
|
360
|
+
editingCardId = card ? card.id : null;
|
|
361
|
+
document.getElementById('cardDialogTitle').textContent = card ? t('card.titleEdit') : t('card.titleNew');
|
|
362
|
+
document.getElementById('deleteCardBtn').hidden = !card;
|
|
363
|
+
// Auch bei nur einem Board sinnvoll: der Dialog kann die Karte klonen
|
|
364
|
+
document.getElementById('transferCardBtn').hidden = !card;
|
|
365
|
+
form.elements.title.value = card ? card.title : '';
|
|
366
|
+
form.elements.description.value = card ? card.description : '';
|
|
367
|
+
form.elements.due.value = (card && card.due) || '';
|
|
368
|
+
form.elements.dueTime.value = (card && card.dueTime) || '';
|
|
369
|
+
form.elements.dueTimeEnabled.checked = !!(card && card.dueTime);
|
|
370
|
+
updateDueTimeUI();
|
|
371
|
+
form.elements.priority.value = String((card && card.priority) || 0);
|
|
372
|
+
form.elements.link.value = (card && card.link) || '';
|
|
373
|
+
form.elements.location.value = (card && card.location) || '';
|
|
374
|
+
form.elements.calendarInvite.checked = !!(card && card.calendarInvite);
|
|
375
|
+
form.elements.calendarDuration.value = (card && card.calendarDuration) || '01:00';
|
|
376
|
+
updateCalDurUI();
|
|
377
|
+
fillColumnSelect(card ? card.columnId : defaultColumnId);
|
|
378
|
+
selAssignees = new Set(card ? card.assignees : []);
|
|
379
|
+
selLabels = new Set(card ? card.labels : []);
|
|
380
|
+
selColor = (card && card.color) || '';
|
|
381
|
+
renderAssigneePick();
|
|
382
|
+
renderLabelPick();
|
|
383
|
+
renderColorPick();
|
|
384
|
+
const box = document.getElementById('checklistEdit');
|
|
385
|
+
box.textContent = '';
|
|
386
|
+
for (const item of (card && card.checklist) || []) addCheckRow(item);
|
|
387
|
+
updateCheckGrips();
|
|
388
|
+
loadRecurrence(card && card.recurrence);
|
|
389
|
+
updatePreview();
|
|
390
|
+
dlg.showModal();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- Wiederholung ------------------------------------------------------
|
|
394
|
+
let selWeekdays = new Set();
|
|
395
|
+
|
|
396
|
+
function renderWeekdayPick() {
|
|
397
|
+
const box = document.getElementById('recWeekdayWrap');
|
|
398
|
+
box.textContent = '';
|
|
399
|
+
// Bei "Monatlich (Wochentag)" nur EIN Wochentag, sonst Mehrfachauswahl.
|
|
400
|
+
const single = form.elements.recType.value === 'monthly_weekday';
|
|
401
|
+
for (const [, iso] of WEEKDAYS) {
|
|
402
|
+
const chip = el('span', 'pick-chip' + (selWeekdays.has(iso) ? ' selected' : ''), t('weekday.' + iso));
|
|
403
|
+
chip.addEventListener('click', () => {
|
|
404
|
+
if (single) selWeekdays = new Set([iso]);
|
|
405
|
+
else if (selWeekdays.has(iso)) selWeekdays.delete(iso);
|
|
406
|
+
else selWeekdays.add(iso);
|
|
407
|
+
renderWeekdayPick();
|
|
408
|
+
updateRecUI();
|
|
409
|
+
});
|
|
410
|
+
box.appendChild(chip);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function loadRecurrence(rec) {
|
|
415
|
+
rec = rec || {};
|
|
416
|
+
form.elements.recType.value = rec.type || 'none';
|
|
417
|
+
form.elements.recInterval.value = rec.interval || 2;
|
|
418
|
+
form.elements.recDom.value = rec.dayOfMonth || (new Date().getDate());
|
|
419
|
+
form.elements.recMonth.value = String(rec.month || (new Date().getMonth() + 1));
|
|
420
|
+
form.elements.recOrdinal.value = String(rec.ordinal || 1);
|
|
421
|
+
form.elements.recWorkdayPos.value = rec.workdayPos || 'first';
|
|
422
|
+
form.elements.recWorkdayN.value = rec.n || 1;
|
|
423
|
+
form.elements.recCron.value = rec.cron || '';
|
|
424
|
+
const initWd = (rec.dayOfWeek && rec.dayOfWeek.length) ? rec.dayOfWeek : [isoToday()];
|
|
425
|
+
selWeekdays = new Set(rec.type === 'monthly_weekday' ? [initWd[0]] : initWd);
|
|
426
|
+
renderWeekdayPick();
|
|
427
|
+
updateRecUI();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isoToday() { const d = new Date().getDay(); return d === 0 ? 7 : d; }
|
|
431
|
+
|
|
432
|
+
function updateRecUI() {
|
|
433
|
+
const type = form.elements.recType.value;
|
|
434
|
+
const pos = form.elements.recWorkdayPos.value;
|
|
435
|
+
document.getElementById('recWeekdayWrap').hidden = !(type === 'weekly' || type === 'monthly_weekday');
|
|
436
|
+
document.getElementById('recIntervalWrap').hidden = type !== 'every_n_days';
|
|
437
|
+
document.getElementById('recDomWrap').hidden = !(type === 'monthly' || type === 'yearly');
|
|
438
|
+
document.getElementById('recMonthWrap').hidden = type !== 'yearly';
|
|
439
|
+
document.getElementById('recOrdinalWrap').hidden = type !== 'monthly_weekday';
|
|
440
|
+
document.getElementById('recWorkdayPosWrap').hidden = type !== 'workday';
|
|
441
|
+
document.getElementById('recWorkdayNWrap').hidden = !(type === 'workday' && (pos === 'nth' || pos === 'nth_last'));
|
|
442
|
+
document.getElementById('recCronWrap').hidden = type !== 'cron';
|
|
443
|
+
document.getElementById('recCronInfo').hidden = type !== 'cron';
|
|
444
|
+
// Bei Cron gibt das Muster die Uhrzeit vor — ein frei editierbares Feld
|
|
445
|
+
// wuerde etwas anderes anzeigen als die Karte spaeter bekommt.
|
|
446
|
+
form.elements.dueTimeEnabled.disabled = type === 'cron';
|
|
447
|
+
form.elements.dueTime.readOnly = type === 'cron';
|
|
448
|
+
if (type === 'cron') scheduleCronCheck();
|
|
449
|
+
const hint = document.getElementById('recHint');
|
|
450
|
+
const txt = type === 'none' ? '' : t('rec.hint.' + type);
|
|
451
|
+
hint.hidden = !txt;
|
|
452
|
+
hint.textContent = txt || '';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
on('recType', 'change', () => { renderWeekdayPick(); updateRecUI(); });
|
|
456
|
+
on('recWorkdayPos', 'change', updateRecUI);
|
|
457
|
+
on('recCron', 'input', scheduleCronCheck);
|
|
458
|
+
|
|
459
|
+
// ---- Cron: Klartext und naechste Termine -------------------------------
|
|
460
|
+
let cronTimer = null;
|
|
461
|
+
function scheduleCronCheck() {
|
|
462
|
+
clearTimeout(cronTimer);
|
|
463
|
+
cronTimer = setTimeout(runCronCheck, 300);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const CRON_FIELDS = ['cron.minute', 'cron.hour', 'cron.day', 'cron.month', 'cron.weekday'];
|
|
467
|
+
const CRON_DOW = ['cron.sun', 'cron.mon', 'cron.tue', 'cron.wed', 'cron.thu', 'cron.fri', 'cron.sat'];
|
|
468
|
+
|
|
469
|
+
/** Muster in Worte fassen. Gaengige Faelle als Satz, alles Weitere feldweise —
|
|
470
|
+
* eine erzwungene Satzform waere bei '1-5/2' irrefuehrender als die Aufzaehlung. */
|
|
471
|
+
function cronText(expr) {
|
|
472
|
+
const p = expr.trim().split(/\s+/);
|
|
473
|
+
if (p.length !== 5) return '';
|
|
474
|
+
const [mi, ho, dom, mon, dow] = p;
|
|
475
|
+
const time = /^\d+$/.test(mi) && /^\d+$/.test(ho)
|
|
476
|
+
? `${String(ho).padStart(2, '0')}:${String(mi).padStart(2, '0')}` : null;
|
|
477
|
+
const named = n => t(CRON_DOW[Number(n) % 7]);
|
|
478
|
+
const simple = mon === '*' && (dom === '*' || dow === '*');
|
|
479
|
+
if (time && simple) {
|
|
480
|
+
if (dom === '*' && dow === '*') return t('cron.everyDayAt', { time });
|
|
481
|
+
if (dow === '*') {
|
|
482
|
+
if (/^\d+$/.test(dom)) return t('cron.monthlyAt', { day: dom, time });
|
|
483
|
+
} else if (/^\d+-\d+$/.test(dow)) {
|
|
484
|
+
const [a, b] = dow.split('-');
|
|
485
|
+
return t('cron.rangeAt', { from: named(a), to: named(b), time });
|
|
486
|
+
} else if (/^\d+(,\d+)*$/.test(dow)) {
|
|
487
|
+
return t('cron.daysAt', { days: dow.split(',').map(named).join(', '), time });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return p.map((v, i) => `${t(CRON_FIELDS[i])} ${v}`).join(' · ');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function runCronCheck() {
|
|
494
|
+
const box = document.getElementById('recCronInfo');
|
|
495
|
+
const expr = form.elements.recCron.value.trim();
|
|
496
|
+
box.textContent = '';
|
|
497
|
+
if (!expr) { box.appendChild(el('span', 'hint', t('cron.empty'))); return; }
|
|
498
|
+
let res;
|
|
499
|
+
try { res = await api('api/cron/check?expr=' + encodeURIComponent(expr)); }
|
|
500
|
+
catch (e) { box.appendChild(el('span', 'cron-bad', e.message)); return; }
|
|
501
|
+
if (!res.ok) { box.appendChild(el('span', 'cron-bad', res.error)); return; }
|
|
502
|
+
box.appendChild(el('div', 'cron-good', cronText(expr)));
|
|
503
|
+
if (res.next && res.next.length) {
|
|
504
|
+
box.appendChild(el('div', 'cron-next', t('cron.next', { dates: res.next.map(fmtDateShort).join(' · ') })));
|
|
505
|
+
}
|
|
506
|
+
// Uhrzeit der Karte an das Muster angleichen
|
|
507
|
+
if (res.time) {
|
|
508
|
+
form.elements.dueTime.value = res.time;
|
|
509
|
+
form.elements.dueTimeEnabled.checked = true;
|
|
510
|
+
document.getElementById('dueTimeWrap').hidden = false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function fmtDateShort(iso) {
|
|
515
|
+
const [y, m, d] = iso.split('-');
|
|
516
|
+
return `${d}.${m}.${y}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function readRecurrence() {
|
|
520
|
+
const t = form.elements.recType.value;
|
|
521
|
+
if (t === 'none') return null;
|
|
522
|
+
const rec = { type: t };
|
|
523
|
+
if (t === 'weekly') rec.dayOfWeek = [...selWeekdays].sort((a, b) => a - b);
|
|
524
|
+
if (t === 'monthly' || t === 'yearly') rec.dayOfMonth = Number(form.elements.recDom.value) || 1;
|
|
525
|
+
if (t === 'yearly') rec.month = Number(form.elements.recMonth.value) || 1;
|
|
526
|
+
if (t === 'monthly_weekday') {
|
|
527
|
+
rec.dayOfWeek = [[...selWeekdays][0] || isoToday()];
|
|
528
|
+
rec.ordinal = Number(form.elements.recOrdinal.value) || 1;
|
|
529
|
+
}
|
|
530
|
+
if (t === 'workday') {
|
|
531
|
+
rec.workdayPos = form.elements.recWorkdayPos.value || 'first';
|
|
532
|
+
if (rec.workdayPos === 'nth' || rec.workdayPos === 'nth_last') {
|
|
533
|
+
rec.n = Math.max(1, Number(form.elements.recWorkdayN.value) || 1);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (t === 'cron') rec.cron = form.elements.recCron.value.trim();
|
|
537
|
+
if (t === 'every_n_days') {
|
|
538
|
+
rec.interval = Math.max(1, Number(form.elements.recInterval.value) || 1);
|
|
539
|
+
rec.startDate = form.elements.due.value || null; // Referenzpunkt = Fälligkeit
|
|
540
|
+
}
|
|
541
|
+
return rec;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function updateDueTimeUI() {
|
|
545
|
+
document.getElementById('dueTimeWrap').hidden = !form.elements.dueTimeEnabled.checked;
|
|
546
|
+
}
|
|
547
|
+
// Termindauer nur zeigen, wenn eine Kalender-Einladung angehängt wird
|
|
548
|
+
function updateCalDurUI() {
|
|
549
|
+
const wrap = document.getElementById('calDurWrap');
|
|
550
|
+
if (wrap) wrap.hidden = !form.elements.calendarInvite.checked;
|
|
551
|
+
}
|
|
552
|
+
on('calendarInvite', 'change', () => {
|
|
553
|
+
updateCalDurUI();
|
|
554
|
+
if (form.elements.calendarInvite.checked && !form.elements.calendarDuration.value) {
|
|
555
|
+
form.elements.calendarDuration.value = '01:00';
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
on('dueTimeEnabled', 'change', () => {
|
|
559
|
+
updateDueTimeUI();
|
|
560
|
+
if (form.elements.dueTimeEnabled.checked && !form.elements.dueTime.value) {
|
|
561
|
+
form.elements.dueTime.value = '09:00'; // sinnvoller Default
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
function updatePreview() {
|
|
566
|
+
const prev = document.getElementById('descPreview');
|
|
567
|
+
const txt = form.elements.description.value.trim();
|
|
568
|
+
if (!txt) { prev.hidden = true; return; }
|
|
569
|
+
prev.hidden = false;
|
|
570
|
+
renderMarkdownInto(prev, txt);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
on('description', 'input', updatePreview);
|
|
574
|
+
document.getElementById('addCheckItem').addEventListener('click', () => {
|
|
575
|
+
addCheckRow().querySelector('input[type=text]').focus();
|
|
576
|
+
});
|
|
577
|
+
document.getElementById('cancelCardBtn').addEventListener('click', () => dlg.close());
|
|
578
|
+
document.getElementById('deleteCardBtn').addEventListener('click', async () => {
|
|
579
|
+
if (!editingCardId) return;
|
|
580
|
+
if (!await confirmDialog({ title: t('card.delete'), message: t('confirm.deleteCard'), danger: true, ok: t('card.delete') })) return;
|
|
581
|
+
await actions.deleteCard(editingCardId);
|
|
582
|
+
dlg.close();
|
|
583
|
+
});
|
|
584
|
+
document.getElementById('transferCardBtn').addEventListener('click', () => {
|
|
585
|
+
if (editingCardId) openTransfer(editingCardId);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Titel-Pflichtmeldung in Board-Sprache statt Browser-Standardtext.
|
|
589
|
+
form.elements.title.addEventListener('invalid', () => form.elements.title.setCustomValidity(t('card.titleRequired')));
|
|
590
|
+
form.elements.title.addEventListener('input', () => form.elements.title.setCustomValidity(''));
|
|
591
|
+
|
|
592
|
+
form.addEventListener('submit', async ev => {
|
|
593
|
+
ev.preventDefault();
|
|
594
|
+
const data = {
|
|
595
|
+
title: form.elements.title.value.trim(),
|
|
596
|
+
description: form.elements.description.value,
|
|
597
|
+
due: form.elements.due.value,
|
|
598
|
+
dueTime: (form.elements.dueTimeEnabled.checked && form.elements.due.value)
|
|
599
|
+
? form.elements.dueTime.value : '',
|
|
600
|
+
priority: Number(form.elements.priority.value),
|
|
601
|
+
link: form.elements.link.value.trim(),
|
|
602
|
+
location: form.elements.location.value.trim(),
|
|
603
|
+
calendarInvite: form.elements.calendarInvite.checked,
|
|
604
|
+
calendarDuration: form.elements.calendarDuration.value || '01:00',
|
|
605
|
+
assignees: [...selAssignees],
|
|
606
|
+
labels: [...selLabels],
|
|
607
|
+
color: selColor,
|
|
608
|
+
checklist: readChecklist(),
|
|
609
|
+
recurrence: readRecurrence(),
|
|
610
|
+
columnId: form.elements.columnId.value,
|
|
611
|
+
};
|
|
612
|
+
if (!data.title) return;
|
|
613
|
+
if (!data.assignees.length) { updateAssigneeValidity(); const p = document.getElementById('assigneeValidity'); if (p) p.reportValidity(); return; }
|
|
614
|
+
try {
|
|
615
|
+
if (editingCardId) {
|
|
616
|
+
await actions.updateCard(editingCardId, data);
|
|
617
|
+
const card = state.board.cards.find(c => c.id === editingCardId);
|
|
618
|
+
if (card && card.columnId !== data.columnId) {
|
|
619
|
+
await actions.moveCard(editingCardId, data.columnId);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
await actions.addCard(data);
|
|
623
|
+
}
|
|
624
|
+
dlg.close();
|
|
625
|
+
} catch (e) {
|
|
626
|
+
alert(t('error.saveFailed', { msg: e.message }));
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------- Board-Verwaltung
|
|
631
|
+
|
|
632
|
+
const bdlg = document.getElementById('boardDialog');
|
|
633
|
+
let bdlgCancel = null; // Escape-Handler des Board-Dialogs (haengt an der Closure)
|
|
634
|
+
|
|
635
|
+
async function openBoardManager() {
|
|
636
|
+
const body = document.getElementById('boardDialogBody');
|
|
637
|
+
body.textContent = '';
|
|
638
|
+
body.appendChild(el('h3', null, t('settings.title')));
|
|
639
|
+
|
|
640
|
+
const tabbar = el('div', 'tabbar');
|
|
641
|
+
const panels = el('div', 'tab-panels');
|
|
642
|
+
body.append(tabbar, panels);
|
|
643
|
+
const tabs = [];
|
|
644
|
+
const activate = id => {
|
|
645
|
+
for (const tb of tabs) { const on = tb.id === id; tb.btn.classList.toggle('active', on); tb.panel.hidden = !on; }
|
|
646
|
+
};
|
|
647
|
+
const addTab = (id, label, build) => {
|
|
648
|
+
const btn = el('button', 'tab-btn', label); btn.type = 'button';
|
|
649
|
+
const panel = el('div', 'tab-panel'); panel.hidden = true;
|
|
650
|
+
build(panel);
|
|
651
|
+
btn.addEventListener('click', () => activate(id));
|
|
652
|
+
tabbar.appendChild(btn); panels.appendChild(panel);
|
|
653
|
+
tabs.push({ id, btn, panel });
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// Bearbeitetes Board (Dropdown) - unabhaengig vom aktiven Board
|
|
657
|
+
let editId = (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '';
|
|
658
|
+
let editBoard = null;
|
|
659
|
+
let dirty = false;
|
|
660
|
+
let titleInput = null, colBox = null, labelBox = null, linkTargetSel = null, linkUrlInput = null;
|
|
661
|
+
let cleanupModeSel = null, cleanupDaysInp = null, cleanupCountInp = null, memberWrap = null;
|
|
662
|
+
let saveBtn = null, boardPanel = null, boardSel = null;
|
|
663
|
+
|
|
664
|
+
const validateMembers = () => {
|
|
665
|
+
if (!saveBtn) return;
|
|
666
|
+
const bad = !!memberWrap && memberWrap.querySelectorAll('input:checked').length === 0;
|
|
667
|
+
saveBtn.disabled = bad;
|
|
668
|
+
saveBtn.title = bad ? t('boards.membersRequiredShort') : '';
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
async function loadEdit(id) {
|
|
672
|
+
// Ohne id wuerde die Anfrage auf der Board-LISTE landen: ein Array ohne
|
|
673
|
+
// .title, das dann als "undefined" im Titelfeld erscheint.
|
|
674
|
+
if (!id) { editBoard = null; return; }
|
|
675
|
+
try {
|
|
676
|
+
const b = await api(`api/boards/${encodeURIComponent(id)}`);
|
|
677
|
+
editBoard = b && b.id ? b : null;
|
|
678
|
+
} catch (e) { editBoard = null; }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Aenderungen des bearbeiteten Boards einsammeln und speichern
|
|
682
|
+
async function saveEdit() {
|
|
683
|
+
if (!editBoard) return true;
|
|
684
|
+
const members = memberWrap ? [...memberWrap.querySelectorAll('input:checked')].map(i => i.dataset.val) : undefined;
|
|
685
|
+
if (memberWrap && !members.length) {
|
|
686
|
+
await confirmDialog({ title: t('boards.members'), message: t('boards.membersRequiredShort'), ok: t('confirm.ok') });
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
// Wer aus der Mitgliederliste fliegt, bleibt auf seinen Karten stehen —
|
|
690
|
+
// die tauchen dann aber nicht mehr im Personenfilter auf. Vorher fragen.
|
|
691
|
+
if (members) {
|
|
692
|
+
const dropped = (editBoard.members || []).filter(n => !members.includes(n));
|
|
693
|
+
if (dropped.length) {
|
|
694
|
+
const trashIds = (editBoard.columns || []).filter(c => c.isTrash).map(c => c.id);
|
|
695
|
+
const orphans = (editBoard.cards || []).filter(c =>
|
|
696
|
+
!trashIds.includes(c.columnId)
|
|
697
|
+
&& (c.assignees || []).length
|
|
698
|
+
&& (c.assignees || []).every(a => dropped.includes(a)));
|
|
699
|
+
if (orphans.length) {
|
|
700
|
+
const names = dropped.map(n => {
|
|
701
|
+
const u = (state.users || []).find(x => x.name === n);
|
|
702
|
+
return (u && u.displayName) || n;
|
|
703
|
+
}).join(', ');
|
|
704
|
+
const go = await confirmDialog({
|
|
705
|
+
title: t('boards.memberRemoveTitle'),
|
|
706
|
+
message: t('boards.memberRemoveMsg', { count: orphans.length, names }),
|
|
707
|
+
ok: t('confirm.ok'),
|
|
708
|
+
});
|
|
709
|
+
if (go !== true) return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const columns = [...colBox.children].map(row => ({
|
|
714
|
+
id: row.dataset.colId || undefined,
|
|
715
|
+
title: row.querySelector('input[type=text]').value.trim() || '?',
|
|
716
|
+
maxVisible: Number(row.querySelector('.col-max').value) || 0,
|
|
717
|
+
wipLimit: Number(row.querySelector('.col-wip').value) || 0,
|
|
718
|
+
isDone: row.querySelector('.col-done').checked,
|
|
719
|
+
allowAdd: row.querySelector('.col-add').checked,
|
|
720
|
+
}));
|
|
721
|
+
const labels = [...labelBox.children].map(row => {
|
|
722
|
+
const title = row.querySelector('input[type=text]').value.trim();
|
|
723
|
+
if (!title) return null;
|
|
724
|
+
return {
|
|
725
|
+
id: row.dataset.labelId || slugify(title),
|
|
726
|
+
title,
|
|
727
|
+
color: row.querySelector('.cp-trigger').dataset.color || '#4CAF50',
|
|
728
|
+
};
|
|
729
|
+
}).filter(Boolean);
|
|
730
|
+
await actions.patchBoardById(editBoard.id, {
|
|
731
|
+
title: titleInput.value.trim() || editBoard.title,
|
|
732
|
+
columns, labels,
|
|
733
|
+
linkTarget: linkTargetSel ? linkTargetSel.value : undefined,
|
|
734
|
+
linkUrl: linkUrlInput ? linkUrlInput.value.trim() : undefined,
|
|
735
|
+
cleanup: cleanupModeSel ? { mode: cleanupModeSel.value, days: Number(cleanupDaysInp.value) || 90, count: Number(cleanupCountInp.value) || 100 } : undefined,
|
|
736
|
+
members,
|
|
737
|
+
});
|
|
738
|
+
dirty = false;
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Beim Wechsel des bearbeiteten Boards nach ungespeicherten Aenderungen fragen
|
|
743
|
+
async function guardUnsaved() {
|
|
744
|
+
if (!dirty) return true;
|
|
745
|
+
const r = await confirmDialog({
|
|
746
|
+
title: t('boards.unsavedTitle'), message: t('boards.unsavedMsg'),
|
|
747
|
+
ok: t('boards.save'), extra: t('boards.discard'),
|
|
748
|
+
});
|
|
749
|
+
if (r === true) return await saveEdit();
|
|
750
|
+
if (r === 'extra') { dirty = false; return true; }
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function switchEdit(id) {
|
|
755
|
+
if (!await guardUnsaved()) { if (boardSel) boardSel.value = editId; return; }
|
|
756
|
+
editId = id;
|
|
757
|
+
await loadEdit(editId);
|
|
758
|
+
buildBoardPanel(boardPanel);
|
|
759
|
+
validateMembers();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function buildBoardPanel(panel) {
|
|
763
|
+
panel.textContent = '';
|
|
764
|
+
dirty = false;
|
|
765
|
+
titleInput = colBox = labelBox = linkTargetSel = linkUrlInput = null;
|
|
766
|
+
cleanupModeSel = cleanupDaysInp = cleanupCountInp = memberWrap = null;
|
|
767
|
+
|
|
768
|
+
// ---- Kopfzeile: Board-Auswahl | Anzeigen | neues Board | Anlegen ----
|
|
769
|
+
// Wird auch ohne Board gebaut — sonst gaebe es keinen Weg, das erste anzulegen.
|
|
770
|
+
const topRow = el('div', 'row board-toprow');
|
|
771
|
+
boardSel = document.createElement('select');
|
|
772
|
+
for (const b of state.boards) {
|
|
773
|
+
const o = document.createElement('option');
|
|
774
|
+
o.value = b.id; o.textContent = b.title;
|
|
775
|
+
boardSel.appendChild(o);
|
|
776
|
+
}
|
|
777
|
+
boardSel.value = editId;
|
|
778
|
+
boardSel.title = t('boards.editingBoard');
|
|
779
|
+
boardSel.dataset.noDirty = '1'; // Board-Wechsel ist keine Aenderung am Board
|
|
780
|
+
boardSel.disabled = !state.boards.length;
|
|
781
|
+
boardSel.addEventListener('change', () => switchEdit(boardSel.value));
|
|
782
|
+
|
|
783
|
+
const showBtn = el('button', 'icon-btn');
|
|
784
|
+
showBtn.type = 'button';
|
|
785
|
+
showBtn.appendChild(mdiIcon(MDI.arrowRightCircle));
|
|
786
|
+
showBtn.title = t('boards.showBoard');
|
|
787
|
+
showBtn.setAttribute('aria-label', showBtn.title);
|
|
788
|
+
showBtn.disabled = !editBoard || !!(state.board && state.board.id === editId);
|
|
789
|
+
showBtn.addEventListener('click', async () => {
|
|
790
|
+
if (state.board && state.board.id === editId) return;
|
|
791
|
+
const b = state.boards.find(x => x.id === editId);
|
|
792
|
+
if (!await confirmDialog({ title: t('boards.showBoard'), message: t('boards.showConfirm', { title: (b && b.title) || editId }), ok: t('boards.showBoard') })) return;
|
|
793
|
+
await actions.switchBoard(editId);
|
|
794
|
+
showBtn.disabled = true;
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const newInput = document.createElement('input');
|
|
798
|
+
newInput.type = 'text';
|
|
799
|
+
newInput.placeholder = t('boards.newBoard');
|
|
800
|
+
newInput.className = 'board-new-name';
|
|
801
|
+
newInput.dataset.noDirty = '1'; // Name fuer ein NEUES Board, keine Aenderung am aktuellen
|
|
802
|
+
const newBtn = el('button', 'primary', t('boards.create'));
|
|
803
|
+
newBtn.type = 'button';
|
|
804
|
+
newBtn.addEventListener('click', async () => {
|
|
805
|
+
const title = newInput.value.trim();
|
|
806
|
+
if (!title) { newInput.focus(); return; }
|
|
807
|
+
if (!await guardUnsaved()) return;
|
|
808
|
+
const created = await actions.createBoardOnly(title);
|
|
809
|
+
newInput.value = '';
|
|
810
|
+
editId = created.id;
|
|
811
|
+
await loadEdit(editId);
|
|
812
|
+
buildBoardPanel(boardPanel);
|
|
813
|
+
validateMembers();
|
|
814
|
+
});
|
|
815
|
+
topRow.append(boardSel, showBtn, newInput, newBtn);
|
|
816
|
+
panel.appendChild(topRow);
|
|
817
|
+
|
|
818
|
+
// Noch kein Board: nur die Kopfzeile zum Anlegen zeigen, alles Weitere
|
|
819
|
+
// braucht ein Board.
|
|
820
|
+
if (!editBoard) { panel.appendChild(el('div', 'hint', t('boards.noneYet'))); return; }
|
|
821
|
+
|
|
822
|
+
// ---- Board-Titel ----
|
|
823
|
+
const titleLabel = el('label', null, t('boards.boardTitle'));
|
|
824
|
+
titleInput = document.createElement('input');
|
|
825
|
+
titleInput.type = 'text';
|
|
826
|
+
titleInput.value = editBoard.title;
|
|
827
|
+
titleLabel.appendChild(titleInput);
|
|
828
|
+
panel.appendChild(titleLabel);
|
|
829
|
+
|
|
830
|
+
// ---- Mitglieder (zuweisbare Benutzer) ----
|
|
831
|
+
if ((state.users || []).length) {
|
|
832
|
+
panel.appendChild(el('label', null, t('boards.members')));
|
|
833
|
+
panel.appendChild(el('div', 'hint', t('boards.membersHint')));
|
|
834
|
+
memberWrap = el('div', 'share-cols');
|
|
835
|
+
const cur = Array.isArray(editBoard.members) ? editBoard.members : [];
|
|
836
|
+
for (const u of state.users) {
|
|
837
|
+
const lab = el('label', 'inline');
|
|
838
|
+
const inp = document.createElement('input');
|
|
839
|
+
inp.type = 'checkbox'; inp.dataset.val = u.name; inp.checked = cur.includes(u.name);
|
|
840
|
+
inp.addEventListener('change', validateMembers);
|
|
841
|
+
lab.append(inp, document.createTextNode(' ' + (u.displayName || u.name)));
|
|
842
|
+
memberWrap.appendChild(lab);
|
|
843
|
+
}
|
|
844
|
+
panel.appendChild(memberWrap);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ---- Spalten ----
|
|
848
|
+
panel.appendChild(el('label', null, t('boards.columns')));
|
|
849
|
+
const chead = el('div', 'col-head');
|
|
850
|
+
const hcell = (cls, label, tip) => { const s = el('span', cls, label); s.title = tip; return s; };
|
|
851
|
+
chead.append(
|
|
852
|
+
el('span', 'h-drag'),
|
|
853
|
+
hcell('h-name', t('boards.colTitle'), t('boards.legendTitle')),
|
|
854
|
+
hcell('h-num', t('boards.colMax'), t('boards.legendMax')),
|
|
855
|
+
hcell('h-num', t('boards.colWip'), t('boards.legendWip')),
|
|
856
|
+
hcell('h-new', t('boards.allowAdd'), t('boards.legendAdd')),
|
|
857
|
+
hcell('h-done', t('boards.done'), t('boards.legendDone')),
|
|
858
|
+
el('span', 'h-rm'),
|
|
859
|
+
);
|
|
860
|
+
panel.appendChild(chead);
|
|
861
|
+
colBox = el('div');
|
|
862
|
+
colBox.style.cssText = 'display:flex;flex-direction:column;gap:6px';
|
|
863
|
+
const mkColRow = (col, i) => {
|
|
864
|
+
const row = el('div', 'col-edit');
|
|
865
|
+
row.dataset.colId = col.id || '';
|
|
866
|
+
const drag = el('span', 'drag', '⠳');
|
|
867
|
+
const name = document.createElement('input');
|
|
868
|
+
name.type = 'text';
|
|
869
|
+
name.value = col.title;
|
|
870
|
+
name.placeholder = t('boards.columnName');
|
|
871
|
+
name.title = t('boards.legendTitle');
|
|
872
|
+
const max = document.createElement('input');
|
|
873
|
+
max.type = 'number'; max.min = '0';
|
|
874
|
+
max.value = String(col.maxVisible || 0);
|
|
875
|
+
max.title = t('boards.maxTitle');
|
|
876
|
+
max.className = 'col-max';
|
|
877
|
+
const wip = document.createElement('input');
|
|
878
|
+
wip.type = 'number'; wip.min = '0';
|
|
879
|
+
wip.value = String(col.wipLimit || 0);
|
|
880
|
+
wip.title = t('boards.wipTitle');
|
|
881
|
+
wip.className = 'col-wip';
|
|
882
|
+
const doneLbl = el('label', 'col-chk c-done');
|
|
883
|
+
const done = document.createElement('input');
|
|
884
|
+
done.type = 'checkbox'; done.className = 'col-done';
|
|
885
|
+
done.checked = !!col.isDone;
|
|
886
|
+
done.title = t('boards.legendDone'); done.setAttribute('aria-label', t('boards.done'));
|
|
887
|
+
doneLbl.appendChild(done);
|
|
888
|
+
const addLbl = el('label', 'col-chk c-new');
|
|
889
|
+
const addChk = document.createElement('input');
|
|
890
|
+
addChk.type = 'checkbox'; addChk.className = 'col-add';
|
|
891
|
+
addChk.checked = (typeof col.allowAdd === 'boolean') ? col.allowAdd : (i === 0);
|
|
892
|
+
addChk.title = t('boards.legendAdd'); addChk.setAttribute('aria-label', t('boards.allowAdd'));
|
|
893
|
+
addLbl.appendChild(addChk);
|
|
894
|
+
const rm = el('button', 'rm', '×');
|
|
895
|
+
rm.type = 'button';
|
|
896
|
+
rm.title = t('boards.deleteColumnTitle');
|
|
897
|
+
rm.addEventListener('click', () => { row.remove(); dirty = true; });
|
|
898
|
+
drag.title = t('boards.dragTitle');
|
|
899
|
+
row.append(drag, name, max, wip, addLbl, doneLbl, rm);
|
|
900
|
+
return row;
|
|
901
|
+
};
|
|
902
|
+
// Papierkorb ist eine Systemspalte und wird hier nicht bearbeitet
|
|
903
|
+
(editBoard.columns || []).filter(c => !c.isTrash).forEach((col, i) => colBox.appendChild(mkColRow(col, i)));
|
|
904
|
+
panel.appendChild(colBox);
|
|
905
|
+
// eslint-disable-next-line no-undef
|
|
906
|
+
Sortable.create(colBox, { handle: '.drag', animation: 150, onEnd: () => { dirty = true; } });
|
|
907
|
+
const addCol = el('button', 'linkbtn', t('boards.addColumn'));
|
|
908
|
+
addCol.addEventListener('click', () => { colBox.appendChild(mkColRow({ title: '', maxVisible: 0, wipLimit: 0, isDone: false, allowAdd: false })); dirty = true; });
|
|
909
|
+
panel.appendChild(addCol);
|
|
910
|
+
|
|
911
|
+
// ---- Labels ----
|
|
912
|
+
panel.appendChild(el('label', null, t('boards.labels')));
|
|
913
|
+
labelBox = el('div');
|
|
914
|
+
labelBox.style.cssText = 'display:flex;flex-direction:column;gap:6px';
|
|
915
|
+
const mkLabelRow = lab => {
|
|
916
|
+
const row = el('div', 'label-edit');
|
|
917
|
+
row.dataset.labelId = lab.id || '';
|
|
918
|
+
const color = makeColorTrigger(lab.color || '#4CAF50', () => { dirty = true; });
|
|
919
|
+
const name = document.createElement('input');
|
|
920
|
+
name.type = 'text';
|
|
921
|
+
name.value = lab.title || '';
|
|
922
|
+
name.placeholder = t('label.namePlaceholder');
|
|
923
|
+
const rm = el('button', 'rm', '×');
|
|
924
|
+
rm.type = 'button';
|
|
925
|
+
rm.title = t('boards.deleteLabelTitle');
|
|
926
|
+
rm.addEventListener('click', () => { row.remove(); dirty = true; });
|
|
927
|
+
row.append(color, name, rm);
|
|
928
|
+
return row;
|
|
929
|
+
};
|
|
930
|
+
for (const lab of editBoard.labels || []) labelBox.appendChild(mkLabelRow(lab));
|
|
931
|
+
panel.appendChild(labelBox);
|
|
932
|
+
const addLabel = el('button', 'linkbtn', t('boards.addLabel'));
|
|
933
|
+
addLabel.addEventListener('click', () => { labelBox.appendChild(mkLabelRow({ color: '#4CAF50' })); dirty = true; });
|
|
934
|
+
panel.appendChild(addLabel);
|
|
935
|
+
|
|
936
|
+
// ---- Link-Ziel fuer Benachrichtigungen ----
|
|
937
|
+
panel.appendChild(el('label', null, t('boards.linkTarget')));
|
|
938
|
+
linkTargetSel = document.createElement('select');
|
|
939
|
+
for (const [val, key] of [['board', 'boards.linkBoard'], ['edit', 'boards.linkEdit'], ['url', 'boards.linkUrl']]) {
|
|
940
|
+
const o = document.createElement('option');
|
|
941
|
+
o.value = val; o.textContent = t(key);
|
|
942
|
+
linkTargetSel.appendChild(o);
|
|
943
|
+
}
|
|
944
|
+
linkTargetSel.value = editBoard.linkTarget || 'board';
|
|
945
|
+
panel.appendChild(linkTargetSel);
|
|
946
|
+
const linkUrlWrap = el('label', null, t('boards.linkUrlField'));
|
|
947
|
+
linkUrlInput = document.createElement('input');
|
|
948
|
+
linkUrlInput.type = 'url';
|
|
949
|
+
linkUrlInput.placeholder = 'https://…';
|
|
950
|
+
linkUrlInput.value = editBoard.linkUrl || '';
|
|
951
|
+
linkUrlWrap.appendChild(linkUrlInput);
|
|
952
|
+
linkUrlWrap.hidden = linkTargetSel.value !== 'url';
|
|
953
|
+
panel.appendChild(linkUrlWrap);
|
|
954
|
+
linkTargetSel.addEventListener('change', () => { linkUrlWrap.hidden = linkTargetSel.value !== 'url'; });
|
|
955
|
+
|
|
956
|
+
// ---- Erledigte Karten aufraeumen ----
|
|
957
|
+
panel.appendChild(el('label', null, t('cleanup.title')));
|
|
958
|
+
const cl = editBoard.cleanup || { mode: 'off', days: 90, count: 100 };
|
|
959
|
+
const cleanupRow = el('div', 'row');
|
|
960
|
+
cleanupModeSel = document.createElement('select');
|
|
961
|
+
for (const [val, key] of [['off', 'cleanup.off'], ['age', 'cleanup.age'], ['count', 'cleanup.count']]) {
|
|
962
|
+
const o = document.createElement('option'); o.value = val; o.textContent = t(key); cleanupModeSel.appendChild(o);
|
|
963
|
+
}
|
|
964
|
+
cleanupModeSel.value = cl.mode || 'off';
|
|
965
|
+
const daysWrap = el('label', 'inline-num', t('cleanup.days'));
|
|
966
|
+
cleanupDaysInp = document.createElement('input');
|
|
967
|
+
cleanupDaysInp.type = 'number'; cleanupDaysInp.min = '1'; cleanupDaysInp.value = String(cl.days || 90);
|
|
968
|
+
daysWrap.appendChild(cleanupDaysInp);
|
|
969
|
+
const countWrap = el('label', 'inline-num', t('cleanup.countLabel'));
|
|
970
|
+
cleanupCountInp = document.createElement('input');
|
|
971
|
+
cleanupCountInp.type = 'number'; cleanupCountInp.min = '1'; cleanupCountInp.value = String(cl.count || 100);
|
|
972
|
+
countWrap.appendChild(cleanupCountInp);
|
|
973
|
+
cleanupRow.append(cleanupModeSel, daysWrap, countWrap);
|
|
974
|
+
panel.appendChild(cleanupRow);
|
|
975
|
+
panel.appendChild(el('div', 'hint', t('cleanup.hint')));
|
|
976
|
+
const syncCleanupUI = () => {
|
|
977
|
+
daysWrap.hidden = cleanupModeSel.value !== 'age';
|
|
978
|
+
countWrap.hidden = cleanupModeSel.value !== 'count';
|
|
979
|
+
};
|
|
980
|
+
cleanupModeSel.addEventListener('change', syncCleanupUI);
|
|
981
|
+
syncCleanupUI();
|
|
982
|
+
|
|
983
|
+
// ---- Papierkorb einblenden (pro Geraet, keine Board-Aenderung) ----
|
|
984
|
+
const trashLbl = el('label', 'inline');
|
|
985
|
+
const trashChk = document.createElement('input');
|
|
986
|
+
trashChk.type = 'checkbox'; trashChk.checked = !!state.showTrash;
|
|
987
|
+
trashChk.dataset.noDirty = '1'; // reine Geraete-Einstellung
|
|
988
|
+
trashChk.addEventListener('change', () => actions.toggleShowTrash());
|
|
989
|
+
trashLbl.append(trashChk, document.createTextNode(' ' + t('trash.showToggle')));
|
|
990
|
+
panel.appendChild(trashLbl);
|
|
991
|
+
|
|
992
|
+
// ---- Board loeschen (ganz unten) ----
|
|
993
|
+
const delBoard = el('button', 'danger', t('boards.deleteBoard'));
|
|
994
|
+
delBoard.type = 'button';
|
|
995
|
+
delBoard.style.marginTop = '18px';
|
|
996
|
+
delBoard.addEventListener('click', async () => {
|
|
997
|
+
if (state.boards.length < 2) {
|
|
998
|
+
await confirmDialog({ title: t('boards.deleteBoard'), message: t('boards.lastBoard'), ok: t('confirm.ok') });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (!await confirmDialog({ title: t('boards.deleteBoard'), message: t('confirm.deleteBoard', { title: editBoard.title }), danger: true, ok: t('boards.deleteBoard') })) return;
|
|
1002
|
+
await actions.deleteBoard(editBoard.id);
|
|
1003
|
+
dirty = false;
|
|
1004
|
+
editId = (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '';
|
|
1005
|
+
if (!editId) { bdlg.close(); return; }
|
|
1006
|
+
await loadEdit(editId);
|
|
1007
|
+
buildBoardPanel(boardPanel);
|
|
1008
|
+
validateMembers();
|
|
1009
|
+
});
|
|
1010
|
+
panel.appendChild(delBoard);
|
|
1011
|
+
|
|
1012
|
+
// Aenderungen an Board-Feldern merken. Ausgenommen sind Bedienelemente,
|
|
1013
|
+
// die nichts am Board aendern (Board-Auswahl, Neu-Feld, Geraete-Einstellungen).
|
|
1014
|
+
const touch = (ev) => {
|
|
1015
|
+
const el2 = ev.target;
|
|
1016
|
+
if (el2 && el2.dataset && el2.dataset.noDirty) return;
|
|
1017
|
+
dirty = true;
|
|
1018
|
+
};
|
|
1019
|
+
panel.addEventListener('input', touch);
|
|
1020
|
+
panel.addEventListener('change', touch);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ---- Tab: Board ----
|
|
1024
|
+
await loadEdit(editId);
|
|
1025
|
+
addTab('board', t('settings.tabBoard'), panel => { boardPanel = panel; buildBoardPanel(panel); });
|
|
1026
|
+
|
|
1027
|
+
// ---- Tab: Benutzer (Avatare/Farben) ----
|
|
1028
|
+
if ((state.users || []).length) {
|
|
1029
|
+
addTab('users', t('settings.tabUsers'), panel => {
|
|
1030
|
+
panel.appendChild(el('label', null, t('boards.avatars')));
|
|
1031
|
+
for (const u of state.users) {
|
|
1032
|
+
const row = el('div', 'avatar-edit');
|
|
1033
|
+
const prev = el('span', 'avatar avatar-prev');
|
|
1034
|
+
const paint = () => {
|
|
1035
|
+
prev.textContent = ''; prev.style.background = '';
|
|
1036
|
+
if (u.avatar) {
|
|
1037
|
+
const im = document.createElement('img');
|
|
1038
|
+
im.src = `avatars/${encodeURIComponent(u.name)}?v=${state.avatarVer || 0}`;
|
|
1039
|
+
prev.appendChild(im);
|
|
1040
|
+
} else {
|
|
1041
|
+
prev.textContent = initials(u.displayName || u.name);
|
|
1042
|
+
prev.style.background = u.color || '#888';
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
paint();
|
|
1046
|
+
const nm = el('span', 'avatar-name', u.displayName || u.name);
|
|
1047
|
+
const pick = el('button', 'linkbtn', t('avatar.choose')); pick.type = 'button';
|
|
1048
|
+
const file = document.createElement('input');
|
|
1049
|
+
file.type = 'file'; file.accept = 'image/png,image/jpeg,image/webp'; file.hidden = true;
|
|
1050
|
+
pick.addEventListener('click', () => file.click());
|
|
1051
|
+
file.addEventListener('change', async () => {
|
|
1052
|
+
if (!file.files || !file.files[0]) return;
|
|
1053
|
+
try {
|
|
1054
|
+
const dataUrl = await fileToSquareDataUrl(file.files[0], 128);
|
|
1055
|
+
await api(`api/users/${encodeURIComponent(u.name)}/avatar`, { method: 'POST', body: { image: dataUrl } });
|
|
1056
|
+
u.avatar = true; await actions.avatarsChanged(); paint(); rm.hidden = false;
|
|
1057
|
+
} catch (e) { alert(t('avatar.failed', { msg: e.message })); }
|
|
1058
|
+
file.value = '';
|
|
1059
|
+
});
|
|
1060
|
+
const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.title = t('avatar.remove');
|
|
1061
|
+
rm.hidden = !u.avatar;
|
|
1062
|
+
rm.addEventListener('click', async () => {
|
|
1063
|
+
await api(`api/users/${encodeURIComponent(u.name)}/avatar`, { method: 'DELETE' });
|
|
1064
|
+
u.avatar = false; await actions.avatarsChanged(); paint(); rm.hidden = true;
|
|
1065
|
+
});
|
|
1066
|
+
let colorTimer = null;
|
|
1067
|
+
const colorTrig = makeColorTrigger(u.color || '#7E57C2', (col) => {
|
|
1068
|
+
u.color = col;
|
|
1069
|
+
clearTimeout(colorTimer);
|
|
1070
|
+
colorTimer = setTimeout(async () => {
|
|
1071
|
+
try {
|
|
1072
|
+
await api(`api/users/${encodeURIComponent(u.name)}`, { method: 'PATCH', body: { color: col } });
|
|
1073
|
+
await actions.avatarsChanged();
|
|
1074
|
+
} catch (e) { alert(t('avatar.failed', { msg: e.message })); }
|
|
1075
|
+
}, 500);
|
|
1076
|
+
});
|
|
1077
|
+
colorTrig.title = t('user.color');
|
|
1078
|
+
row.append(prev, nm, colorTrig, pick, file, rm);
|
|
1079
|
+
panel.appendChild(row);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---- Fester Footer: Schliessen + Speichern ----
|
|
1085
|
+
const foot = el('footer');
|
|
1086
|
+
foot.appendChild(el('span', 'spacer'));
|
|
1087
|
+
const closeBtn = el('button', null, t('boards.close'));
|
|
1088
|
+
closeBtn.type = 'button';
|
|
1089
|
+
closeBtn.addEventListener('click', async () => { if (await guardUnsaved()) bdlg.close(); });
|
|
1090
|
+
foot.appendChild(closeBtn);
|
|
1091
|
+
saveBtn = el('button', 'primary', t('boards.save'));
|
|
1092
|
+
saveBtn.type = 'button';
|
|
1093
|
+
saveBtn.addEventListener('click', async () => { if (await saveEdit()) bdlg.close(); });
|
|
1094
|
+
foot.appendChild(saveBtn);
|
|
1095
|
+
body.appendChild(foot);
|
|
1096
|
+
validateMembers();
|
|
1097
|
+
|
|
1098
|
+
activate('board');
|
|
1099
|
+
// Escape: erst den Farbwaehler schliessen, danach nur mit Rueckfrage den
|
|
1100
|
+
// ganzen Dialog verwerfen — sonst sind ungespeicherte Spalten und Labels weg.
|
|
1101
|
+
// Der Handler haengt an der Closure dieses Aufrufs und wird darum jedes Mal
|
|
1102
|
+
// ausgetauscht.
|
|
1103
|
+
if (bdlgCancel) bdlg.removeEventListener('cancel', bdlgCancel);
|
|
1104
|
+
bdlgCancel = ev => {
|
|
1105
|
+
if (colorPickerOpen()) { ev.preventDefault(); closeColorPicker(); return; }
|
|
1106
|
+
if (!dirty) return;
|
|
1107
|
+
ev.preventDefault();
|
|
1108
|
+
guardUnsaved().then(ok => { if (ok) bdlg.close(); });
|
|
1109
|
+
};
|
|
1110
|
+
bdlg.addEventListener('cancel', bdlgCancel);
|
|
1111
|
+
bdlg.showModal();
|
|
1112
|
+
}
|
|
1113
|
+
// ---------------------------------------------------------- Ansicht teilen
|
|
1114
|
+
async function openShareDialog() {
|
|
1115
|
+
const sdlg = document.getElementById('shareDialog');
|
|
1116
|
+
const body = document.getElementById('shareBody');
|
|
1117
|
+
body.textContent = '';
|
|
1118
|
+
body.appendChild(el('h3', null, t('share.title')));
|
|
1119
|
+
body.appendChild(el('p', 'hint', t('share.hint')));
|
|
1120
|
+
|
|
1121
|
+
const opt = {
|
|
1122
|
+
board: (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '',
|
|
1123
|
+
users: [], labels: [], labelMode: 'hide', columns: null, doneLimit: null,
|
|
1124
|
+
hideSettings: false, embed: false,
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const mkCheck = (text) => {
|
|
1128
|
+
const lab = el('label', 'inline');
|
|
1129
|
+
const inp = document.createElement('input'); inp.type = 'checkbox';
|
|
1130
|
+
lab.append(inp, document.createTextNode(' ' + text));
|
|
1131
|
+
return { lab, inp };
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const board = (() => {
|
|
1135
|
+
const lab = el('label', null, t('share.board'));
|
|
1136
|
+
const sel = document.createElement('select');
|
|
1137
|
+
for (const b of state.boards) { const o = document.createElement('option'); o.value = b.id; o.textContent = b.title; sel.appendChild(o); }
|
|
1138
|
+
sel.value = opt.board;
|
|
1139
|
+
lab.appendChild(sel);
|
|
1140
|
+
return { lab, sel };
|
|
1141
|
+
})();
|
|
1142
|
+
|
|
1143
|
+
// Benutzer (Mehrfachauswahl, board-abhängig) – keine angehakt = alle
|
|
1144
|
+
const usersLabel = el('label', null, t('share.users'));
|
|
1145
|
+
const usersWrap = el('div', 'share-cols');
|
|
1146
|
+
const updateUsers = () => { opt.users = [...usersWrap.querySelectorAll('input:checked')].map(i => i.dataset.val); };
|
|
1147
|
+
/** Beim Board-Wechsel mitziehen: Die Liste blieb früher auf den Mitgliedern
|
|
1148
|
+
* des angezeigten Boards stehen. Ein damit erzeugter `users=`-Link nannte
|
|
1149
|
+
* dann Personen, die im Zielboard niemandem gehören — und filterte beim
|
|
1150
|
+
* Öffnen stillschweigend gar nicht. */
|
|
1151
|
+
async function fillUsers(boardId) {
|
|
1152
|
+
usersWrap.textContent = '';
|
|
1153
|
+
opt.users = [];
|
|
1154
|
+
let b = state.board && state.board.id === boardId ? state.board : null;
|
|
1155
|
+
if (!b && boardId) { try { b = await api(`api/boards/${encodeURIComponent(boardId)}`); } catch (e) { b = null; } }
|
|
1156
|
+
const names = new Set(boardMembers(b, state.users));
|
|
1157
|
+
for (const u of (state.users || []).filter(x => names.has(x.name))) {
|
|
1158
|
+
const chk = mkCheck(u.displayName || u.name);
|
|
1159
|
+
chk.inp.dataset.val = u.name;
|
|
1160
|
+
chk.inp.addEventListener('change', () => { updateUsers(); update(); });
|
|
1161
|
+
usersWrap.appendChild(chk.lab);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Labels (Mehrfachauswahl, board-abhängig) – keine angehakt = alle.
|
|
1166
|
+
// Modus: ausblenden (Blacklist, ?label=) oder nur diese zeigen (Whitelist, ?onlyLabel=)
|
|
1167
|
+
const labelsLabel = el('label', null, t('share.labels'));
|
|
1168
|
+
const labelModeSel = document.createElement('select');
|
|
1169
|
+
for (const [val, key] of [['hide', 'share.labelsHide'], ['only', 'share.labelsOnly']]) {
|
|
1170
|
+
const o2 = document.createElement('option');
|
|
1171
|
+
o2.value = val; o2.textContent = t(key);
|
|
1172
|
+
labelModeSel.appendChild(o2);
|
|
1173
|
+
}
|
|
1174
|
+
labelModeSel.addEventListener('change', () => { opt.labelMode = labelModeSel.value; update(); });
|
|
1175
|
+
labelsLabel.appendChild(labelModeSel);
|
|
1176
|
+
const labelsWrap = el('div', 'share-cols');
|
|
1177
|
+
const updateLabels = () => { opt.labels = [...labelsWrap.querySelectorAll('input:checked')].map(i => i.dataset.val); };
|
|
1178
|
+
async function fillLabels(boardId) {
|
|
1179
|
+
labelsWrap.textContent = '';
|
|
1180
|
+
let labels = [];
|
|
1181
|
+
if (state.board && state.board.id === boardId) labels = state.board.labels || [];
|
|
1182
|
+
else if (boardId) { try { const b = await api(`api/boards/${encodeURIComponent(boardId)}`); labels = (b && b.labels) || []; } catch (e) { /* ignore */ } }
|
|
1183
|
+
for (const l of labels) {
|
|
1184
|
+
const chk = mkCheck(l.title);
|
|
1185
|
+
chk.inp.dataset.val = l.id;
|
|
1186
|
+
chk.inp.addEventListener('change', () => { updateLabels(); update(); });
|
|
1187
|
+
labelsWrap.appendChild(chk.lab);
|
|
1188
|
+
}
|
|
1189
|
+
opt.labels = [];
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const cHideSettings = mkCheck(t('share.hideSettings'));
|
|
1193
|
+
const cEmbed = mkCheck(t('share.embed'));
|
|
1194
|
+
|
|
1195
|
+
// Sichtbare Spalten (des gewählten Boards) – alle an = kein Filter
|
|
1196
|
+
let curColumns = [];
|
|
1197
|
+
const colsLabel = el('label', null, t('share.visibleColumns'));
|
|
1198
|
+
const colsWrap = el('div', 'share-cols');
|
|
1199
|
+
const updateColumns = () => {
|
|
1200
|
+
const checked = [...colsWrap.querySelectorAll('input:checked')].map(i => i.dataset.colId);
|
|
1201
|
+
opt.columns = (checked.length === curColumns.length) ? null : checked;
|
|
1202
|
+
};
|
|
1203
|
+
async function fillColumns(boardId) {
|
|
1204
|
+
colsWrap.textContent = '';
|
|
1205
|
+
curColumns = [];
|
|
1206
|
+
if (state.board && state.board.id === boardId) curColumns = state.board.columns || [];
|
|
1207
|
+
else if (boardId) { try { const b = await api(`api/boards/${encodeURIComponent(boardId)}`); curColumns = (b && b.columns) || []; } catch (e) { /* ignore */ } }
|
|
1208
|
+
curColumns = curColumns.filter(c => !c.isTrash);
|
|
1209
|
+
for (const c of curColumns) {
|
|
1210
|
+
const chk = mkCheck(c.title);
|
|
1211
|
+
chk.inp.checked = true;
|
|
1212
|
+
chk.inp.dataset.colId = c.id;
|
|
1213
|
+
chk.inp.addEventListener('change', () => { updateColumns(); update(); });
|
|
1214
|
+
colsWrap.appendChild(chk.lab);
|
|
1215
|
+
}
|
|
1216
|
+
opt.columns = null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Limit für erledigte Karten in Erledigt-Spalten
|
|
1220
|
+
const doneLimitLbl = el('label', null, t('share.doneLimit'));
|
|
1221
|
+
const doneLimitInp = document.createElement('input');
|
|
1222
|
+
doneLimitInp.type = 'number'; doneLimitInp.min = '0'; doneLimitInp.value = ''; doneLimitInp.placeholder = t('share.doneLimitAll');
|
|
1223
|
+
doneLimitLbl.appendChild(doneLimitInp);
|
|
1224
|
+
|
|
1225
|
+
const urlWrap = el('div', 'share-url');
|
|
1226
|
+
const urlField = document.createElement('input');
|
|
1227
|
+
urlField.type = 'text'; urlField.readOnly = true; urlField.className = 'share-url-input';
|
|
1228
|
+
const copyBtn = el('button', 'primary', t('share.copy')); copyBtn.type = 'button';
|
|
1229
|
+
urlWrap.append(urlField, copyBtn);
|
|
1230
|
+
|
|
1231
|
+
const buildUrl = () => {
|
|
1232
|
+
const p = new URLSearchParams();
|
|
1233
|
+
if (opt.board) p.set('board', opt.board);
|
|
1234
|
+
if (opt.users.length) p.set('users', opt.users.join(','));
|
|
1235
|
+
if (opt.labels.length) p.set(opt.labelMode === 'only' ? 'onlyLabel' : 'label', opt.labels.join(','));
|
|
1236
|
+
if (opt.columns && opt.columns.length) p.set('columns', opt.columns.join(','));
|
|
1237
|
+
if (opt.doneLimit != null) p.set('doneLimit', String(opt.doneLimit));
|
|
1238
|
+
if (opt.hideSettings) p.set('hideSettings', '1');
|
|
1239
|
+
if (opt.embed) p.set('embed', '1');
|
|
1240
|
+
const q = p.toString();
|
|
1241
|
+
return location.origin + location.pathname + (q ? '?' + q : '');
|
|
1242
|
+
};
|
|
1243
|
+
const update = () => { urlField.value = buildUrl(); };
|
|
1244
|
+
|
|
1245
|
+
board.sel.addEventListener('change', async () => { opt.board = board.sel.value; await Promise.all([fillUsers(opt.board), fillLabels(opt.board), fillColumns(opt.board)]); update(); });
|
|
1246
|
+
doneLimitInp.addEventListener('input', () => { opt.doneLimit = doneLimitInp.value === '' ? null : Math.max(0, parseInt(doneLimitInp.value, 10) || 0); update(); });
|
|
1247
|
+
cHideSettings.inp.addEventListener('change', () => { opt.hideSettings = cHideSettings.inp.checked; update(); });
|
|
1248
|
+
cEmbed.inp.addEventListener('change', () => { opt.embed = cEmbed.inp.checked; update(); });
|
|
1249
|
+
copyBtn.addEventListener('click', async () => {
|
|
1250
|
+
const done = () => { copyBtn.textContent = t('share.copied'); setTimeout(() => { copyBtn.textContent = t('share.copy'); }, 1500); };
|
|
1251
|
+
try { await navigator.clipboard.writeText(urlField.value); done(); }
|
|
1252
|
+
catch (e) { urlField.select(); try { document.execCommand('copy'); } catch (_) { /* ignore */ } done(); }
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
body.append(board.lab, usersLabel, usersWrap, labelsLabel, labelsWrap, colsLabel, colsWrap, doneLimitLbl,
|
|
1256
|
+
cHideSettings.lab, cEmbed.lab,
|
|
1257
|
+
el('label', null, t('share.generatedUrl')), urlWrap);
|
|
1258
|
+
|
|
1259
|
+
const foot = el('footer');
|
|
1260
|
+
const close = el('button', null, t('boards.close')); close.type = 'button';
|
|
1261
|
+
close.addEventListener('click', () => sdlg.close());
|
|
1262
|
+
foot.append(el('span', 'spacer'), close);
|
|
1263
|
+
body.appendChild(foot);
|
|
1264
|
+
|
|
1265
|
+
await Promise.all([fillUsers(opt.board), fillLabels(opt.board), fillColumns(opt.board)]);
|
|
1266
|
+
update();
|
|
1267
|
+
sdlg.showModal();
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ---------------------------------------------------------- In-App-Bestätigung (Feature 61)
|
|
1271
|
+
function confirmDialog(opts = {}) {
|
|
1272
|
+
const cdlg = document.getElementById('confirmDialog');
|
|
1273
|
+
const body = document.getElementById('confirmBody');
|
|
1274
|
+
body.textContent = '';
|
|
1275
|
+
body.appendChild(el('h3', null, opts.title || t('confirm.title')));
|
|
1276
|
+
body.appendChild(el('p', null, opts.message || ''));
|
|
1277
|
+
const foot = el('footer');
|
|
1278
|
+
const cancel = el('button', null, opts.cancel || t('confirm.cancel')); cancel.type = 'button';
|
|
1279
|
+
const ok = el('button', opts.danger ? 'danger' : 'primary', opts.ok || t('confirm.ok')); ok.type = 'button';
|
|
1280
|
+
// Optionaler dritter Knopf (z.B. „Verwerfen") → löst mit 'extra' auf
|
|
1281
|
+
let extraBtn = null;
|
|
1282
|
+
if (opts.extra) { extraBtn = el('button', null, opts.extra); extraBtn.type = 'button'; }
|
|
1283
|
+
foot.append(el('span', 'spacer'), cancel);
|
|
1284
|
+
if (extraBtn) foot.appendChild(extraBtn);
|
|
1285
|
+
foot.appendChild(ok);
|
|
1286
|
+
body.appendChild(foot);
|
|
1287
|
+
return new Promise(resolve => {
|
|
1288
|
+
let done = false;
|
|
1289
|
+
const finish = v => { if (done) return; done = true; try { cdlg.close(); } catch (e) { /* ignore */ } resolve(v); };
|
|
1290
|
+
cancel.addEventListener('click', () => finish(false));
|
|
1291
|
+
if (extraBtn) extraBtn.addEventListener('click', () => finish('extra'));
|
|
1292
|
+
ok.addEventListener('click', () => finish(true));
|
|
1293
|
+
cdlg.addEventListener('close', () => finish(false), { once: true });
|
|
1294
|
+
cdlg.showModal();
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ---------------------------------------------------------- Karte übertragen (Feature 6)
|
|
1299
|
+
async function openTransfer(cardId) {
|
|
1300
|
+
const card = state.board && state.board.cards.find(c => c.id === cardId);
|
|
1301
|
+
if (!card) return;
|
|
1302
|
+
const tdlg = document.getElementById('transferDialog');
|
|
1303
|
+
const body = document.getElementById('transferBody');
|
|
1304
|
+
body.textContent = '';
|
|
1305
|
+
body.appendChild(el('h3', null, t('transfer.title')));
|
|
1306
|
+
const others = state.boards.filter(b => b.id !== state.board.id);
|
|
1307
|
+
const foot = el('footer');
|
|
1308
|
+
const cancel = el('button', null, t('boards.close')); cancel.type = 'button';
|
|
1309
|
+
cancel.addEventListener('click', () => tdlg.close());
|
|
1310
|
+
|
|
1311
|
+
// Klonen (im selben Board), Kopieren oder Verschieben (auf ein anderes Board)
|
|
1312
|
+
let mode = 'clone';
|
|
1313
|
+
let targetBoard = null;
|
|
1314
|
+
const overrideSel = new Set();
|
|
1315
|
+
const labelName = l => String((l && (l.title || l.name)) || '').trim().toLowerCase();
|
|
1316
|
+
|
|
1317
|
+
const modeWrap = el('div', 'transfer-mode');
|
|
1318
|
+
const mkMode = (val, label) => {
|
|
1319
|
+
const b = el('button', 'transfer-mode-btn' + (val === mode ? ' active' : ''), label);
|
|
1320
|
+
b.type = 'button'; b.dataset.mode = val; b.title = label;
|
|
1321
|
+
if (val !== 'clone' && !others.length) { b.disabled = true; b.title = t('transfer.noOtherBoards'); }
|
|
1322
|
+
b.addEventListener('click', () => setMode(val));
|
|
1323
|
+
return b;
|
|
1324
|
+
};
|
|
1325
|
+
modeWrap.append(mkMode('clone', t('transfer.clone')), mkMode('copy', t('transfer.copy')), mkMode('move', t('transfer.move')));
|
|
1326
|
+
|
|
1327
|
+
const boardLbl = el('label', null, t('transfer.targetBoard'));
|
|
1328
|
+
const boardSel = document.createElement('select');
|
|
1329
|
+
for (const b of others) { const o = document.createElement('option'); o.value = b.id; o.textContent = b.title; boardSel.appendChild(o); }
|
|
1330
|
+
boardLbl.appendChild(boardSel);
|
|
1331
|
+
|
|
1332
|
+
const colLbl = el('label', null, t('transfer.targetColumn'));
|
|
1333
|
+
const colSel = document.createElement('select');
|
|
1334
|
+
colLbl.appendChild(colSel);
|
|
1335
|
+
|
|
1336
|
+
const note = el('div', 'transfer-note'); note.hidden = true;
|
|
1337
|
+
const assignLbl = el('label', null, t('transfer.pickAssignee')); assignLbl.hidden = true;
|
|
1338
|
+
const assignWrap = el('div', 'share-cols'); assignWrap.hidden = true;
|
|
1339
|
+
const ok = el('button', 'primary', t('transfer.clone')); ok.type = 'button';
|
|
1340
|
+
|
|
1341
|
+
function updateOk() { ok.disabled = !assignLbl.hidden && overrideSel.size === 0; }
|
|
1342
|
+
// Modus wechseln: Ziel-Board nur bei Kopieren/Verschieben, Klonen bleibt im Board
|
|
1343
|
+
function setMode(val) {
|
|
1344
|
+
mode = val;
|
|
1345
|
+
for (const x of modeWrap.children) x.classList.toggle('active', x.dataset.mode === mode);
|
|
1346
|
+
const clone = mode === 'clone';
|
|
1347
|
+
boardLbl.hidden = clone;
|
|
1348
|
+
ok.textContent = clone ? t('transfer.clone') : (mode === 'copy' ? t('transfer.copy') : t('transfer.move'));
|
|
1349
|
+
if (clone) { targetBoard = state.board; recompute(); } else loadTarget(boardSel.value);
|
|
1350
|
+
}
|
|
1351
|
+
function recompute() {
|
|
1352
|
+
if (!targetBoard) return;
|
|
1353
|
+
const clone = mode === 'clone';
|
|
1354
|
+
colSel.textContent = '';
|
|
1355
|
+
const cols = (targetBoard.columns || []).filter(c => !c.isTrash);
|
|
1356
|
+
for (const c of cols) { const o = document.createElement('option'); o.value = c.id; o.textContent = c.title; colSel.appendChild(o); }
|
|
1357
|
+
const def = (clone && cols.find(c => c.id === card.columnId)) || cols.find(c => c.allowAdd) || cols[0];
|
|
1358
|
+
if (def) colSel.value = def.id;
|
|
1359
|
+
if (clone) {
|
|
1360
|
+
note.textContent = t('transfer.cloneHint');
|
|
1361
|
+
note.hidden = false;
|
|
1362
|
+
assignLbl.hidden = true; assignWrap.hidden = true;
|
|
1363
|
+
overrideSel.clear();
|
|
1364
|
+
updateOk();
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const fromById = new Map((state.board.labels || []).map(l => [l.id, l]));
|
|
1368
|
+
const toNames = new Set((targetBoard.labels || []).map(labelName));
|
|
1369
|
+
const droppedLabels = (card.labels || []).filter(id => { const n = labelName(fromById.get(id)); return !(n && toNames.has(n)); });
|
|
1370
|
+
// Wirksame Mitglieder wie auf dem Server: eine Liste, die auf keinen
|
|
1371
|
+
// bekannten Benutzer zeigt, bedeutet "alle". Die rohe Liste ergab hier
|
|
1372
|
+
// sonst eine Sackgasse — Zustaendiger war Pflicht, aber die Auswahl leer.
|
|
1373
|
+
const members = new Set(boardMembers(targetBoard, state.users));
|
|
1374
|
+
const keptAssignees = (card.assignees || []).filter(a => members.has(a));
|
|
1375
|
+
const droppedAssignees = (card.assignees || []).filter(a => !members.has(a));
|
|
1376
|
+
const parts = [];
|
|
1377
|
+
if (droppedLabels.length) parts.push(t('transfer.dropLabels', { n: droppedLabels.length }));
|
|
1378
|
+
if (droppedAssignees.length) parts.push(t('transfer.dropAssignees', { n: droppedAssignees.length }));
|
|
1379
|
+
note.textContent = parts.join(' ');
|
|
1380
|
+
note.hidden = !parts.length;
|
|
1381
|
+
const needPick = keptAssignees.length === 0;
|
|
1382
|
+
assignLbl.hidden = !needPick; assignWrap.hidden = !needPick;
|
|
1383
|
+
overrideSel.clear();
|
|
1384
|
+
if (needPick) {
|
|
1385
|
+
assignWrap.textContent = '';
|
|
1386
|
+
for (const u of (state.users || []).filter(x => members.has(x.name))) {
|
|
1387
|
+
const lab = el('label', 'inline');
|
|
1388
|
+
const inp = document.createElement('input'); inp.type = 'checkbox'; inp.dataset.val = u.name;
|
|
1389
|
+
inp.addEventListener('change', () => { if (inp.checked) overrideSel.add(u.name); else overrideSel.delete(u.name); updateOk(); });
|
|
1390
|
+
lab.append(inp, document.createTextNode(' ' + (u.displayName || u.name)));
|
|
1391
|
+
assignWrap.appendChild(lab);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
updateOk();
|
|
1395
|
+
}
|
|
1396
|
+
async function loadTarget(id) {
|
|
1397
|
+
try { targetBoard = await api(`api/boards/${encodeURIComponent(id)}`); } catch (e) { targetBoard = null; }
|
|
1398
|
+
recompute();
|
|
1399
|
+
}
|
|
1400
|
+
boardSel.addEventListener('change', () => loadTarget(boardSel.value));
|
|
1401
|
+
ok.addEventListener('click', async () => {
|
|
1402
|
+
try {
|
|
1403
|
+
const toBoard = mode === 'clone' ? state.board.id : boardSel.value;
|
|
1404
|
+
await actions.transferCard(cardId, toBoard, colSel.value, mode === 'move' ? 'move' : 'copy', [...overrideSel]);
|
|
1405
|
+
tdlg.close();
|
|
1406
|
+
dlg.close();
|
|
1407
|
+
} catch (e) { alert(t('error.saveFailed', { msg: e.message })); }
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
body.append(modeWrap, boardLbl, colLbl, note, assignLbl, assignWrap);
|
|
1411
|
+
foot.append(el('span', 'spacer'), cancel, ok);
|
|
1412
|
+
body.appendChild(foot);
|
|
1413
|
+
if (others.length) boardSel.value = others[0].id;
|
|
1414
|
+
setMode('clone');
|
|
1415
|
+
tdlg.showModal();
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
return { openCard, copyCard, openDescription, openBoardManager, openShareDialog, confirm: confirmDialog };
|
|
1419
|
+
}
|