@zeyos/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +458 -0
  4. package/agents/README.md +66 -0
  5. package/agents/shared/business-app-benchmarks.md +111 -0
  6. package/agents/shared/zeyos-entity-map.md +142 -0
  7. package/agents/shared/zeyos-entity-reference.md +570 -0
  8. package/agents/shared/zeyos-query-patterns.md +89 -0
  9. package/agents/zeyos-account-intelligence/SKILL.md +34 -0
  10. package/agents/zeyos-account-intelligence/agents/openai.yaml +4 -0
  11. package/agents/zeyos-account-intelligence/references/workflows.md +84 -0
  12. package/agents/zeyos-billing-insights/SKILL.md +41 -0
  13. package/agents/zeyos-billing-insights/agents/openai.yaml +4 -0
  14. package/agents/zeyos-billing-insights/references/workflows.md +106 -0
  15. package/agents/zeyos-campaign-and-outreach/SKILL.md +44 -0
  16. package/agents/zeyos-campaign-and-outreach/agents/openai.yaml +4 -0
  17. package/agents/zeyos-campaign-and-outreach/references/workflows.md +100 -0
  18. package/agents/zeyos-collaboration-and-activity/SKILL.md +37 -0
  19. package/agents/zeyos-collaboration-and-activity/agents/openai.yaml +4 -0
  20. package/agents/zeyos-collaboration-and-activity/references/workflows.md +104 -0
  21. package/agents/zeyos-collections-and-dunning/SKILL.md +46 -0
  22. package/agents/zeyos-collections-and-dunning/agents/openai.yaml +4 -0
  23. package/agents/zeyos-collections-and-dunning/references/workflows.md +132 -0
  24. package/agents/zeyos-commerce-and-inventory/SKILL.md +38 -0
  25. package/agents/zeyos-commerce-and-inventory/agents/openai.yaml +4 -0
  26. package/agents/zeyos-commerce-and-inventory/references/workflows.md +101 -0
  27. package/agents/zeyos-mail-operations/SKILL.md +35 -0
  28. package/agents/zeyos-mail-operations/agents/openai.yaml +4 -0
  29. package/agents/zeyos-mail-operations/references/workflows.md +110 -0
  30. package/agents/zeyos-notes-and-sops/SKILL.md +31 -0
  31. package/agents/zeyos-notes-and-sops/agents/openai.yaml +4 -0
  32. package/agents/zeyos-notes-and-sops/references/workflows.md +85 -0
  33. package/agents/zeyos-platform-and-schema/SKILL.md +37 -0
  34. package/agents/zeyos-platform-and-schema/agents/openai.yaml +4 -0
  35. package/agents/zeyos-platform-and-schema/references/workflows.md +97 -0
  36. package/agents/zeyos-work-management/SKILL.md +45 -0
  37. package/agents/zeyos-work-management/agents/openai.yaml +4 -0
  38. package/agents/zeyos-work-management/references/workflows.md +148 -0
  39. package/docs/01-api-reference/01-data-retrieval.md +601 -0
  40. package/docs/01-api-reference/02-authentication.md +288 -0
  41. package/docs/01-api-reference/03-resources.md +270 -0
  42. package/docs/01-api-reference/04-schema.md +539 -0
  43. package/docs/01-api-reference/_category_.json +9 -0
  44. package/docs/02-javascript-client/01-getting-started.md +146 -0
  45. package/docs/02-javascript-client/02-authentication.md +287 -0
  46. package/docs/02-javascript-client/03-making-requests.md +572 -0
  47. package/docs/02-javascript-client/04-practical-guide.md +348 -0
  48. package/docs/02-javascript-client/_category_.json +9 -0
  49. package/docs/03-cli/01-getting-started.md +219 -0
  50. package/docs/03-cli/02-commands.md +407 -0
  51. package/docs/03-cli/03-configuration.md +220 -0
  52. package/docs/03-cli/_category_.json +9 -0
  53. package/docs/04-agent-workflows/00-coding-agents.md +35 -0
  54. package/docs/04-agent-workflows/01-agent-quickstart.md +147 -0
  55. package/docs/04-agent-workflows/02-agent-recipes.md +109 -0
  56. package/docs/04-agent-workflows/03-cli-coverage-and-escalation.md +65 -0
  57. package/docs/04-agent-workflows/_category_.json +9 -0
  58. package/docs/04-sample-apps/01-kanban.md +89 -0
  59. package/docs/04-sample-apps/02-crm.md +81 -0
  60. package/docs/04-sample-apps/03-dashboard.md +80 -0
  61. package/docs/04-sample-apps/_category_.json +9 -0
  62. package/docs/05-tutorials/00-application-developers.md +43 -0
  63. package/docs/05-tutorials/01-integration-architecture.md +60 -0
  64. package/docs/05-tutorials/02-build-your-own-zeyos-frontend.md +517 -0
  65. package/docs/05-tutorials/03-server-side-integrations.md +185 -0
  66. package/docs/05-tutorials/_category_.json +9 -0
  67. package/docs/intro.md +197 -0
  68. package/openapi/api.json +24308 -0
  69. package/openapi/auth.json +415 -0
  70. package/openapi/dbref.json +56223 -0
  71. package/openapi/oauth2.json +781 -0
  72. package/openapi/sdk.json +949 -0
  73. package/openapi/views.txt +642 -0
  74. package/package.json +49 -0
  75. package/samples/crm/README.md +28 -0
  76. package/samples/crm/index.html +327 -0
  77. package/samples/crm/js/api.js +208 -0
  78. package/samples/crm/js/auth.js +61 -0
  79. package/samples/crm/js/main.js +545 -0
  80. package/samples/crm/js/state.js +90 -0
  81. package/samples/crm/js/ui.js +51 -0
  82. package/samples/dashboard/README.md +28 -0
  83. package/samples/dashboard/index.html +280 -0
  84. package/samples/dashboard/js/api.js +197 -0
  85. package/samples/dashboard/js/auth.js +59 -0
  86. package/samples/dashboard/js/main.js +382 -0
  87. package/samples/dashboard/js/state.js +81 -0
  88. package/samples/dashboard/js/ui.js +48 -0
  89. package/samples/kanban/README.md +28 -0
  90. package/samples/kanban/index.html +263 -0
  91. package/samples/kanban/js/api.js +152 -0
  92. package/samples/kanban/js/auth.js +59 -0
  93. package/samples/kanban/js/constants.js +40 -0
  94. package/samples/kanban/js/kanban.js +246 -0
  95. package/samples/kanban/js/main.js +362 -0
  96. package/samples/kanban/js/modals.js +474 -0
  97. package/samples/kanban/js/settings.js +82 -0
  98. package/samples/kanban/js/state.js +118 -0
  99. package/samples/kanban/js/ui.js +49 -0
  100. package/scripts/generate-client.mjs +344 -0
  101. package/src/generated/operations.js +9772 -0
  102. package/src/generated/schema.js +8982 -0
  103. package/src/index.js +85 -0
  104. package/src/runtime/client.js +1208 -0
  105. package/src/runtime/error.js +29 -0
  106. package/src/runtime/http.js +174 -0
  107. package/src/runtime/request-shape.js +35 -0
  108. package/src/runtime/schema.js +206 -0
  109. package/src/runtime/suggest.js +74 -0
  110. package/src/runtime/token-store.js +105 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Kanban board — column rendering + HTML5 drag-and-drop.
3
+ * Dispatches custom events for ticket interactions; all API calls are handled
4
+ * in main.js to keep this module free of async side-effects.
5
+ */
6
+ import { STATUS_MAP, PRIORITY_MAP } from './constants.js';
7
+ import { runtime } from './state.js';
8
+
9
+ // ── Custom Events ──────────────────────────────────────────────────────────
10
+ // 'ticket:open' — detail({ ticketId })
11
+ // 'ticket:move' — detail({ ticketId, toStatus })
12
+ // 'ticket:create' — detail({ status }) (quick-create in a column)
13
+
14
+ // ── Public API ─────────────────────────────────────────────────────────────
15
+
16
+ /** Render (or re-render) the entire board into #kanban-board. */
17
+ export function renderBoard() {
18
+ const board = document.getElementById('kanban-board');
19
+ if (!board) return;
20
+
21
+ const { settings, tickets } = runtime;
22
+
23
+ if (settings.columns.length === 0) {
24
+ board.innerHTML = '';
25
+ document.getElementById('no-columns-banner')?.classList.remove('hidden');
26
+ return;
27
+ }
28
+
29
+ document.getElementById('no-columns-banner')?.classList.add('hidden');
30
+ board.innerHTML = '';
31
+
32
+ for (const statusValue of settings.columns) {
33
+ const status = STATUS_MAP[statusValue];
34
+ if (!status) continue;
35
+ const colTickets = tickets.filter(t => t.status === statusValue);
36
+ board.appendChild(_buildColumn(status, colTickets));
37
+ }
38
+ }
39
+
40
+ /** Update a single column in-place (faster than full re-render). */
41
+ export function updateColumn(statusValue) {
42
+ const col = document.querySelector(`[data-column="${statusValue}"]`);
43
+ if (!col) { renderBoard(); return; }
44
+
45
+ const status = STATUS_MAP[statusValue];
46
+ const colTickets = runtime.tickets.filter(t => t.status === statusValue);
47
+
48
+ const badge = col.querySelector('.col-badge');
49
+ if (badge) badge.textContent = colTickets.length;
50
+
51
+ const list = col.querySelector('.col-cards');
52
+ if (list) {
53
+ list.innerHTML = '';
54
+ for (const ticket of colTickets) list.appendChild(_buildCard(ticket));
55
+ }
56
+ }
57
+
58
+ // ── Column Builder ─────────────────────────────────────────────────────────
59
+
60
+ function _buildColumn(status, tickets) {
61
+ const col = document.createElement('div');
62
+ col.className = 'kanban-col flex flex-col rounded-xl shadow-sm overflow-hidden';
63
+ col.style.cssText = 'min-width:280px; width:280px; max-height:100%;';
64
+ col.dataset.column = status.value;
65
+
66
+ const header = document.createElement('div');
67
+ header.className = 'col-header flex items-center justify-between px-3 py-2.5 flex-shrink-0';
68
+ header.style.cssText = `background:${status.headerBg}; border-bottom:2px solid ${status.cardBorder};`;
69
+ header.innerHTML = `
70
+ <div class="flex items-center gap-2">
71
+ <span class="font-semibold text-sm" style="color:${status.headerText}">${status.label}</span>
72
+ <span class="col-badge text-xs font-medium px-1.5 py-0.5 rounded-full bg-white/60" style="color:${status.headerText}">${tickets.length}</span>
73
+ </div>
74
+ <button class="col-add-btn text-lg leading-none opacity-50 hover:opacity-100 transition-opacity" style="color:${status.headerText}" title="Add ticket" data-status="${status.value}">+</button>
75
+ `;
76
+ col.appendChild(header);
77
+
78
+ const body = document.createElement('div');
79
+ body.className = 'col-cards flex-1 overflow-y-auto p-2 space-y-2 bg-white/50';
80
+ body.style.cssText = 'min-height:80px; background:#f8fafc;';
81
+ body.dataset.dropzone = status.value;
82
+
83
+ for (const ticket of tickets) body.appendChild(_buildCard(ticket));
84
+
85
+ body.addEventListener('dragover', _onDragOver);
86
+ body.addEventListener('dragenter', _onDragEnter);
87
+ body.addEventListener('dragleave', _onDragLeave);
88
+ body.addEventListener('drop', _onDrop);
89
+
90
+ col.appendChild(body);
91
+
92
+ header.querySelector('.col-add-btn').addEventListener('click', () => {
93
+ col.dispatchEvent(new CustomEvent('ticket:create', {
94
+ bubbles: true, detail: { status: status.value },
95
+ }));
96
+ });
97
+
98
+ return col;
99
+ }
100
+
101
+ // ── Card Builder ───────────────────────────────────────────────────────────
102
+
103
+ function _buildCard(ticket) {
104
+ const priority = PRIORITY_MAP[ticket.priority ?? 2];
105
+ const status = STATUS_MAP[ticket.status ?? 0];
106
+
107
+ const card = document.createElement('div');
108
+ card.className =
109
+ 'kanban-card bg-white rounded-lg p-3 shadow-sm cursor-pointer ' +
110
+ 'hover:shadow-md transition-all select-none group relative';
111
+ card.style.cssText = `border-left: 3px solid ${status?.cardBorder ?? '#94a3b8'};`;
112
+ card.draggable = true;
113
+ card.dataset.ticketId = ticket.ID;
114
+
115
+ const meta = document.createElement('div');
116
+ meta.className = 'flex items-center justify-between mb-1';
117
+ meta.innerHTML = `
118
+ <span class="text-xs text-slate-400 font-mono">${ticket.ticketnum ?? `#${ticket.ID}`}</span>
119
+ <span class="text-xs font-semibold" style="color:${priority.color}" title="${priority.label}">${priority.symbol}</span>
120
+ `;
121
+ card.appendChild(meta);
122
+
123
+ const name = document.createElement('p');
124
+ name.className = 'text-sm font-medium text-slate-800 leading-snug line-clamp-2 mb-1.5';
125
+ name.textContent = ticket.name ?? '(untitled)';
126
+ card.appendChild(name);
127
+
128
+ if (ticket.duedate) {
129
+ const due = document.createElement('div');
130
+ due.className = 'flex items-center gap-1 text-xs mb-1';
131
+ const isOverdue = ticket.duedate * 1000 < Date.now();
132
+ due.innerHTML = `
133
+ <span class="${isOverdue ? 'text-red-500' : 'text-slate-400'}">📅</span>
134
+ <span class="${isOverdue ? 'text-red-500 font-medium' : 'text-slate-400'}">${_formatDate(ticket.duedate)}</span>
135
+ `;
136
+ card.appendChild(due);
137
+ }
138
+
139
+ const actions = document.createElement('div');
140
+ actions.className =
141
+ 'absolute top-1.5 right-1.5 hidden group-hover:flex gap-1';
142
+ actions.innerHTML = `
143
+ <button class="card-btn-edit p-1 rounded bg-slate-100 hover:bg-blue-100 text-slate-500 hover:text-blue-600 text-xs" title="Edit">✎</button>
144
+ <button class="card-btn-delete p-1 rounded bg-slate-100 hover:bg-red-100 text-slate-500 hover:text-red-600 text-xs" title="Delete">✕</button>
145
+ `;
146
+ card.appendChild(actions);
147
+
148
+ card.addEventListener('dragstart', _onDragStart);
149
+ card.addEventListener('dragend', _onDragEnd);
150
+ // Allow dropping ONTO a card (not just onto the column background). Without
151
+ // this some browsers require e.preventDefault() at the target element level
152
+ // and won't allow a drop when the cursor is over a child card.
153
+ card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; });
154
+
155
+ card.addEventListener('click', e => {
156
+ if (e.target.closest('.card-btn-edit') || e.target.closest('.card-btn-delete')) return;
157
+ card.dispatchEvent(new CustomEvent('ticket:open', {
158
+ bubbles: true, detail: { ticketId: ticket.ID, mode: 'view' },
159
+ }));
160
+ });
161
+
162
+ actions.querySelector('.card-btn-edit').addEventListener('click', e => {
163
+ e.stopPropagation();
164
+ card.dispatchEvent(new CustomEvent('ticket:open', {
165
+ bubbles: true, detail: { ticketId: ticket.ID, mode: 'edit' },
166
+ }));
167
+ });
168
+
169
+ actions.querySelector('.card-btn-delete').addEventListener('click', e => {
170
+ e.stopPropagation();
171
+ card.dispatchEvent(new CustomEvent('ticket:delete', {
172
+ bubbles: true, detail: { ticketId: ticket.ID },
173
+ }));
174
+ });
175
+
176
+ return card;
177
+ }
178
+
179
+ // ── Drag & Drop ────────────────────────────────────────────────────────────
180
+
181
+ let _dragTicketId = null;
182
+ let _dragSourceStatus = null;
183
+
184
+ function _onDragStart(e) {
185
+ const card = e.currentTarget;
186
+ _dragTicketId = Number(card.dataset.ticketId);
187
+ _dragSourceStatus = Number(card.closest('[data-column]')?.dataset.column);
188
+ e.dataTransfer.effectAllowed = 'move';
189
+ e.dataTransfer.setData('text/plain', String(_dragTicketId));
190
+ // slight delay so the ghost image renders before we fade the card
191
+ requestAnimationFrame(() => card.style.opacity = '0.4');
192
+ }
193
+
194
+ function _onDragEnd(e) {
195
+ e.currentTarget.style.opacity = '';
196
+ _dragTicketId = null;
197
+ _dragSourceStatus = null;
198
+ document.querySelectorAll('.col-cards').forEach(z => z.classList.remove('drop-active'));
199
+ }
200
+
201
+ function _onDragOver(e) {
202
+ e.preventDefault();
203
+ e.dataTransfer.dropEffect = 'move';
204
+ }
205
+
206
+ function _onDragEnter(e) {
207
+ e.preventDefault();
208
+ e.currentTarget.classList.add('drop-active');
209
+ }
210
+
211
+ function _onDragLeave(e) {
212
+ // Only remove when leaving the dropzone itself, not a child element
213
+ if (!e.currentTarget.contains(e.relatedTarget)) {
214
+ e.currentTarget.classList.remove('drop-active');
215
+ }
216
+ }
217
+
218
+ function _onDrop(e) {
219
+ e.preventDefault();
220
+ const zone = e.currentTarget;
221
+ const toStatus = Number(zone.dataset.dropzone);
222
+ zone.classList.remove('drop-active');
223
+
224
+ // dataTransfer is the authoritative source — it's set in dragstart and
225
+ // stays available through the drop event regardless of dragend timing.
226
+ const raw = e.dataTransfer.getData('text/plain');
227
+ const ticketId = raw ? Number(raw) : _dragTicketId;
228
+ if (!ticketId) return;
229
+
230
+ // Read source status from the module variable; NaN means same-column check
231
+ // is skipped (safe to always dispatch and let main.js do the guard).
232
+ const srcStatus = Number.isFinite(_dragSourceStatus) ? _dragSourceStatus : toStatus + 1;
233
+ if (toStatus === srcStatus) return;
234
+
235
+ zone.dispatchEvent(new CustomEvent('ticket:move', {
236
+ bubbles: true, detail: { ticketId, toStatus },
237
+ }));
238
+ }
239
+
240
+ // ── Utilities ──────────────────────────────────────────────────────────────
241
+
242
+ function _formatDate(unix) {
243
+ return new Date(unix * 1000).toLocaleDateString(undefined, {
244
+ month: 'short', day: 'numeric', year: 'numeric',
245
+ });
246
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Application entry point.
3
+ * Bootstraps the client, handles auth, wires up all UI events,
4
+ * and orchestrates data loading / board re-renders.
5
+ *
6
+ * Authentication: reads config from <body> data attributes + localStorage.
7
+ * 1. If tokens are available → token mode
8
+ * 2. If URL only → try session detection via /oauth2/v1/userinfo
9
+ * 3. Otherwise → connection screen with troubleshooting
10
+ *
11
+ * Exposes a global ZeyOS console API for debugging / configuration.
12
+ */
13
+ import { initTokenClient, initSessionClient, fetchTickets, fetchProjects, updateTicket, deleteTicket } from './api.js';
14
+ import { trySessionAuth, logout } from './auth.js';
15
+ import { runtime, resolveConfig, saveUrl, saveTokens, clearTokens, clearUrl, saveContext } from './state.js';
16
+ import { renderBoard, updateColumn } from './kanban.js';
17
+ import { openTicketDetail, openCreateTicket } from './modals.js';
18
+ import { openSettings, closeSettings, onSettingsChange } from './settings.js';
19
+ import { showToast, showLoading, hideLoading } from './ui.js';
20
+
21
+ // ── Boot ────────────────────────────────────────────────────────────────────
22
+
23
+ document.addEventListener('DOMContentLoaded', async () => {
24
+ const config = resolveConfig();
25
+
26
+ // 1. No URL configured → show connection screen immediately
27
+ if (!config.url) {
28
+ _showConnectionScreen('No ZeyOS URL configured.');
29
+ return;
30
+ }
31
+
32
+ runtime.url = config.url;
33
+
34
+ // 2. Tokens available → initialize in token mode
35
+ if (config.accessToken) {
36
+ saveTokens({
37
+ accessToken: config.accessToken,
38
+ refreshToken: config.refreshToken ?? null,
39
+ });
40
+ initTokenClient(config.url);
41
+ runtime.authMode = 'token';
42
+ await _bootApp();
43
+ return;
44
+ }
45
+
46
+ // 3. No tokens → try session detection
47
+ showLoading();
48
+ const userInfo = await trySessionAuth(config.url);
49
+ hideLoading();
50
+
51
+ if (userInfo) {
52
+ initSessionClient(config.url);
53
+ runtime.authMode = 'session';
54
+ await _bootApp();
55
+ return;
56
+ }
57
+
58
+ // 4. Nothing works → connection screen
59
+ _showConnectionScreen('Could not connect. Set a token or log into ZeyOS first.');
60
+ });
61
+
62
+ // ── Connection Screen ────────────────────────────────────────────────────────
63
+
64
+ function _showConnectionScreen(message) {
65
+ document.getElementById('connection-screen')?.classList.remove('hidden');
66
+ document.getElementById('app-shell')?.classList.add('hidden');
67
+
68
+ const msgEl = document.getElementById('connection-message');
69
+ if (msgEl) msgEl.textContent = message;
70
+ }
71
+
72
+ // ── Main App Boot ───────────────────────────────────────────────────────────
73
+
74
+ async function _bootApp() {
75
+ document.getElementById('connection-screen')?.classList.add('hidden');
76
+ document.getElementById('app-shell')?.classList.remove('hidden');
77
+
78
+ onSettingsChange(_refreshBoard);
79
+
80
+ _wireNavbar();
81
+ _wireBoard();
82
+
83
+ showLoading();
84
+ try {
85
+ await _loadContextData();
86
+ await _loadTickets();
87
+ renderBoard();
88
+ } catch (err) {
89
+ if (err?.status === 401) {
90
+ showToast('Session expired. Please log in again.', 'error');
91
+ setTimeout(async () => { await logout(); location.reload(); }, 2000);
92
+ return;
93
+ }
94
+ showToast(`Failed to load data: ${err.message}`, 'error');
95
+ } finally {
96
+ hideLoading();
97
+ }
98
+ }
99
+
100
+ // ── Data Loading ────────────────────────────────────────────────────────────
101
+
102
+ async function _loadContextData() {
103
+ runtime.projects = await fetchProjects();
104
+ _renderContextDropdown();
105
+ _updateContextLabel();
106
+ }
107
+
108
+ async function _loadTickets() {
109
+ runtime.tickets = await fetchTickets({ context: runtime.context });
110
+ }
111
+
112
+ async function _refreshBoard() {
113
+ showLoading();
114
+ try {
115
+ await _loadTickets();
116
+ renderBoard();
117
+ } catch (err) {
118
+ showToast(`Refresh failed: ${err.message}`, 'error');
119
+ } finally {
120
+ hideLoading();
121
+ }
122
+ }
123
+
124
+ // ── Navbar Wiring ───────────────────────────────────────────────────────────
125
+
126
+ function _wireNavbar() {
127
+ document.getElementById('btn-settings')?.addEventListener('click', openSettings);
128
+ document.getElementById('btn-open-settings')?.addEventListener('click', openSettings);
129
+ document.getElementById('btn-close-settings')?.addEventListener('click', closeSettings);
130
+ document.getElementById('settings-overlay')?.addEventListener('click', closeSettings);
131
+
132
+ document.getElementById('btn-new-ticket')?.addEventListener('click', () => {
133
+ openCreateTicket(runtime.settings.columns[0] ?? 0);
134
+ });
135
+
136
+ document.getElementById('btn-logout')?.addEventListener('click', async () => {
137
+ if (!confirm('Log out of ZeyOS Kanban?')) return;
138
+ await logout();
139
+ clearUrl();
140
+ location.reload();
141
+ });
142
+
143
+ // Context button toggle
144
+ document.getElementById('btn-context')?.addEventListener('click', e => {
145
+ e.stopPropagation();
146
+ const drop = document.getElementById('context-dropdown');
147
+ if (!drop) return;
148
+ if (drop.classList.contains('hidden')) {
149
+ const rect = e.currentTarget.getBoundingClientRect();
150
+ drop.style.top = `${rect.bottom + 4}px`;
151
+ drop.style.left = `${rect.left}px`;
152
+ drop.classList.remove('hidden');
153
+ } else {
154
+ drop.classList.add('hidden');
155
+ }
156
+ });
157
+
158
+ document.addEventListener('click', e => {
159
+ if (!e.target.closest('#context-dropdown') && !e.target.closest('#btn-context')) {
160
+ document.getElementById('context-dropdown')?.classList.add('hidden');
161
+ }
162
+ });
163
+ }
164
+
165
+ // ── Board Event Wiring ──────────────────────────────────────────────────────
166
+
167
+ function _wireBoard() {
168
+ const board = document.getElementById('kanban-board');
169
+ if (!board) return;
170
+
171
+ // Ticket open
172
+ board.addEventListener('ticket:open', async e => {
173
+ const { ticketId, mode } = e.detail;
174
+ await openTicketDetail(ticketId, mode);
175
+ });
176
+
177
+ // Drag-and-drop move
178
+ board.addEventListener('ticket:move', async e => {
179
+ const { ticketId, toStatus } = e.detail;
180
+ const ticket = runtime.tickets.find(t => t.ID === ticketId);
181
+ if (!ticket) return;
182
+
183
+ const fromStatus = ticket.status;
184
+ // Optimistic update
185
+ ticket.status = toStatus;
186
+ updateColumn(fromStatus);
187
+ updateColumn(toStatus);
188
+
189
+ try {
190
+ const updated = await updateTicket(ticketId, { status: toStatus });
191
+
192
+ // Use the server-confirmed status from the response body.
193
+ // If the API rejected or clamped the value it will differ from toStatus.
194
+ const confirmedStatus = updated?.status ?? toStatus;
195
+ if (confirmedStatus !== toStatus) {
196
+ ticket.status = confirmedStatus;
197
+ updateColumn(toStatus);
198
+ updateColumn(confirmedStatus);
199
+ showToast(`Status set to ${confirmedStatus} (server override).`, 'info');
200
+ } else {
201
+ showToast('Status updated.', 'success');
202
+ }
203
+ } catch (err) {
204
+ // Revert on failure
205
+ ticket.status = fromStatus;
206
+ updateColumn(fromStatus);
207
+ updateColumn(toStatus);
208
+ showToast(`Move failed: ${err.message}`, 'error');
209
+ }
210
+ });
211
+
212
+ // Quick-create
213
+ board.addEventListener('ticket:create', e => {
214
+ openCreateTicket(e.detail.status);
215
+ });
216
+
217
+ // Delete from card hover button
218
+ board.addEventListener('ticket:delete', async e => {
219
+ const { ticketId } = e.detail;
220
+ const ticket = runtime.tickets.find(t => t.ID === ticketId);
221
+ if (!confirm(`Delete "${ticket?.name ?? ticketId}"?`)) return;
222
+ try {
223
+ await deleteTicket(ticketId);
224
+ runtime.tickets = runtime.tickets.filter(t => t.ID !== ticketId);
225
+ renderBoard();
226
+ showToast('Ticket deleted.', 'success');
227
+ } catch (err) {
228
+ showToast(`Delete failed: ${err.message}`, 'error');
229
+ }
230
+ });
231
+
232
+ // Reload after create/update/delete inside modals
233
+ document.addEventListener('app:reload', _refreshBoard);
234
+ }
235
+
236
+ // ── Context Dropdown ────────────────────────────────────────────────────────
237
+
238
+ function _renderContextDropdown() {
239
+ const drop = document.getElementById('context-dropdown');
240
+ if (!drop) return;
241
+
242
+ const allItem = { type: 'all', id: null, name: 'All Tickets' };
243
+ const projectItems = runtime.projects.map(p => ({ type: 'project', id: p.ID, name: p.name }));
244
+
245
+ const groupHtml = (title, list) =>
246
+ list.length === 0 ? '' : `
247
+ ${title ? `<li class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wide">${title}</li>` : ''}
248
+ ${list.map(item => `
249
+ <li>
250
+ <button class="ctx-item w-full text-left px-3 py-2 text-sm hover:bg-slate-50 text-slate-700 flex items-center gap-2"
251
+ data-type="${item.type}" data-id="${item.id ?? ''}" data-name="${_esc(item.name)}">
252
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 ${item.type === 'all' ? 'bg-slate-400' : 'bg-purple-400'}"></span>
253
+ ${_esc(item.name)}
254
+ </button>
255
+ </li>
256
+ `).join('')}
257
+ `;
258
+
259
+ drop.innerHTML = `<ul class="py-1">
260
+ ${groupHtml('', [allItem])}
261
+ ${groupHtml('Projects', projectItems)}
262
+ </ul>`;
263
+
264
+ drop.querySelectorAll('.ctx-item').forEach(btn => {
265
+ btn.addEventListener('click', async () => {
266
+ drop.classList.add('hidden');
267
+ const ctx = {
268
+ type: btn.dataset.type,
269
+ id: btn.dataset.id ? Number(btn.dataset.id) : null,
270
+ name: btn.dataset.name,
271
+ };
272
+ runtime.context = ctx;
273
+ saveContext(ctx);
274
+ _updateContextLabel();
275
+ await _refreshBoard();
276
+ });
277
+ });
278
+ }
279
+
280
+ function _updateContextLabel() {
281
+ const el = document.getElementById('context-label');
282
+ if (el) el.textContent = runtime.context.name ?? 'All Tickets';
283
+ }
284
+
285
+ function _esc(str) {
286
+ return String(str)
287
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
288
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
289
+ }
290
+
291
+ // ── Console API ─────────────────────────────────────────────────────────────
292
+ // Provides ZeyOS.setUrl(), ZeyOS.setToken(), ZeyOS.status(), etc.
293
+ // Values set via the console are persisted to localStorage and override
294
+ // <body> data attributes on next page load.
295
+
296
+ globalThis.ZeyOS = {
297
+ /**
298
+ * Set the ZeyOS instance URL.
299
+ * @param {string} url - e.g. 'https://cloud.zeyos.com/demo/'
300
+ */
301
+ setUrl(url) {
302
+ if (!url || typeof url !== 'string') {
303
+ console.error('[ZeyOS] Usage: ZeyOS.setUrl("https://cloud.zeyos.com/demo/")');
304
+ return;
305
+ }
306
+ saveUrl(url.trim());
307
+ console.log(`%c[ZeyOS]%c URL set to: ${url}`, 'color:#2563eb;font-weight:bold', '');
308
+ console.log('%c[ZeyOS]%c Call ZeyOS.reconnect() to apply.', 'color:#2563eb;font-weight:bold', '');
309
+ },
310
+
311
+ /**
312
+ * Set access (and optionally refresh) token.
313
+ * @param {string} accessToken
314
+ * @param {string} [refreshToken]
315
+ */
316
+ setToken(accessToken, refreshToken) {
317
+ if (!accessToken || typeof accessToken !== 'string') {
318
+ console.error('[ZeyOS] Usage: ZeyOS.setToken("access-token", "optional-refresh-token")');
319
+ return;
320
+ }
321
+ saveTokens({
322
+ accessToken: accessToken.trim(),
323
+ refreshToken: refreshToken?.trim() ?? null,
324
+ });
325
+ console.log('%c[ZeyOS]%c Token saved.', 'color:#2563eb;font-weight:bold', '');
326
+ console.log('%c[ZeyOS]%c Call ZeyOS.reconnect() to apply.', 'color:#2563eb;font-weight:bold', '');
327
+ },
328
+
329
+ /**
330
+ * Print the current connection status to the console.
331
+ */
332
+ status() {
333
+ const config = resolveConfig();
334
+ const lines = [
335
+ '',
336
+ ` URL: ${config.url ?? '(not set)'}`,
337
+ ` Access Token: ${config.accessToken ? config.accessToken.slice(0, 16) + '...' : '(not set)'}`,
338
+ ` Refresh Token: ${config.refreshToken ? 'yes' : 'no'}`,
339
+ ` Auth Mode: ${runtime.authMode ?? '(not connected)'}`,
340
+ '',
341
+ ];
342
+ console.log(`%c[ZeyOS] Status%c\n${lines.join('\n')}`, 'color:#2563eb;font-weight:bold', 'color:inherit');
343
+ },
344
+
345
+ /**
346
+ * Clear all stored config (URL + tokens) and reload.
347
+ */
348
+ logout() {
349
+ clearTokens();
350
+ clearUrl();
351
+ console.log('%c[ZeyOS]%c Config cleared. Reloading...', 'color:#2563eb;font-weight:bold', '');
352
+ location.reload();
353
+ },
354
+
355
+ /**
356
+ * Reload the page to re-run the boot sequence with current config.
357
+ */
358
+ reconnect() {
359
+ console.log('%c[ZeyOS]%c Reconnecting...', 'color:#2563eb;font-weight:bold', '');
360
+ location.reload();
361
+ },
362
+ };