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/app.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// Bootstrap: Konfiguration laden, URL-Parameter, Theme, Live-Sync, Aktionen
|
|
2
|
+
|
|
3
|
+
import { api, liveSync } from './api.js';
|
|
4
|
+
import { renderBoard, userAvatar, boardUsers, contrastText, mdiIcon } from './board.js';
|
|
5
|
+
import { initDialogs } from './dialogs.js';
|
|
6
|
+
import { initI18n, applyStatic, t } from './i18n.js';
|
|
7
|
+
|
|
8
|
+
const qs = new URLSearchParams(location.search);
|
|
9
|
+
|
|
10
|
+
const state = {
|
|
11
|
+
users: [],
|
|
12
|
+
boards: [],
|
|
13
|
+
board: null,
|
|
14
|
+
avatarVer: 0, // Cache-Bust für Avatar-Bilder
|
|
15
|
+
labelFilter: qs.get('label') ? qs.get('label').split(',').filter(Boolean) : null, // Blacklist: diese Labels ausblenden
|
|
16
|
+
labelOnly: qs.get('onlyLabel') ? qs.get('onlyLabel').split(',').filter(Boolean) : null, // Whitelist: nur Karten mit diesen Labels
|
|
17
|
+
usersFilter: [], // Mehrfach-User-Filter (Kopf-Chips); je Board aus localStorage, per URL vorbelegbar
|
|
18
|
+
columnsFilter: qs.get('columns') ? qs.get('columns').split(',').filter(Boolean) : null,
|
|
19
|
+
doneLimit: qs.has('doneLimit') ? Math.max(0, parseInt(qs.get('doneLimit'), 10) || 0) : null, // null = alle, 0 = keine
|
|
20
|
+
hideSettings: qs.get('hideSettings') === '1',
|
|
21
|
+
showDone: localStorage.getItem('kanban.showDone') !== '0', // erledigte Spalten ein-/ausblenden
|
|
22
|
+
showTrash: localStorage.getItem('kanban.showTrash') === '1', // Papierkorb-Spalte ein-/ausblenden (pro Gerät, Default aus)
|
|
23
|
+
theme: qs.get('theme') || localStorage.getItem('kanban.theme') || '',
|
|
24
|
+
accent: qs.get('accent') || '',
|
|
25
|
+
embed: qs.get('embed') === '1',
|
|
26
|
+
collapsedCols: new Set((localStorage.getItem('kanban.collapsedCols') || '').split(',').filter(Boolean)),
|
|
27
|
+
sortModes: (() => { try { return JSON.parse(localStorage.getItem('kanban.sortModes') || '{}') || {}; } catch (e) { return {}; } })(),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ------------------------------------------------------------ Theme
|
|
31
|
+
|
|
32
|
+
const MDI_SUN = 'M3.55 19.09L4.96 20.5L6.76 18.71L5.34 17.29M12 6C8.69 6 6 8.69 6 12S8.69 18 12 18 18 15.31 18 12C18 8.68 15.31 6 12 6M20 13H23V11H20M17.24 18.71L19.04 20.5L20.45 19.09L18.66 17.29M20.45 5L19.04 3.6L17.24 5.39L18.66 6.81M13 1H11V4H13M6.76 5.39L4.96 3.6L3.55 5L5.34 6.81L6.76 5.39M1 13H4V11H1M13 20H11V23H13';
|
|
33
|
+
const MDI_MOON = 'M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.5,18.67 19.08,19.07C15.17,23 8.84,23 4.94,19.07C1.03,15.17 1.03,8.83 4.94,4.93C5.34,4.53 5.76,4.17 6.21,3.85C6.96,3.32 8.14,4.21 8.06,5.04C7.79,7.9 8.75,10.87 10.95,13.06C13.14,15.26 16.1,16.22 18.97,15.95M17.33,17.97C14.5,17.81 11.7,16.64 9.53,14.5C7.36,12.31 6.2,9.5 6.04,6.68C3.23,9.82 3.34,14.64 6.35,17.66C9.37,20.67 14.19,20.78 17.33,17.97Z';
|
|
34
|
+
|
|
35
|
+
function applyTheme(cfg) {
|
|
36
|
+
const theme = state.theme || cfg.themeDefault || 'auto';
|
|
37
|
+
const resolved = theme === 'auto'
|
|
38
|
+
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
39
|
+
: theme;
|
|
40
|
+
document.documentElement.dataset.theme = resolved;
|
|
41
|
+
const accent = state.accent || cfg.accentColor;
|
|
42
|
+
if (accent) document.documentElement.style.setProperty('--accent', accent);
|
|
43
|
+
// Schriftfarbe auf Akzent-Buttons je nach Helligkeit (auch bei Custom-CSS-Akzent)
|
|
44
|
+
const effAccent = (getComputedStyle(document.documentElement).getPropertyValue('--accent') || accent || '#7E57C2').trim();
|
|
45
|
+
document.documentElement.style.setProperty('--accent-text', contrastText(effAccent));
|
|
46
|
+
const tbtn = document.getElementById('themeBtn');
|
|
47
|
+
if (tbtn) { tbtn.textContent = ''; tbtn.appendChild(mdiIcon(resolved === 'dark' ? MDI_MOON : MDI_SUN)); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
51
|
+
if ((state.theme || state.cfg.themeDefault || 'auto') === 'auto') applyTheme(state.cfg);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ Laden & Rendern
|
|
55
|
+
|
|
56
|
+
const boardEl = document.getElementById('board');
|
|
57
|
+
let actions; // wird unten befüllt (Dialoge brauchen actions, actions brauchen render)
|
|
58
|
+
|
|
59
|
+
function render() {
|
|
60
|
+
renderBoard(boardEl, state, actions);
|
|
61
|
+
renderHeader();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderHeader() {
|
|
65
|
+
const sel = document.getElementById('boardSelect');
|
|
66
|
+
sel.textContent = '';
|
|
67
|
+
for (const b of state.boards) {
|
|
68
|
+
const o = document.createElement('option');
|
|
69
|
+
o.value = b.id;
|
|
70
|
+
o.textContent = b.title;
|
|
71
|
+
sel.appendChild(o);
|
|
72
|
+
}
|
|
73
|
+
if (state.board) sel.value = state.board.id;
|
|
74
|
+
|
|
75
|
+
const chips = document.getElementById('userChips');
|
|
76
|
+
chips.textContent = '';
|
|
77
|
+
// Chips = Board-Mitglieder als Mehrfach-Filter (angeklickt = nur Karten dieses Users; nichts gewaehlt = alle).
|
|
78
|
+
for (const u of boardUsers(state)) {
|
|
79
|
+
const chip = document.createElement('span');
|
|
80
|
+
const on = state.usersFilter.includes(u.name);
|
|
81
|
+
chip.className = 'user-chip' + (on ? ' active' : '');
|
|
82
|
+
if (u.color) chip.style.setProperty('--uc', u.color);
|
|
83
|
+
chip.appendChild(userAvatar(state, u.name));
|
|
84
|
+
const nm = document.createElement('span');
|
|
85
|
+
nm.textContent = u.displayName;
|
|
86
|
+
chip.appendChild(nm);
|
|
87
|
+
chip.addEventListener('click', () => {
|
|
88
|
+
const i = state.usersFilter.indexOf(u.name);
|
|
89
|
+
if (i >= 0) state.usersFilter.splice(i, 1); else state.usersFilter.push(u.name);
|
|
90
|
+
if (state.board) saveUserFilter(state.board.id, state.usersFilter);
|
|
91
|
+
render();
|
|
92
|
+
});
|
|
93
|
+
chips.appendChild(chip);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadBoards() {
|
|
98
|
+
state.boards = await api('api/boards');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadUserFilter(boardId) {
|
|
102
|
+
const raw = localStorage.getItem('kanban.userFilter.' + boardId);
|
|
103
|
+
if (raw == null) return null; // nichts gespeichert -> Standard (alle Mitglieder)
|
|
104
|
+
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : null; }
|
|
105
|
+
catch (e) { return null; }
|
|
106
|
+
}
|
|
107
|
+
function saveUserFilter(boardId, arr) {
|
|
108
|
+
try { localStorage.setItem('kanban.userFilter.' + boardId, JSON.stringify(arr)); } catch (e) { /* ignore */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Aktuelles Board in der Adresszeile mitfuehren (?board=…), damit die URL
|
|
112
|
+
// kopier- und teilbar ist. replaceState: keine zusaetzlichen History-Eintraege,
|
|
113
|
+
// alle uebrigen Parameter (Filter, Theme, card=…) bleiben erhalten.
|
|
114
|
+
function syncUrlBoard(id) {
|
|
115
|
+
if (!window.history || !history.replaceState) return;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(location.href);
|
|
118
|
+
if ((url.searchParams.get('board') || '') === (id || '')) return;
|
|
119
|
+
if (id) url.searchParams.set('board', id); else url.searchParams.delete('board');
|
|
120
|
+
history.replaceState(history.state, '', url);
|
|
121
|
+
} catch (e) { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadBoard(id, force) {
|
|
125
|
+
if (!id) { state.board = null; syncUrlBoard(''); render(); return; }
|
|
126
|
+
if (!force && state.board && state.board.id === id) return;
|
|
127
|
+
state.board = await api(`api/boards/${encodeURIComponent(id)}`);
|
|
128
|
+
syncUrlBoard(state.board.id);
|
|
129
|
+
const savedFilter = loadUserFilter(id);
|
|
130
|
+
state.usersFilter = savedFilter !== null ? savedFilter : boardUsers(state).map(u => u.name);
|
|
131
|
+
render();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Laufende Nummer je Abruf: Watchdog, Tab-Fokus und der Minutentakt koennen
|
|
135
|
+
// gleichzeitig abrufen, und Antworten treffen nicht zwingend in der Reihenfolge
|
|
136
|
+
// ein, in der sie losgeschickt wurden.
|
|
137
|
+
let refreshSeq = 0;
|
|
138
|
+
|
|
139
|
+
async function refreshCurrent() {
|
|
140
|
+
if (!state.board) return;
|
|
141
|
+
const boardId = state.board.id;
|
|
142
|
+
const seq = ++refreshSeq;
|
|
143
|
+
const data = await api(`api/boards/${encodeURIComponent(boardId)}?rev=${state.board.rev}`);
|
|
144
|
+
// Antwort verwerfen, wenn inzwischen das Board gewechselt wurde oder ein
|
|
145
|
+
// neuerer Abruf laeuft. Sonst sprang die Ansicht auf das alte Board zurueck
|
|
146
|
+
// bzw. eine aeltere Revision ueberschrieb eine neuere.
|
|
147
|
+
if (seq !== refreshSeq || !state.board || state.board.id !== boardId) return;
|
|
148
|
+
if (!data.unchanged) {
|
|
149
|
+
state.board = data;
|
|
150
|
+
render();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------ Aktionen (API + optimistisches Update)
|
|
155
|
+
|
|
156
|
+
actions = {
|
|
157
|
+
openCard: null, // wird nach initDialogs gesetzt
|
|
158
|
+
|
|
159
|
+
toggleShowDone() {
|
|
160
|
+
state.showDone = !state.showDone;
|
|
161
|
+
localStorage.setItem('kanban.showDone', state.showDone ? '1' : '0');
|
|
162
|
+
render();
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
toggleShowTrash() {
|
|
166
|
+
state.showTrash = !state.showTrash;
|
|
167
|
+
localStorage.setItem('kanban.showTrash', state.showTrash ? '1' : '0');
|
|
168
|
+
render();
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Sortiermodus je Board+Spalte (pro Gerät)
|
|
172
|
+
setSortMode(colKey, mode) {
|
|
173
|
+
state.sortModes[colKey] = mode;
|
|
174
|
+
try { localStorage.setItem('kanban.sortModes', JSON.stringify(state.sortModes)); } catch (e) { /* ignore */ }
|
|
175
|
+
render();
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
rerender() { render(); },
|
|
179
|
+
|
|
180
|
+
copyCard: null, // wird nach initDialogs gesetzt (öffnet Editor mit Kopie)
|
|
181
|
+
openDescription: null, // wird nach initDialogs gesetzt (Beschreibung nur lesend)
|
|
182
|
+
|
|
183
|
+
async restoreCard(cardId, columnId) {
|
|
184
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}/restore`, { method: 'POST', body: { columnId, by: '' } });
|
|
185
|
+
await refreshCurrent();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async purgeCard(cardId) {
|
|
189
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}/purge`, { method: 'POST', body: { by: '' } });
|
|
190
|
+
await refreshCurrent();
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async emptyTrash() {
|
|
194
|
+
await api(`api/boards/${state.board.id}/trash/empty`, { method: 'POST', body: { by: '' } });
|
|
195
|
+
await refreshCurrent();
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async transferCard(cardId, toBoard, toColumn, mode, assignees) {
|
|
199
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}/transfer`, {
|
|
200
|
+
method: 'POST', body: { toBoard, toColumn, mode, assignees, by: '' },
|
|
201
|
+
});
|
|
202
|
+
await loadBoards();
|
|
203
|
+
await refreshCurrent();
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async avatarsChanged() {
|
|
207
|
+
state.avatarVer++;
|
|
208
|
+
state.cfg = await api('api/config');
|
|
209
|
+
state.users = state.cfg.users || [];
|
|
210
|
+
renderHeader();
|
|
211
|
+
render();
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async addCard(data) {
|
|
215
|
+
data.by = '';
|
|
216
|
+
data.createdBy = '';
|
|
217
|
+
await api(`api/boards/${state.board.id}/cards`, { method: 'POST', body: data });
|
|
218
|
+
await refreshCurrent();
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async updateCard(cardId, data) {
|
|
222
|
+
data.by = '';
|
|
223
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}`, { method: 'PATCH', body: data });
|
|
224
|
+
await refreshCurrent();
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async moveCard(cardId, columnId, order) {
|
|
228
|
+
// optimistisch: lokal sofort umhängen, Server bestätigt via dirty/refresh
|
|
229
|
+
const card = state.board.cards.find(c => c.id === cardId);
|
|
230
|
+
if (card) { card.columnId = columnId; if (order !== undefined) card.order = order - 0.5; }
|
|
231
|
+
try {
|
|
232
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}/move`, {
|
|
233
|
+
method: 'POST', body: { columnId, order, by: '' },
|
|
234
|
+
});
|
|
235
|
+
} catch (e) {
|
|
236
|
+
alert(t('error.moveFailed', { msg: e.message }));
|
|
237
|
+
}
|
|
238
|
+
await refreshCurrent();
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async deleteCard(cardId) {
|
|
242
|
+
await api(`api/boards/${state.board.id}/cards/${cardId}`, { method: 'DELETE' });
|
|
243
|
+
await refreshCurrent();
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async patchBoard(patch) {
|
|
247
|
+
await api(`api/boards/${state.board.id}`, { method: 'PATCH', body: patch });
|
|
248
|
+
await loadBoards();
|
|
249
|
+
await refreshCurrent();
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async createBoard(title) {
|
|
253
|
+
const board = await api('api/boards', { method: 'POST', body: { title } });
|
|
254
|
+
// Neues Board: standardmaessig alle bekannten Benutzer als Mitglieder
|
|
255
|
+
if ((state.users || []).length) {
|
|
256
|
+
await api(`api/boards/${encodeURIComponent(board.id)}`, { method: 'PATCH', body: { members: state.users.map(u => u.name) } });
|
|
257
|
+
}
|
|
258
|
+
await loadBoards();
|
|
259
|
+
await loadBoard(board.id, true);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async patchBoardById(id, patch) {
|
|
263
|
+
await api(`api/boards/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch });
|
|
264
|
+
await loadBoards();
|
|
265
|
+
if (state.board && state.board.id === id) await refreshCurrent();
|
|
266
|
+
else render();
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// Board anlegen, ohne das aktive Board zu wechseln (Einstellungen-Dialog bleibt offen)
|
|
270
|
+
async createBoardOnly(title) {
|
|
271
|
+
const board = await api('api/boards', { method: 'POST', body: { title } });
|
|
272
|
+
if ((state.users || []).length) {
|
|
273
|
+
await api(`api/boards/${encodeURIComponent(board.id)}`, { method: 'PATCH', body: { members: state.users.map(u => u.name) } });
|
|
274
|
+
}
|
|
275
|
+
await loadBoards();
|
|
276
|
+
render();
|
|
277
|
+
return board;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
// Aktives Board wechseln (aus dem Einstellungen-Dialog heraus, Dialog bleibt offen)
|
|
281
|
+
async switchBoard(id) {
|
|
282
|
+
await loadBoard(id, true);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async deleteBoard(id) {
|
|
286
|
+
const wasActive = !!(state.board && state.board.id === id);
|
|
287
|
+
await api(`api/boards/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
288
|
+
await loadBoards();
|
|
289
|
+
if (wasActive) {
|
|
290
|
+
state.board = null;
|
|
291
|
+
await loadBoard(state.boards[0] && state.boards[0].id, true);
|
|
292
|
+
} else {
|
|
293
|
+
render();
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// ------------------------------------------------------------ Init
|
|
299
|
+
|
|
300
|
+
async function init() {
|
|
301
|
+
// Stale-Cache-Schutz: fehlt ein erwartetes Formularfeld, ist ein veraltetes
|
|
302
|
+
// index.html gecacht (neues JS + altes HTML) → einmal frisch nachladen.
|
|
303
|
+
const cf = document.getElementById('cardForm');
|
|
304
|
+
if (cf && cf.elements && !cf.elements.recType) {
|
|
305
|
+
if (!sessionStorage.getItem('kanban.reloaded')) {
|
|
306
|
+
sessionStorage.setItem('kanban.reloaded', '1');
|
|
307
|
+
location.reload();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
sessionStorage.removeItem('kanban.reloaded'); // frisches HTML → Schutz zurücksetzen
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (state.embed) document.body.classList.add('embed');
|
|
315
|
+
|
|
316
|
+
state.cfg = await api('api/config');
|
|
317
|
+
state.users = state.cfg.users || [];
|
|
318
|
+
await initI18n(state.cfg.language);
|
|
319
|
+
applyStatic();
|
|
320
|
+
applyTheme(state.cfg);
|
|
321
|
+
|
|
322
|
+
const dialogs = initDialogs(state, actions);
|
|
323
|
+
actions.openCard = dialogs.openCard;
|
|
324
|
+
actions.copyCard = dialogs.copyCard;
|
|
325
|
+
actions.openDescription = dialogs.openDescription;
|
|
326
|
+
actions.confirm = dialogs.confirm;
|
|
327
|
+
|
|
328
|
+
document.getElementById('boardSelect').addEventListener('change', ev => loadBoard(ev.target.value, true));
|
|
329
|
+
document.getElementById('addCardBtn').addEventListener('click', () => state.board && dialogs.openCard(null));
|
|
330
|
+
document.getElementById('settingsBtn').addEventListener('click', () => {
|
|
331
|
+
Promise.resolve(dialogs.openBoardManager()).catch(e => alert(t('error.loadFailed') + ': ' + e.message));
|
|
332
|
+
});
|
|
333
|
+
document.getElementById('shareBtn').addEventListener('click', () => dialogs.openShareDialog());
|
|
334
|
+
// Der fruehere „nur meine Karten"-Button entfaellt – Filtern geschieht ueber die Kopf-Chips.
|
|
335
|
+
document.getElementById('filterBtn').hidden = true;
|
|
336
|
+
// Per URL ausblendbare Bedienelemente (geteilte Ansichten)
|
|
337
|
+
if (state.hideSettings) document.getElementById('settingsBtn').hidden = true;
|
|
338
|
+
document.getElementById('themeBtn').addEventListener('click', () => {
|
|
339
|
+
// Immer sichtbar umschalten: der Zwischenschritt 'auto' wirkte wie ein
|
|
340
|
+
// wirkungsloser Klick, wenn das System ohnehin dasselbe Theme liefert.
|
|
341
|
+
// 'auto' bleibt die Vorgabe, solange hier nie geklickt wurde.
|
|
342
|
+
const resolved = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
|
343
|
+
state.theme = resolved === 'dark' ? 'light' : 'dark';
|
|
344
|
+
localStorage.setItem('kanban.theme', state.theme);
|
|
345
|
+
applyTheme(state.cfg);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Toolbar-Icons als MDI (Monitor = Ansichten, Hell/Dunkel = Theme, Zahnrad = Einstellungen)
|
|
349
|
+
const TOOLBAR_ICONS = {
|
|
350
|
+
shareBtn: "M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z",
|
|
351
|
+
settingsBtn: "M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z",
|
|
352
|
+
};
|
|
353
|
+
for (const [id, path] of Object.entries(TOOLBAR_ICONS)) {
|
|
354
|
+
const b = document.getElementById(id);
|
|
355
|
+
if (b) { b.textContent = ''; b.appendChild(mdiIcon(path)); }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await loadBoards();
|
|
359
|
+
const wanted = qs.get('board');
|
|
360
|
+
const first = state.boards.find(b => b.id === wanted) || state.boards[0];
|
|
361
|
+
await loadBoard(first && first.id, true);
|
|
362
|
+
|
|
363
|
+
// URL-Vorbelegung des User-Filters (?users= oder ?user=) fuer das Startboard
|
|
364
|
+
const urlUsers = (qs.get('users') || qs.get('user') || '').split(',').filter(Boolean);
|
|
365
|
+
if (urlUsers.length && state.board) {
|
|
366
|
+
// Nur fuer diesen Aufruf setzen, nicht speichern: ein geteilter Link soll die
|
|
367
|
+
// persoenliche Chip-Auswahl des Geraets nicht dauerhaft ueberschreiben.
|
|
368
|
+
state.usersFilter = urlUsers;
|
|
369
|
+
render();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Deep-Link auf Karte (aus E-Mail): 'card' öffnet den Editor
|
|
373
|
+
const cardParam = qs.get('card');
|
|
374
|
+
if (cardParam && state.board && state.board.cards.some(c => c.id === cardParam)) {
|
|
375
|
+
dialogs.openCard(cardParam);
|
|
376
|
+
}
|
|
377
|
+
// Link-Ziel 'board': Karte im Board hervorheben statt Editor öffnen ('focus'-Parameter)
|
|
378
|
+
const focusParam = qs.get('focus');
|
|
379
|
+
if (focusParam && state.board && state.board.cards.some(c => c.id === focusParam)) {
|
|
380
|
+
requestAnimationFrame(() => {
|
|
381
|
+
const sel = (window.CSS && CSS.escape) ? CSS.escape(focusParam) : focusParam;
|
|
382
|
+
const cardEl = boardEl.querySelector(`.card[data-card-id="${sel}"]`);
|
|
383
|
+
if (cardEl) {
|
|
384
|
+
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
385
|
+
cardEl.classList.add('card-focus');
|
|
386
|
+
setTimeout(() => cardEl.classList.remove('card-focus'), 3000);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
liveSync(
|
|
392
|
+
(boardId, rev) => {
|
|
393
|
+
if (rev === -1) { loadBoards().then(render); return; }
|
|
394
|
+
if (state.board && boardId === state.board.id && rev > state.board.rev) refreshCurrent();
|
|
395
|
+
else if (!state.board) loadBoards().then(() => loadBoard(boardId, true));
|
|
396
|
+
else loadBoards().then(renderHeader);
|
|
397
|
+
},
|
|
398
|
+
// Waehrend eines Adapter-Neustarts schlaegt der Abgleich fehl; das ist normal
|
|
399
|
+
// und darf den naechsten Versuch nicht verhindern.
|
|
400
|
+
() => refreshCurrent().catch(() => {}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
render();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
init().catch(e => {
|
|
407
|
+
// i18n ist evtl. noch nicht geladen → Fallback zweisprachig
|
|
408
|
+
const label = t('error.loadFailed') === 'error.loadFailed' ? 'Fehler beim Laden / Loading failed' : t('error.loadFailed');
|
|
409
|
+
const esc = String(e && e.message || e).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
|
410
|
+
boardEl.innerHTML = `<div class="empty">${label}: ${esc}</div>`;
|
|
411
|
+
});
|