admindb 1.0.3 → 1.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.
@@ -1,31 +1,71 @@
1
- /* Row actions on the table browse page: delete, page-size, row filters, and
2
- * CSV import. */
1
+ /* Table browse page.
2
+ *
3
+ * Features:
4
+ * - Inline (spreadsheet-style) cell editing — double-click a cell, edit in
5
+ * place with a type-aware control (FK dropdown, boolean toggle, date picker,
6
+ * number input, text input). Changes are staged in the grid and marked as
7
+ * pending; press "Apply" to write them all at once (single transaction), or
8
+ * "Discard" to revert every pending cell back to the server values.
9
+ * - Type-aware row filters — the filter form adapts to each column's type
10
+ * (FK dropdowns, boolean toggles, date/number ranges, plain text).
11
+ * - Bulk row operations — row checkboxes + batch delete (with FK-impact
12
+ * warnings) and batch CSV/JSON export.
13
+ * - Plus the existing single-row delete, page-size, CSV import and
14
+ * related-rows expander.
15
+ */
3
16
  (function () {
4
17
  'use strict';
5
18
 
6
19
  const base = (window.APP && window.APP.basePath) || '';
20
+ const readonly = !!(window.APP && window.APP.readonly);
7
21
 
8
- // ---- Delete row --------------------------------------------------------
9
-
10
- document.querySelectorAll('.js-delete-row').forEach((btn) => {
11
- btn.addEventListener('click', () => {
12
- const table = btn.dataset.table;
13
- const pk = btn.dataset.pk;
14
- if (!table || !pk) return;
15
- if (!window.confirm('Delete this row? This cannot be undone.')) return;
16
- Api.del('/api/tables/' + encodeURIComponent(table) + '/row/' + encodeURIComponent(pk))
17
- .then(() => {
18
- UI.showToast('Row deleted.', 'success');
19
- window.location.reload();
20
- })
21
- .catch((e) => UI.showError(e.message));
22
+ const cfgEl = document.getElementById('browse-config');
23
+ const cfg = cfgEl
24
+ ? JSON.parse(cfgEl.textContent)
25
+ : { table: '', filters: {}, pkCols: [], hasPk: false, readonly: readonly };
26
+ const t = encodeURIComponent(cfg.table);
27
+
28
+ function escapeHtml(s) {
29
+ return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
30
+ }
31
+
32
+ let info = null; // TableInfoData from /api/tables/:table/info
33
+ let fkOptions = {}; // { [from]: [{ value, label }] }
34
+
35
+ function colInfo(name) {
36
+ return info ? info.columns.find((c) => c.name === name) : null;
37
+ }
38
+ function isPk(name) {
39
+ return !!info && info.primaryKey.indexOf(name) !== -1;
40
+ }
41
+ function fkFor(name) {
42
+ return info ? info.foreignKeys.find((f) => f.from === name) : null;
43
+ }
44
+
45
+ // ---- Single row delete --------------------------------------------------
46
+
47
+ function initRowDelete() {
48
+ document.querySelectorAll('.js-delete-row').forEach((btn) => {
49
+ btn.addEventListener('click', () => {
50
+ const table = btn.dataset.table;
51
+ const pk = btn.dataset.pk;
52
+ if (!table || !pk) return;
53
+ if (!window.confirm('Delete this row? This cannot be undone.')) return;
54
+ Api.del('/api/tables/' + encodeURIComponent(table) + '/row/' + encodeURIComponent(pk))
55
+ .then(() => {
56
+ UI.showToast('Row deleted.', 'success');
57
+ window.location.reload();
58
+ })
59
+ .catch((e) => UI.showError(e.message));
60
+ });
22
61
  });
23
- });
62
+ }
24
63
 
25
64
  // ---- Page size (keeps current filters) ----------------------------------
26
65
 
27
- const sizeSel = document.getElementById('page-size');
28
- if (sizeSel) {
66
+ function initPageSize() {
67
+ const sizeSel = document.getElementById('page-size');
68
+ if (!sizeSel) return;
29
69
  sizeSel.addEventListener('change', () => {
30
70
  const table = sizeSel.dataset.table;
31
71
  const size = sizeSel.value;
@@ -40,84 +80,798 @@
40
80
  });
41
81
  }
42
82
 
43
- // ---- Row filters --------------------------------------------------------
83
+ // ---- Type-aware row filters ---------------------------------------------
44
84
 
45
- const filterForm = document.getElementById('filter-form');
46
- const clearBtn = document.getElementById('clear-filter');
47
- if (filterForm) {
48
- function currentFilters() {
49
- const out = {};
50
- filterForm.querySelectorAll('.filter-input').forEach((input) => {
51
- const v = (input.value || '').trim();
52
- if (v) out[input.name] = v;
53
- });
54
- return out;
55
- }
56
- function navigate(filters) {
57
- let table = '';
58
- const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
59
- if (m) table = decodeURIComponent(m[1]);
60
- if (!table) return;
61
- const qs = new URLSearchParams({ page: '1', size: '50' });
62
- const keys = Object.keys(filters);
63
- if (keys.length) qs.set('f', JSON.stringify(filters));
64
- window.location.href = base + '/tables/' + encodeURIComponent(table) + '?' + qs.toString();
65
- }
66
- filterForm.addEventListener('submit', (e) => {
67
- e.preventDefault();
68
- navigate(currentFilters());
69
- });
70
- if (clearBtn) {
71
- clearBtn.addEventListener('click', () => navigate({}));
85
+ function filterCondArray(existing) {
86
+ if (existing == null) return [];
87
+ if (Array.isArray(existing)) {
88
+ return existing.filter((x) => x && typeof x === 'object' && typeof x.op === 'string');
72
89
  }
90
+ if (typeof existing === 'object') return [existing];
91
+ return [];
92
+ }
93
+ function legacyString(existing) {
94
+ return typeof existing === 'string' ? existing : null;
95
+ }
96
+ function condVal(conds, op) {
97
+ const c = conds.find((x) => x.op === op);
98
+ return c ? (c.value ?? null) : null;
99
+ }
100
+ function condBetween(conds) {
101
+ const c = conds.find((x) => x.op === 'between');
102
+ return c ? { min: c.value ?? null, max: c.max ?? null } : { min: null, max: null };
73
103
  }
74
104
 
75
- // ---- CSV import ---------------------------------------------------------
105
+ function toDateInputValue(v, dateOnly) {
106
+ const m = String(v).match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2}))?/);
107
+ if (!m) return '';
108
+ if (dateOnly) return m[1];
109
+ return m[1] + 'T' + (m[2] ? m[2] + ':' + m[3] : '00:00');
110
+ }
111
+
112
+ function addOption(sel, value, label) {
113
+ const opt = document.createElement('option');
114
+ opt.value = value;
115
+ opt.textContent = label;
116
+ sel.appendChild(opt);
117
+ return opt;
118
+ }
119
+
120
+ function buildFilterField(col) {
121
+ const wrap = document.createElement('label');
122
+ wrap.className = 'block';
123
+ const label = document.createElement('span');
124
+ label.className = 'field-label !mb-1 font-mono';
125
+ label.textContent = col.name;
126
+ wrap.appendChild(label);
127
+
128
+ const fk = fkFor(col.name);
129
+ const type = (col.type || '').toUpperCase();
130
+ const isBool = type === 'BOOLEAN';
131
+ const isNum = type.startsWith('INTEGER') || type === 'REAL';
132
+ const isDate = type === 'DATE' || type === 'DATETIME';
133
+ const existing = cfg.filters[col.name];
134
+ const conds = filterCondArray(existing);
135
+ const legacy = legacyString(existing);
136
+
137
+ // FK dropdown — exact match on the referenced value.
138
+ if (fk) {
139
+ const sel = document.createElement('select');
140
+ sel.className = 'filter-input field-select !py-1.5 !text-[13px]';
141
+ sel.dataset.column = col.name;
142
+ sel.dataset.kind = 'fk';
143
+ addOption(sel, '', '(any)');
144
+ addOption(sel, '__NULL__', '(not set / NULL)');
145
+ (fkOptions[col.name] || []).forEach((o) => addOption(sel, String(o.value), o.label === '' ? String(o.value) : o.label));
146
+ let selVal = condVal(conds, 'eq');
147
+ if (selVal == null && legacy && legacy.startsWith('=')) selVal = legacy.slice(1);
148
+ if (selVal === '__NULL__') sel.value = '__NULL__';
149
+ else if (selVal != null && [...sel.options].some((o) => o.value === String(selVal))) sel.value = String(selVal);
150
+ wrap.appendChild(sel);
151
+ return wrap;
152
+ }
153
+
154
+ // Boolean toggle.
155
+ if (isBool) {
156
+ const sel = document.createElement('select');
157
+ sel.className = 'filter-input field-select !py-1.5 !text-[13px]';
158
+ sel.dataset.column = col.name;
159
+ sel.dataset.kind = 'bool';
160
+ addOption(sel, '', '(any)');
161
+ addOption(sel, '1', 'true');
162
+ addOption(sel, '0', 'false');
163
+ let selVal = condVal(conds, 'eq');
164
+ if (selVal == null && legacy && legacy.startsWith('=')) selVal = legacy.slice(1);
165
+ if (selVal === '1' || selVal === '0' || selVal === 'true' || selVal === 'false') {
166
+ sel.value = selVal === 'true' || selVal === '1' ? '1' : '0';
167
+ }
168
+ wrap.appendChild(sel);
169
+ return wrap;
170
+ }
171
+
172
+ // Date / datetime range, or numeric range.
173
+ if (isDate || isNum) {
174
+ const row = document.createElement('div');
175
+ row.className = 'flex items-center gap-1.5';
176
+ const lo = document.createElement('input');
177
+ const hi = document.createElement('input');
178
+ if (isDate) {
179
+ lo.type = hi.type = type === 'DATE' ? 'date' : 'datetime-local';
180
+ } else {
181
+ lo.type = hi.type = 'number';
182
+ lo.step = hi.step = type === 'REAL' ? 'any' : '1';
183
+ }
184
+ lo.className = 'filter-input field-input !py-1.5 !text-[13px]';
185
+ hi.className = 'filter-input field-input !py-1.5 !text-[13px]';
186
+ lo.dataset.column = col.name;
187
+ lo.dataset.bound = 'min';
188
+ lo.placeholder = isDate ? 'from' : 'min';
189
+ hi.dataset.column = col.name;
190
+ hi.dataset.bound = 'max';
191
+ hi.placeholder = isDate ? 'to' : 'max';
192
+ const bt = condBetween(conds);
193
+ const minV = condVal(conds, 'gte') ?? condVal(conds, 'gt') ?? bt.min;
194
+ const maxV = condVal(conds, 'lte') ?? condVal(conds, 'lt') ?? bt.max;
195
+ if (minV != null) lo.value = isDate ? toDateInputValue(minV, type === 'DATE') : String(minV);
196
+ if (maxV != null) hi.value = isDate ? toDateInputValue(maxV, type === 'DATE') : String(maxV);
197
+ row.appendChild(lo);
198
+ row.appendChild(hi);
199
+ wrap.appendChild(row);
200
+ return wrap;
201
+ }
76
202
 
77
- const importBtn = document.getElementById('import-csv');
78
- const importPanel = document.getElementById('import-panel');
79
- const importClose = document.getElementById('import-csv-close');
80
- const importText = document.getElementById('import-csv-text');
81
- const importFile = document.getElementById('import-csv-file');
82
- const importSubmit = document.getElementById('import-csv-submit');
83
-
84
- if (importBtn && importPanel) {
85
- importBtn.addEventListener('click', () => {
86
- importPanel.classList.toggle('hidden');
87
- if (!importPanel.classList.contains('hidden')) importText.focus();
203
+ // Text / BLOB / anything else — legacy operator syntax.
204
+ const input = document.createElement('input');
205
+ input.type = 'text';
206
+ input.className = 'filter-input field-input !py-1.5 !text-[13px]';
207
+ input.dataset.column = col.name;
208
+ input.dataset.kind = 'text';
209
+ input.placeholder = type === 'BLOB' ? '0x… or text' : 'filter…';
210
+ input.autocomplete = 'off';
211
+ input.spellcheck = false;
212
+ let textVal = legacy;
213
+ if (textVal == null) {
214
+ const prefix = condVal(conds, 'prefix');
215
+ const eq = condVal(conds, 'eq');
216
+ const like = condVal(conds, 'like');
217
+ if (prefix != null) textVal = String(prefix) + '*';
218
+ else if (eq != null) textVal = '=' + String(eq);
219
+ else if (like != null) textVal = String(like);
220
+ }
221
+ if (textVal != null) input.value = textVal;
222
+ wrap.appendChild(input);
223
+ return wrap;
224
+ }
225
+
226
+ function collectFilters() {
227
+ const filters = {};
228
+ const byCol = {};
229
+ document.querySelectorAll('#filter-fields .filter-input').forEach((el) => {
230
+ const col = el.dataset.column;
231
+ if (!col) return;
232
+ (byCol[col] = byCol[col] || []).push(el);
88
233
  });
234
+ for (const col of Object.keys(byCol)) {
235
+ const els = byCol[col];
236
+ const first = els[0];
237
+ const kind = first.dataset.kind || '';
238
+ if (kind === 'fk') {
239
+ const v = first.value;
240
+ if (v === '') continue;
241
+ filters[col] = v === '__NULL__' ? { op: 'null' } : { op: 'eq', value: v };
242
+ } else if (kind === 'bool') {
243
+ const v = first.value;
244
+ if (v === '') continue;
245
+ filters[col] = { op: 'eq', value: v };
246
+ } else if (els.length === 2 && first.dataset.bound) {
247
+ const minEl = els.find((e) => e.dataset.bound === 'min');
248
+ const maxEl = els.find((e) => e.dataset.bound === 'max');
249
+ const minV = minEl && minEl.value !== '' ? minEl.value : null;
250
+ const maxV = maxEl && maxEl.value !== '' ? maxEl.value : null;
251
+ if (minV != null && maxV != null) filters[col] = { op: 'between', value: minV, max: maxV };
252
+ else if (minV != null) filters[col] = { op: 'gte', value: minV };
253
+ else if (maxV != null) filters[col] = { op: 'lte', value: maxV };
254
+ } else {
255
+ const v = (first.value || '').trim();
256
+ if (v) filters[col] = v; // legacy string syntax
257
+ }
258
+ }
259
+ return filters;
260
+ }
261
+
262
+ function navigate(filters) {
263
+ let table = '';
264
+ const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
265
+ if (m) table = decodeURIComponent(m[1]);
266
+ if (!table) return;
267
+ const sizeSel = document.getElementById('page-size');
268
+ const qs = new URLSearchParams({ page: '1', size: sizeSel ? sizeSel.value : '50' });
269
+ const keys = Object.keys(filters);
270
+ if (keys.length) qs.set('f', JSON.stringify(filters));
271
+ window.location.href = base + '/tables/' + encodeURIComponent(table) + '?' + qs.toString();
89
272
  }
90
- if (importClose) {
91
- importClose.addEventListener('click', () => importPanel.classList.add('hidden'));
92
- }
93
- if (importFile) {
94
- importFile.addEventListener('change', () => {
95
- const file = importFile.files && importFile.files[0];
96
- if (!file) return;
97
- const reader = new FileReader();
98
- reader.onload = () => {
99
- importText.value = String(reader.result || '');
100
- };
101
- reader.readAsText(file);
273
+
274
+ function initFilters() {
275
+ const filterForm = document.getElementById('filter-form');
276
+ const clearBtn = document.getElementById('clear-filter');
277
+ const fields = document.getElementById('filter-fields');
278
+ if (filterForm && fields && info) {
279
+ info.columns.forEach((col) => fields.appendChild(buildFilterField(col)));
280
+ filterForm.addEventListener('submit', (e) => {
281
+ e.preventDefault();
282
+ navigate(collectFilters());
283
+ });
284
+ }
285
+ if (clearBtn) clearBtn.addEventListener('click', () => navigate({}));
286
+ }
287
+
288
+ // ---- Inline (spreadsheet-style) cell editing ----------------------------
289
+
290
+ function renderCellValue(td, value) {
291
+ const isNull = value === null || value === undefined;
292
+ const display = isNull ? '' : String(value);
293
+ td.dataset.null = String(isNull);
294
+ td.dataset.value = display;
295
+ td.classList.remove('js-editing', 'opacity-60');
296
+ td.innerHTML = isNull
297
+ ? '<span class="chip chip-null js-cell-value">NULL</span>'
298
+ : '<span class="js-cell-value block max-w-xs truncate font-mono text-[13px]" title="' +
299
+ escapeHtml(display) + '">' + escapeHtml(display.length > 120 ? display.slice(0, 120) + '…' : display) + '</span>';
300
+ }
301
+
302
+ function currentCellValue(td) {
303
+ return td.dataset.null === 'true' ? null : (td.dataset.value ?? '');
304
+ }
305
+
306
+ function createEditor(td) {
307
+ const col = td.dataset.col;
308
+ const c = colInfo(col);
309
+ if (!c) return null;
310
+ const fk = fkFor(col);
311
+ const type = (c.type || '').toUpperCase();
312
+ const isBool = type === 'BOOLEAN';
313
+ const isDate = type === 'DATE' || type === 'DATETIME';
314
+ const isNum = type.startsWith('INTEGER') || type === 'REAL';
315
+ const isBlob = type === 'BLOB';
316
+ const value = currentCellValue(td);
317
+ const nullish = value === null || value === '';
318
+
319
+ let el;
320
+ if (isBool) {
321
+ el = document.createElement('input');
322
+ el.type = 'checkbox';
323
+ el.className = 'checkbox checkbox-primary checkbox-sm js-inline-ctrl';
324
+ el.checked = value === 1 || value === true || value === '1' || value === 'true';
325
+ } else if (fk) {
326
+ el = document.createElement('select');
327
+ el.className = 'field-select !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
328
+ addOption(el, '', '(not set / NULL)');
329
+ (fkOptions[col] || []).forEach((o) => {
330
+ const opt = addOption(el, String(o.value), o.label === '' ? String(o.value) : o.label);
331
+ if (!nullish && String(value) === String(o.value)) opt.selected = true;
332
+ });
333
+ } else if (isDate) {
334
+ el = document.createElement('input');
335
+ el.type = type === 'DATE' ? 'date' : 'datetime-local';
336
+ el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
337
+ if (!nullish) el.value = toDateInputValue(String(value), type === 'DATE');
338
+ } else if (isNum) {
339
+ el = document.createElement('input');
340
+ el.type = 'number';
341
+ el.step = type === 'REAL' ? 'any' : '1';
342
+ el.className = 'field-input !w-48 !py-1.5 !text-[13px] js-inline-ctrl';
343
+ if (!nullish) el.value = String(value);
344
+ } else {
345
+ el = document.createElement('input');
346
+ el.type = 'text';
347
+ el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
348
+ el.placeholder = isBlob ? '0x… hex or text' : '';
349
+ if (!nullish) el.value = String(value);
350
+ }
351
+ return el;
352
+ }
353
+
354
+ function restoreCell(td) {
355
+ renderCellValue(td, td._origNull ? null : td._origValue);
356
+ td.classList.remove('js-editing');
357
+ }
358
+
359
+ // ---- Staged (Neon-style) cell edits --------------------------------------
360
+ // Edits are buffered locally and marked in the grid; nothing is written to
361
+ // the database until "Apply" sends every pending cell change to the
362
+ // bulk-update endpoint in a single transaction. "Discard" reverts everything
363
+ // back to the values loaded from the server.
364
+
365
+ const pendingChanges = new Map(); // key: `${pk}::${col}` → staged change
366
+
367
+ function stageCell(tr, td, el) {
368
+ const pk = tr.dataset.pk;
369
+ const col = td.dataset.col;
370
+ if (!pk || !col) return;
371
+ const c = colInfo(col);
372
+ if (!c) return;
373
+
374
+ let rawValue;
375
+ let setNull = false;
376
+ if (el.type === 'checkbox') {
377
+ rawValue = el.checked ? '1' : '0';
378
+ } else {
379
+ const v = el.value === undefined ? '' : String(el.value);
380
+ if (v === '' || v === '__NULL__') { rawValue = ''; setNull = true; }
381
+ else rawValue = v;
382
+ }
383
+
384
+ // Remember the server-loaded value the first time this cell is edited.
385
+ if (td.dataset.origValue === undefined) {
386
+ td.dataset.origValue = td.dataset.value;
387
+ td.dataset.origNull = td.dataset.null === 'true' ? 'true' : 'false';
388
+ }
389
+
390
+ // Reflect the staged value in the cell immediately.
391
+ renderCellValue(td, setNull ? null : rawValue);
392
+
393
+ pendingChanges.set(pk + '::' + col, {
394
+ td,
395
+ pk,
396
+ col,
397
+ rawValue,
398
+ setNull,
399
+ isPk: isPk(col),
400
+ isFk: !!fkFor(col),
401
+ origValue: td.dataset.origValue,
402
+ origNull: td.dataset.origNull === 'true',
102
403
  });
404
+
405
+ td.classList.add('cell-dirty');
406
+ updateChangesBar();
407
+ }
408
+
409
+ function updateChangesBar() {
410
+ if (!changesBar || !changesCount) return;
411
+ const n = pendingChanges.size;
412
+ changesCount.textContent = String(n);
413
+ changesBar.classList.toggle('hidden', n === 0);
103
414
  }
104
- if (importSubmit && importText) {
105
- importSubmit.addEventListener('click', async () => {
106
- const csv = importText.value.trim();
107
- if (!csv) return UI.showError('Paste or choose a CSV file first.');
108
- const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
109
- const table = m ? decodeURIComponent(m[1]) : '';
110
- if (!table) return UI.showError('Could not determine the table.');
111
- importSubmit.disabled = true;
112
- try {
113
- const data = await Api.post('/api/tables/' + encodeURIComponent(table) + '/rows/import', { csv });
114
- UI.showToast(data.message || 'Import complete.', 'success');
415
+
416
+ function applyPending() {
417
+ if (!pendingChanges.size || readonly) return;
418
+ const byRow = new Map();
419
+ for (const ch of pendingChanges.values()) {
420
+ let entry = byRow.get(ch.pk);
421
+ if (!entry) {
422
+ entry = { id: ch.pk, values: {}, nulls: [] };
423
+ byRow.set(ch.pk, entry);
424
+ }
425
+ if (ch.setNull) entry.nulls.push(ch.col);
426
+ else entry.values[ch.col] = ch.rawValue;
427
+ }
428
+ const updates = [...byRow.values()];
429
+ if (applyBtn) applyBtn.disabled = true;
430
+ Api.post('/api/tables/' + t + '/rows/bulk-update', { updates })
431
+ .then((data) => {
432
+ UI.showToast(data.message || 'Changes applied.', 'success');
115
433
  window.location.reload();
116
- } catch (e) {
434
+ })
435
+ .catch((e) => {
117
436
  UI.showError(e.message);
118
- } finally {
119
- importSubmit.disabled = false;
120
- }
437
+ if (applyBtn) applyBtn.disabled = false;
438
+ });
439
+ }
440
+
441
+ function discardPending() {
442
+ for (const ch of pendingChanges.values()) {
443
+ renderCellValue(ch.td, ch.origNull ? null : ch.origValue);
444
+ ch.td.classList.remove('cell-dirty');
445
+ delete ch.td.dataset.origValue;
446
+ delete ch.td.dataset.origNull;
447
+ }
448
+ pendingChanges.clear();
449
+ updateChangesBar();
450
+ }
451
+
452
+ function buildChangesBar() {
453
+ const bar = document.createElement('div');
454
+ bar.id = 'changes-bar';
455
+ bar.className =
456
+ 'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-2xl border border-base-300 bg-base-100 px-4 py-2.5 shadow-lift hidden';
457
+ bar.innerHTML =
458
+ '<span class="text-sm font-medium text-base-content/80"><b id="changes-count" class="tabular-nums text-primary">0</b> pending change(s)</span>' +
459
+ '<span class="hidden text-sm text-base-content/40 sm:inline">edit cells, then apply</span>' +
460
+ '<div class="flex items-center gap-1.5">' +
461
+ '<button type="button" id="changes-apply" class="btn btn-primary btn-sm gap-1">' +
462
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>' +
463
+ 'Apply</button>' +
464
+ '<button type="button" id="changes-discard" class="btn btn-ghost btn-sm">Discard</button>' +
465
+ '</div>';
466
+ document.body.appendChild(bar);
467
+ return bar;
468
+ }
469
+
470
+ let changesBar = null;
471
+ let changesCount = null;
472
+ let applyBtn = null;
473
+
474
+ function beginEdit(td) {
475
+ if (readonly) return;
476
+ if (td.classList.contains('js-editing')) return;
477
+ const tr = td.closest('tr');
478
+ if (!tr || !tr.dataset.pk || !tr.dataset.table) return;
479
+ const c = colInfo(td.dataset.col);
480
+ if (!c) return;
481
+
482
+ // Stage any currently-open editor first — same as blur would.
483
+ const open = document.querySelector('.js-editing');
484
+ if (open && open._finish) open._finish(true);
485
+
486
+ td._origNull = td.dataset.null === 'true';
487
+ td._origValue = currentCellValue(td);
488
+
489
+ const el = createEditor(td);
490
+ if (!el) return;
491
+ td.classList.add('js-editing');
492
+ td.innerHTML = '';
493
+ td.appendChild(el);
494
+
495
+ let committed = false;
496
+ const finish = (commit) => {
497
+ if (committed) return;
498
+ committed = true;
499
+ if (commit) stageCell(tr, td, el);
500
+ else restoreCell(td);
501
+ };
502
+ td._finish = finish;
503
+
504
+ el.addEventListener('keydown', (e) => {
505
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
506
+ else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
507
+ });
508
+ if (el.tagName === 'SELECT') {
509
+ el.addEventListener('change', () => finish(true));
510
+ } else if (el.type === 'checkbox') {
511
+ el.addEventListener('change', () => finish(true));
512
+ } else {
513
+ el.addEventListener('blur', () => finish(true));
514
+ }
515
+ el.focus();
516
+ if (typeof el.select === 'function') el.select();
517
+ }
518
+
519
+ function initInlineEditing() {
520
+ if (readonly) return;
521
+ changesBar = buildChangesBar();
522
+ changesCount = document.getElementById('changes-count');
523
+ applyBtn = document.getElementById('changes-apply');
524
+ const discardBtn = document.getElementById('changes-discard');
525
+ if (applyBtn) applyBtn.addEventListener('click', applyPending);
526
+ if (discardBtn) discardBtn.addEventListener('click', discardPending);
527
+
528
+ document.querySelectorAll('.js-inline-cell').forEach((td) => {
529
+ td.addEventListener('dblclick', () => beginEdit(td));
530
+ });
531
+ }
532
+
533
+ // ---- Bulk row operations ------------------------------------------------
534
+
535
+ function selectedRows() {
536
+ return Array.prototype.slice
537
+ .call(document.querySelectorAll('.row-checkbox:checked'))
538
+ .map((cb) => cb.value)
539
+ .filter(Boolean);
540
+ }
541
+
542
+ function initBulk() {
543
+ const bulkBar = document.getElementById('bulk-bar');
544
+ if (!bulkBar) return;
545
+ const selectAll = document.getElementById('select-all-rows');
546
+ const countEl = document.getElementById('bulk-count');
547
+ const deleteBtn = document.getElementById('bulk-delete');
548
+ const exportCsv = document.getElementById('bulk-export-csv');
549
+ const exportJson = document.getElementById('bulk-export-json');
550
+ const clearBtn = document.getElementById('bulk-clear');
551
+ const checkboxes = Array.prototype.slice.call(document.querySelectorAll('.row-checkbox'));
552
+
553
+ function update() {
554
+ const ids = selectedRows();
555
+ const n = ids.length;
556
+ if (countEl) countEl.textContent = String(n);
557
+ bulkBar.classList.toggle('hidden', n === 0);
558
+ if (selectAll) selectAll.checked = n > 0 && n === checkboxes.length;
559
+ if (deleteBtn) deleteBtn.disabled = readonly || n === 0;
560
+ }
561
+
562
+ checkboxes.forEach((cb) => cb.addEventListener('change', update));
563
+ if (selectAll) {
564
+ selectAll.addEventListener('change', () => {
565
+ checkboxes.forEach((cb) => { cb.checked = selectAll.checked; });
566
+ update();
567
+ });
568
+ }
569
+ if (clearBtn) clearBtn.addEventListener('click', () => {
570
+ checkboxes.forEach((cb) => { cb.checked = false; });
571
+ update();
572
+ });
573
+ if (exportCsv) exportCsv.addEventListener('click', () => bulkExport('csv'));
574
+ if (exportJson) exportJson.addEventListener('click', () => bulkExport('json'));
575
+ if (deleteBtn) deleteBtn.addEventListener('click', bulkDelete);
576
+ }
577
+
578
+ async function bulkExport(format) {
579
+ const ids = selectedRows();
580
+ if (!ids.length) return;
581
+ try {
582
+ const data = await Api.post('/api/tables/' + t + '/rows/bulk-export', { ids, format });
583
+ const blob = new Blob([data.content], {
584
+ type: format === 'json' ? 'application/json;charset=utf-8' : 'text/csv;charset=utf-8',
585
+ });
586
+ const a = document.createElement('a');
587
+ a.href = URL.createObjectURL(blob);
588
+ a.download = data.filename;
589
+ document.body.appendChild(a);
590
+ a.click();
591
+ a.remove();
592
+ URL.revokeObjectURL(a.href);
593
+ UI.showToast('Exported ' + data.rowCount + ' row(s).', 'success');
594
+ } catch (e) {
595
+ UI.showError(e.message);
596
+ }
597
+ }
598
+
599
+ async function bulkDelete() {
600
+ const ids = selectedRows();
601
+ if (!ids.length || readonly) return;
602
+ let impact;
603
+ try {
604
+ impact = await Api.post('/api/tables/' + t + '/rows/bulk-impact', { ids });
605
+ } catch (e) {
606
+ UI.showError(e.message);
607
+ return;
608
+ }
609
+ const refs = (impact && impact.references) || [];
610
+ const details = refs.length
611
+ ? '<p class="mb-2 font-medium text-warning">⚠ Other tables reference these records:</p>' +
612
+ '<ul class="mb-3 list-disc space-y-1 pl-5">' +
613
+ refs.map((r) =>
614
+ '<li><code class="font-mono">' + escapeHtml(r.table) + '.' + escapeHtml(r.from) + '</code> — <b class="tabular-nums">' + r.count + '</b> row(s) point at them</li>'
615
+ ).join('') +
616
+ '</ul>' +
617
+ '<p class="text-xs text-base-content/55">Depending on the foreign-key action, those rows may be deleted or orphaned. Deleting the selected rows can fail if a constraint blocks it.</p>'
618
+ : '<p class="text-base-content/60">No other tables reference these rows — a clean delete.</p>';
619
+
620
+ const message =
621
+ '<p class="mb-3">Delete <b class="tabular-nums">' + ids.length + '</b> selected row(s) from <code class="font-mono">' + escapeHtml(cfg.table) + '</code>? This cannot be undone.</p>' +
622
+ details;
623
+
624
+ showConfirmModal({
625
+ title: 'Delete selected rows',
626
+ message: message,
627
+ confirmLabel: 'Delete ' + ids.length + ' row(s)',
628
+ danger: true,
629
+ onConfirm: async () => {
630
+ try {
631
+ const data = await Api.post('/api/tables/' + t + '/rows/bulk-delete', { ids, confirmImpact: true });
632
+ UI.showToast(data.message || 'Rows deleted.', 'success');
633
+ window.location.reload();
634
+ } catch (e) {
635
+ UI.showError(e.message);
636
+ }
637
+ },
121
638
  });
122
639
  }
640
+
641
+ function showConfirmModal(opts) {
642
+ const overlay = document.createElement('div');
643
+ overlay.className = 'fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4';
644
+ const card = document.createElement('div');
645
+ card.className = 'app-card w-full max-w-lg overflow-hidden';
646
+ card.innerHTML =
647
+ '<div class="border-b border-base-200 px-5 py-4"><h3 class="text-base font-semibold">' + escapeHtml(opts.title) + '</h3></div>' +
648
+ '<div class="max-h-[55vh] overflow-auto px-5 py-4 text-sm leading-relaxed">' + opts.message + '</div>' +
649
+ '<div class="flex flex-wrap justify-end gap-2 border-t border-base-200 px-5 py-3">' +
650
+ '<button type="button" class="btn btn-ghost btn-sm js-modal-cancel">Cancel</button>' +
651
+ '<button type="button" class="btn btn-sm ' + (opts.danger ? 'btn-error' : 'btn-primary') + ' js-modal-ok">' + escapeHtml(opts.confirmLabel || 'Confirm') + '</button>' +
652
+ '</div>';
653
+ overlay.appendChild(card);
654
+ document.body.appendChild(overlay);
655
+
656
+ const onKey = (e) => { if (e.key === 'Escape') close(false); };
657
+ const close = (result) => {
658
+ document.removeEventListener('keydown', onKey);
659
+ overlay.remove();
660
+ if (result && opts.onConfirm) opts.onConfirm();
661
+ };
662
+ document.addEventListener('keydown', onKey);
663
+ overlay.querySelector('.js-modal-cancel').addEventListener('click', () => close(false));
664
+ overlay.querySelector('.js-modal-ok').addEventListener('click', () => close(true));
665
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
666
+ const okBtn = overlay.querySelector('.js-modal-ok');
667
+ if (okBtn) okBtn.focus();
668
+ }
669
+
670
+ // ---- CSV import ---------------------------------------------------------
671
+
672
+ function initImport() {
673
+ const importBtn = document.getElementById('import-csv');
674
+ const importPanel = document.getElementById('import-panel');
675
+ const importClose = document.getElementById('import-csv-close');
676
+ const importText = document.getElementById('import-csv-text');
677
+ const importFile = document.getElementById('import-csv-file');
678
+ const importSubmit = document.getElementById('import-csv-submit');
679
+
680
+ if (importBtn && importPanel) {
681
+ importBtn.addEventListener('click', () => {
682
+ importPanel.classList.toggle('hidden');
683
+ if (!importPanel.classList.contains('hidden')) importText.focus();
684
+ });
685
+ }
686
+ if (importClose) {
687
+ importClose.addEventListener('click', () => importPanel.classList.add('hidden'));
688
+ }
689
+ if (importFile) {
690
+ importFile.addEventListener('change', () => {
691
+ const file = importFile.files && importFile.files[0];
692
+ if (!file) return;
693
+ const reader = new FileReader();
694
+ reader.onload = () => { importText.value = String(reader.result || ''); };
695
+ reader.readAsText(file);
696
+ });
697
+ }
698
+ if (importSubmit && importText) {
699
+ importSubmit.addEventListener('click', async () => {
700
+ const csv = importText.value.trim();
701
+ if (!csv) return UI.showError('Paste or choose a CSV file first.');
702
+ const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
703
+ const table = m ? decodeURIComponent(m[1]) : '';
704
+ if (!table) return UI.showError('Could not determine the table.');
705
+ importSubmit.disabled = true;
706
+ try {
707
+ const data = await Api.post('/api/tables/' + encodeURIComponent(table) + '/rows/import', { csv });
708
+ UI.showToast(data.message || 'Import complete.', 'success');
709
+ window.location.reload();
710
+ } catch (e) {
711
+ UI.showError(e.message);
712
+ } finally {
713
+ importSubmit.disabled = false;
714
+ }
715
+ });
716
+ }
717
+ }
718
+
719
+ // ---- Related rows (Neon-style per-row expander) -------------------------
720
+
721
+ function rotateChevron(btn, open) {
722
+ const chev = btn.querySelector('.js-ref-chevron');
723
+ if (chev) chev.classList.toggle('rotate-180', open);
724
+ }
725
+
726
+ let refOpenBtn = null;
727
+
728
+ function closeRefPop() {
729
+ const pop = document.querySelector('.js-ref-pop');
730
+ if (pop) {
731
+ if (pop._cleanup) pop._cleanup();
732
+ pop.remove();
733
+ }
734
+ if (refOpenBtn) {
735
+ rotateChevron(refOpenBtn, false);
736
+ refOpenBtn.setAttribute('aria-expanded', 'false');
737
+ refOpenBtn.classList.remove('opacity-50');
738
+ refOpenBtn = null;
739
+ }
740
+ }
741
+
742
+ function renderMiniTable(ref, basePath) {
743
+ const href = basePath + '/tables/' + encodeURIComponent(ref.table);
744
+ let html =
745
+ '<div class="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">' +
746
+ '<span class="min-w-0 truncate">' +
747
+ '<a class="link link-primary font-mono text-xs font-semibold" href="' + href + '">' + escapeHtml(ref.table) + '</a>' +
748
+ '<span class="ml-1.5 text-xs text-base-content/45">via <code class="font-mono">' + escapeHtml(ref.from) + '</code> = <code class="font-mono">' + escapeHtml(String(ref.value)) + '</code></span>' +
749
+ '</span>' +
750
+ '<span class="chip ' + (ref.count ? 'chip-pk' : 'chip-type') + '">' + escapeHtml(ref.count + ' row' + (ref.count === 1 ? '' : 's')) + '</span>' +
751
+ '</div>';
752
+
753
+ if (!ref.rows.length) {
754
+ return html + '<div class="px-3 py-3 text-xs text-base-content/45">No matching rows.</div>';
755
+ }
756
+
757
+ const thead = '<tr>' + ref.columns.map(function (c) {
758
+ return '<th class="whitespace-nowrap px-2.5 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-base-content/50"><span class="font-mono normal-case">' + escapeHtml(c) + '</span></th>';
759
+ }).join('') + '</tr>';
760
+ const tbody = ref.rows.map(function (r) {
761
+ return '<tr class="border-t border-base-200/60 hover:bg-primary/[0.04]">' + ref.columns.map(function (c) {
762
+ const v = r[c];
763
+ if (v === null || v === undefined) return '<td class="px-2.5 py-1.5 align-middle"><span class="text-[11px] text-base-content/40">NULL</span></td>';
764
+ const s = String(v);
765
+ const short = s.length > 120 ? s.slice(0, 120) + '…' : s;
766
+ return '<td class="max-w-[180px] truncate px-2.5 py-1.5 align-middle font-mono text-[11px] text-base-content/80" title="' + escapeHtml(s) + '">' + escapeHtml(short) + '</td>';
767
+ }).join('') + '</tr>';
768
+ }).join('');
769
+
770
+ return html +
771
+ '<div class="max-h-64 overflow-auto">' +
772
+ '<table class="w-full"><thead><tr class="bg-base-200/40">' + thead + '</tr></thead>' +
773
+ '<tbody>' + tbody + '</tbody></table>' +
774
+ '</div>' +
775
+ '<div class="flex justify-end border-t border-base-200 px-3 py-1.5">' +
776
+ '<a class="btn btn-ghost btn-xs text-base-content/60 hover:text-primary" href="' + href + '">Open table</a>' +
777
+ '</div>';
778
+ }
779
+
780
+ function showRefPop(btn, refs) {
781
+ closeRefPop();
782
+ const pop = document.createElement('div');
783
+ pop.className = 'js-ref-pop app-card js-ref-anim z-50 min-w-[280px] max-w-[460px] overflow-hidden shadow-lift';
784
+ pop.innerHTML = refs.length
785
+ ? refs.map((ref) => renderMiniTable(ref, base)).join('<div class="h-px bg-base-200"></div>')
786
+ : '<div class="px-4 py-3 text-sm text-base-content/45">No related rows for this record.</div>';
787
+
788
+ pop.style.visibility = 'hidden';
789
+ document.body.appendChild(pop);
790
+ const pad = 8;
791
+ const popW = Math.min(460, Math.max(280, pop.offsetWidth));
792
+ const popH = pop.offsetHeight;
793
+
794
+ const rect = btn.getBoundingClientRect();
795
+ let left = rect.left;
796
+ if (left + popW > window.innerWidth - pad) left = Math.max(pad, window.innerWidth - popW - pad);
797
+ let top = rect.bottom + 6;
798
+ if (top + popH > window.innerHeight - pad) top = Math.max(pad, rect.top - popH - 6);
799
+
800
+ pop.style.position = 'fixed';
801
+ pop.style.left = left + 'px';
802
+ pop.style.top = top + 'px';
803
+ pop.style.visibility = 'visible';
804
+ refOpenBtn = btn;
805
+
806
+ const onViewChange = () => closeRefPop();
807
+ window.addEventListener('scroll', onViewChange, true);
808
+ window.addEventListener('resize', onViewChange);
809
+ const onClickOutside = (e) => {
810
+ if (!pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) closeRefPop();
811
+ };
812
+ document.addEventListener('click', onClickOutside, true);
813
+ const onKey = (e) => { if (e.key === 'Escape') closeRefPop(); };
814
+ document.addEventListener('keydown', onKey);
815
+
816
+ pop._cleanup = () => {
817
+ window.removeEventListener('scroll', onViewChange, true);
818
+ window.removeEventListener('resize', onViewChange);
819
+ document.removeEventListener('click', onClickOutside, true);
820
+ document.removeEventListener('keydown', onKey);
821
+ };
822
+ }
823
+
824
+ function initRefToggles() {
825
+ document.querySelectorAll('.js-ref-toggle').forEach((btn) => {
826
+ btn.addEventListener('click', (e) => {
827
+ e.stopPropagation();
828
+ const open = btn.getAttribute('aria-expanded') === 'true';
829
+ if (open) {
830
+ closeRefPop();
831
+ return;
832
+ }
833
+ const table = btn.dataset.table;
834
+ const pk = btn.dataset.pk;
835
+ if (!table || !pk) return;
836
+ btn.classList.add('opacity-50');
837
+ Api.get('/api/tables/' + encodeURIComponent(table) + '/rows/' + encodeURIComponent(pk) + '/references')
838
+ .then((data) => {
839
+ btn.classList.remove('opacity-50');
840
+ const refTable = btn.dataset.refTable;
841
+ const refs = ((data && data.references) || []).filter((r) => !refTable || r.table === refTable);
842
+ btn.setAttribute('aria-expanded', 'true');
843
+ rotateChevron(btn, true);
844
+ showRefPop(btn, refs);
845
+ })
846
+ .catch((e) => {
847
+ btn.classList.remove('opacity-50');
848
+ UI.showError(e.message);
849
+ });
850
+ });
851
+ });
852
+ }
853
+
854
+ // ---- Init ---------------------------------------------------------------
855
+
856
+ async function init() {
857
+ try {
858
+ info = await Api.get('/api/tables/' + t + '/info');
859
+ } catch (e) {
860
+ info = null;
861
+ }
862
+ try {
863
+ fkOptions = await Api.get('/api/tables/' + t + '/fk-options');
864
+ } catch (e) {
865
+ fkOptions = {};
866
+ }
867
+ initFilters();
868
+ initInlineEditing();
869
+ initBulk();
870
+ initRowDelete();
871
+ initPageSize();
872
+ initImport();
873
+ initRefToggles();
874
+ }
875
+
876
+ init();
123
877
  })();