admindb 1.2.1 → 2.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 (208) hide show
  1. package/README.md +262 -329
  2. package/dist/app.d.ts +25 -4
  3. package/dist/app.js +79 -15
  4. package/dist/app.js.map +1 -1
  5. package/dist/auth/config.d.ts +19 -0
  6. package/dist/auth/config.js +147 -0
  7. package/dist/auth/config.js.map +1 -0
  8. package/dist/auth/crypto.d.ts +27 -0
  9. package/dist/auth/crypto.js +133 -0
  10. package/dist/auth/crypto.js.map +1 -0
  11. package/dist/auth/index.d.ts +5 -0
  12. package/dist/auth/index.js +22 -0
  13. package/dist/auth/index.js.map +1 -0
  14. package/dist/auth/middleware.d.ts +14 -0
  15. package/dist/auth/middleware.js +100 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/routes.d.ts +7 -0
  18. package/dist/auth/routes.js +78 -0
  19. package/dist/auth/routes.js.map +1 -0
  20. package/dist/auth/types.d.ts +35 -0
  21. package/dist/auth/types.js +11 -0
  22. package/dist/auth/types.js.map +1 -0
  23. package/dist/cli/args.d.ts +45 -0
  24. package/dist/cli/args.js +328 -0
  25. package/dist/cli/args.js.map +1 -0
  26. package/dist/cli/config.d.ts +56 -0
  27. package/dist/cli/config.js +133 -0
  28. package/dist/cli/config.js.map +1 -0
  29. package/dist/cli/index.d.ts +4 -0
  30. package/dist/cli/index.js +21 -0
  31. package/dist/cli/index.js.map +1 -0
  32. package/dist/cli/runner.d.ts +1 -0
  33. package/dist/cli/runner.js +222 -0
  34. package/dist/cli/runner.js.map +1 -0
  35. package/dist/cli.js +2 -127
  36. package/dist/cli.js.map +1 -1
  37. package/dist/data/datasets.d.ts +17 -0
  38. package/dist/data/datasets.js +133 -0
  39. package/dist/data/datasets.js.map +1 -0
  40. package/dist/data/detector.d.ts +30 -0
  41. package/dist/data/detector.js +472 -0
  42. package/dist/data/detector.js.map +1 -0
  43. package/dist/data/engine.d.ts +10 -0
  44. package/dist/data/engine.js +211 -0
  45. package/dist/data/engine.js.map +1 -0
  46. package/dist/data/index.d.ts +5 -0
  47. package/dist/data/index.js +22 -0
  48. package/dist/data/index.js.map +1 -0
  49. package/dist/data/strategies.d.ts +30 -0
  50. package/dist/data/strategies.js +343 -0
  51. package/dist/data/strategies.js.map +1 -0
  52. package/dist/data/types.d.ts +73 -0
  53. package/dist/data/types.js +8 -0
  54. package/dist/data/types.js.map +1 -0
  55. package/dist/db/database.d.ts +14 -146
  56. package/dist/db/database.js +208 -461
  57. package/dist/db/database.js.map +1 -1
  58. package/dist/db/export.d.ts +2 -2
  59. package/dist/db/export.js +7 -2
  60. package/dist/db/export.js.map +1 -1
  61. package/dist/db/filters.d.ts +22 -0
  62. package/dist/db/filters.js +158 -0
  63. package/dist/db/filters.js.map +1 -0
  64. package/dist/db/index.d.ts +8 -0
  65. package/dist/db/index.js +25 -0
  66. package/dist/db/index.js.map +1 -0
  67. package/dist/db/introspection.d.ts +6 -0
  68. package/dist/db/introspection.js +172 -0
  69. package/dist/db/introspection.js.map +1 -0
  70. package/dist/db/manager.d.ts +32 -19
  71. package/dist/db/manager.js +203 -44
  72. package/dist/db/manager.js.map +1 -1
  73. package/dist/db/migrations.d.ts +5 -0
  74. package/dist/db/migrations.js +110 -0
  75. package/dist/db/migrations.js.map +1 -0
  76. package/dist/db/postgres.d.ts +85 -0
  77. package/dist/db/postgres.js +745 -0
  78. package/dist/db/postgres.js.map +1 -0
  79. package/dist/db/types.d.ts +206 -0
  80. package/dist/db/types.js +7 -0
  81. package/dist/db/types.js.map +1 -0
  82. package/dist/index.d.ts +9 -2
  83. package/dist/index.js +46 -4
  84. package/dist/index.js.map +1 -1
  85. package/dist/public/css/app.css +1 -1
  86. package/dist/public/css/input.css +74 -11
  87. package/dist/public/js/app.js +142 -0
  88. package/dist/public/js/browse.js +591 -104
  89. package/dist/public/js/databases.js +105 -13
  90. package/dist/public/js/designer.js +55 -2
  91. package/dist/public/js/forms.js +610 -28
  92. package/dist/public/js/icons.js +51 -0
  93. package/dist/public/js/inspector.js +638 -0
  94. package/dist/public/js/query.js +93 -3
  95. package/dist/public/js/schema.js +185 -52
  96. package/dist/public/js/seed.js +865 -152
  97. package/dist/routes/api/helpers.d.ts +42 -0
  98. package/dist/routes/api/helpers.js +133 -0
  99. package/dist/routes/api/helpers.js.map +1 -0
  100. package/dist/routes/api/import-export.d.ts +3 -0
  101. package/dist/routes/api/import-export.js +85 -0
  102. package/dist/routes/api/import-export.js.map +1 -0
  103. package/dist/routes/api/index.d.ts +4 -0
  104. package/dist/routes/api/index.js +37 -0
  105. package/dist/routes/api/index.js.map +1 -0
  106. package/dist/routes/api/query.d.ts +3 -0
  107. package/dist/routes/api/query.js +76 -0
  108. package/dist/routes/api/query.js.map +1 -0
  109. package/dist/routes/api/rows.d.ts +3 -0
  110. package/dist/routes/api/rows.js +373 -0
  111. package/dist/routes/api/rows.js.map +1 -0
  112. package/dist/routes/api/seed.d.ts +3 -0
  113. package/dist/routes/api/seed.js +82 -0
  114. package/dist/routes/api/seed.js.map +1 -0
  115. package/dist/routes/api/tables.d.ts +3 -0
  116. package/dist/routes/api/tables.js +160 -0
  117. package/dist/routes/api/tables.js.map +1 -0
  118. package/dist/routes/databases.d.ts +8 -11
  119. package/dist/routes/databases.js +100 -66
  120. package/dist/routes/databases.js.map +1 -1
  121. package/dist/routes/index.d.ts +3 -0
  122. package/dist/routes/index.js +20 -0
  123. package/dist/routes/index.js.map +1 -0
  124. package/dist/routes/pages.d.ts +3 -3
  125. package/dist/routes/pages.js +157 -64
  126. package/dist/routes/pages.js.map +1 -1
  127. package/dist/serverless.d.ts +40 -0
  128. package/dist/serverless.js +210 -0
  129. package/dist/serverless.js.map +1 -0
  130. package/dist/sql/generator.d.ts +7 -3
  131. package/dist/sql/generator.js +144 -7
  132. package/dist/sql/generator.js.map +1 -1
  133. package/dist/sql/index.d.ts +2 -0
  134. package/dist/sql/index.js +19 -0
  135. package/dist/sql/index.js.map +1 -0
  136. package/dist/types/api.d.ts +412 -0
  137. package/dist/types/api.js +9 -0
  138. package/dist/types/api.js.map +1 -0
  139. package/dist/utils/colors.d.ts +40 -0
  140. package/dist/utils/colors.js +64 -0
  141. package/dist/utils/colors.js.map +1 -0
  142. package/dist/{util.d.ts → utils/common.d.ts} +10 -6
  143. package/dist/{util.js → utils/common.js} +80 -13
  144. package/dist/utils/common.js.map +1 -0
  145. package/dist/utils/csv.js.map +1 -0
  146. package/dist/utils/datatype.d.ts +49 -0
  147. package/dist/utils/datatype.js +222 -0
  148. package/dist/utils/datatype.js.map +1 -0
  149. package/dist/utils/icons.d.ts +11 -0
  150. package/dist/utils/icons.js +72 -0
  151. package/dist/utils/icons.js.map +1 -0
  152. package/dist/utils/index.d.ts +4 -0
  153. package/dist/utils/index.js +21 -0
  154. package/dist/utils/index.js.map +1 -0
  155. package/dist/{logger.d.ts → utils/logger.d.ts} +1 -1
  156. package/dist/utils/logger.js +40 -0
  157. package/dist/utils/logger.js.map +1 -0
  158. package/dist/views/layouts/main.hbs +40 -11
  159. package/dist/views/pages/databases.hbs +182 -74
  160. package/dist/views/pages/designer.hbs +29 -11
  161. package/dist/views/pages/error.hbs +3 -3
  162. package/dist/views/pages/form.hbs +5 -3
  163. package/dist/views/pages/home.hbs +22 -13
  164. package/dist/views/pages/login.hbs +111 -0
  165. package/dist/views/pages/query.hbs +8 -6
  166. package/dist/views/pages/schema.hbs +41 -13
  167. package/dist/views/pages/seed.hbs +200 -54
  168. package/dist/views/pages/table.hbs +152 -72
  169. package/dist/views/partials/icon.hbs +2 -0
  170. package/dist/views/partials/navbar.hbs +69 -25
  171. package/dist/views/partials/sidebar.hbs +108 -94
  172. package/docs/API.md +144 -0
  173. package/docs/EXAMPLES.md +576 -0
  174. package/docs/SECURITY.md +265 -0
  175. package/package.json +15 -4
  176. package/dist/args.d.ts +0 -28
  177. package/dist/args.js +0 -187
  178. package/dist/args.js.map +0 -1
  179. package/dist/config.d.ts +0 -17
  180. package/dist/config.js +0 -23
  181. package/dist/config.js.map +0 -1
  182. package/dist/csv.js.map +0 -1
  183. package/dist/data/generator.d.ts +0 -86
  184. package/dist/data/generator.js +0 -1031
  185. package/dist/data/generator.js.map +0 -1
  186. package/dist/logger.js +0 -25
  187. package/dist/logger.js.map +0 -1
  188. package/dist/routes/api.d.ts +0 -9
  189. package/dist/routes/api.js +0 -848
  190. package/dist/routes/api.js.map +0 -1
  191. package/dist/test/classifier.test.d.ts +0 -1
  192. package/dist/test/classifier.test.js +0 -44
  193. package/dist/test/classifier.test.js.map +0 -1
  194. package/dist/test/csv.test.d.ts +0 -1
  195. package/dist/test/csv.test.js +0 -64
  196. package/dist/test/csv.test.js.map +0 -1
  197. package/dist/test/database.test.d.ts +0 -1
  198. package/dist/test/database.test.js +0 -402
  199. package/dist/test/database.test.js.map +0 -1
  200. package/dist/test/generator.test.d.ts +0 -1
  201. package/dist/test/generator.test.js +0 -132
  202. package/dist/test/generator.test.js.map +0 -1
  203. package/dist/test/manager.test.d.ts +0 -1
  204. package/dist/test/manager.test.js +0 -146
  205. package/dist/test/manager.test.js.map +0 -1
  206. package/dist/util.js.map +0 -1
  207. /package/dist/{csv.d.ts → utils/csv.d.ts} +0 -0
  208. /package/dist/{csv.js → utils/csv.js} +0 -0
@@ -46,11 +46,19 @@
46
46
 
47
47
  function initRowDelete() {
48
48
  document.querySelectorAll('.js-delete-row').forEach((btn) => {
49
- btn.addEventListener('click', () => {
49
+ btn.addEventListener('click', async () => {
50
50
  const table = btn.dataset.table;
51
51
  const pk = btn.dataset.pk;
52
52
  if (!table || !pk) return;
53
- if (!window.confirm('Delete this row? This cannot be undone.')) return;
53
+ const confirmed = await UI.confirm({
54
+ title: 'Delete Row',
55
+ message: 'Are you sure you want to permanently delete this row? This action cannot be undone.',
56
+ item: pk,
57
+ itemType: 'Primary Key',
58
+ confirmText: 'Delete row',
59
+ danger: true,
60
+ });
61
+ if (!confirmed) return;
54
62
  Api.del('/api/tables/' + encodeURIComponent(table) + '/row/' + encodeURIComponent(pk))
55
63
  .then(() => {
56
64
  UI.showToast('Row deleted.', 'success');
@@ -102,11 +110,20 @@
102
110
  return c ? { min: c.value ?? null, max: c.max ?? null } : { min: null, max: null };
103
111
  }
104
112
 
105
- function toDateInputValue(v, dateOnly) {
106
- const m = String(v).match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2}))?/);
113
+ function toDateInputValue(v, dateOnly, timeOnly) {
114
+ if (v == null || v === '') return '';
115
+ const s = String(v);
116
+ if (dateOnly) {
117
+ const dm = s.match(/^(\d{4}-\d{2}-\d{2})/);
118
+ return dm ? dm[1] : '';
119
+ }
120
+ if (timeOnly) {
121
+ const tm = s.match(/(\d{2}:\d{2}(?::\d{2})?)/);
122
+ return tm ? tm[1] : '';
123
+ }
124
+ const m = s.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/);
107
125
  if (!m) return '';
108
- if (dateOnly) return m[1];
109
- return m[1] + 'T' + (m[2] ? m[2] + ':' + m[3] : '00:00');
126
+ return m[1] + 'T' + (m[2] ? m[2] + ':' + m[3] + (m[4] ? ':' + m[4] : '') : '00:00:00');
110
127
  }
111
128
 
112
129
  function addOption(sel, value, label) {
@@ -127,9 +144,12 @@
127
144
 
128
145
  const fk = fkFor(col.name);
129
146
  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';
147
+ const isBool = type === 'BOOLEAN' || type === 'BOOL';
148
+ const isNum = type.startsWith('INT') || type === 'REAL' || type === 'NUMERIC' || type.startsWith('DECIMAL') || type === 'FLOAT' || type === 'DOUBLE' || type.startsWith('BIGINT') || type.startsWith('SMALLINT') || type.startsWith('SERIAL') || type === 'MONEY';
149
+ const isDateOnly = type === 'DATE';
150
+ const isTimeOnly = type === 'TIME' || type === 'TIMETZ' || type.startsWith('TIME ') || type.startsWith('TIME(');
151
+ const isTimestamp = type === 'DATETIME' || type.includes('TIMESTAMP');
152
+ const isDate = isDateOnly || isTimeOnly || isTimestamp;
133
153
  const existing = cfg.filters[col.name];
134
154
  const conds = filterCondArray(existing);
135
155
  const legacy = legacyString(existing);
@@ -176,10 +196,12 @@
176
196
  const lo = document.createElement('input');
177
197
  const hi = document.createElement('input');
178
198
  if (isDate) {
179
- lo.type = hi.type = type === 'DATE' ? 'date' : 'datetime-local';
199
+ lo.type = hi.type = isDateOnly ? 'date' : (isTimeOnly ? 'time' : 'datetime-local');
200
+ if (isTimeOnly || isTimestamp) lo.step = hi.step = '1';
180
201
  } else {
181
202
  lo.type = hi.type = 'number';
182
- lo.step = hi.step = type === 'REAL' ? 'any' : '1';
203
+ const isInteger = type.startsWith('INTEGER') || type === 'INT' || type.startsWith('BIGINT') || type.startsWith('SMALLINT') || type.startsWith('SERIAL');
204
+ lo.step = hi.step = isInteger ? '1' : 'any';
183
205
  }
184
206
  lo.className = 'filter-input field-input !py-1.5 !text-[13px]';
185
207
  hi.className = 'filter-input field-input !py-1.5 !text-[13px]';
@@ -192,14 +214,15 @@
192
214
  const bt = condBetween(conds);
193
215
  const minV = condVal(conds, 'gte') ?? condVal(conds, 'gt') ?? bt.min;
194
216
  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);
217
+ if (minV != null) lo.value = isDate ? toDateInputValue(minV, isDateOnly, isTimeOnly) : String(minV);
218
+ if (maxV != null) hi.value = isDate ? toDateInputValue(maxV, isDateOnly, isTimeOnly) : String(maxV);
197
219
  row.appendChild(lo);
198
220
  row.appendChild(hi);
199
221
  wrap.appendChild(row);
200
222
  return wrap;
201
223
  }
202
224
 
225
+
203
226
  // Text / BLOB / anything else — legacy operator syntax.
204
227
  const input = document.createElement('input');
205
228
  input.type = 'text';
@@ -285,7 +308,40 @@
285
308
  if (clearBtn) clearBtn.addEventListener('click', () => navigate({}));
286
309
  }
287
310
 
288
- // ---- Inline (spreadsheet-style) cell editing ----------------------------
311
+ // ---- Inline (spreadsheet-style) cell editing & Keyboard Navigation --------
312
+
313
+ let focusedCell = null;
314
+
315
+ function clearFocusedCell() {
316
+ if (focusedCell) {
317
+ focusedCell.classList.remove('cell-focused');
318
+ focusedCell = null;
319
+ }
320
+ }
321
+
322
+ function setFocusedCell(td) {
323
+ if (!td || td === focusedCell || td.classList.contains('js-editing')) return;
324
+ clearFocusedCell();
325
+ focusedCell = td;
326
+ td.classList.add('cell-focused');
327
+ td.scrollIntoView({ block: 'nearest', inline: 'nearest' });
328
+ }
329
+
330
+ function revertCell(td) {
331
+ const tr = td.closest('tr');
332
+ const pk = tr && tr.dataset.pk;
333
+ const col = td.dataset.col;
334
+ if (!pk || !col) return;
335
+ const origVal = td.dataset.origValue;
336
+ const origNull = td.dataset.origNull === 'true';
337
+ renderCellValue(td, origNull ? null : origVal);
338
+ td.classList.remove('cell-dirty');
339
+ delete td.dataset.origValue;
340
+ delete td.dataset.origNull;
341
+ pendingChanges.delete(pk + '::' + col);
342
+ updateChangesBar();
343
+ UI.showToast('Reverted edit in column "' + col + '".', 'info');
344
+ }
289
345
 
290
346
  function renderCellValue(td, value) {
291
347
  const isNull = value === null || value === undefined;
@@ -293,10 +349,66 @@
293
349
  td.dataset.null = String(isNull);
294
350
  td.dataset.value = display;
295
351
  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>';
352
+
353
+ const isDirty = td.dataset.origValue !== undefined;
354
+ const origVal = td.dataset.origValue;
355
+ const origNull = td.dataset.origNull === 'true';
356
+ const dirtyTitle = isDirty
357
+ ? 'Staged edit: ' + (isNull ? 'NULL' : display) + ' (Saved: ' + (origNull ? 'NULL' : origVal) + ')'
358
+ : display;
359
+
360
+ const revertBtnHtml = isDirty
361
+ ? '<button type="button" class="cell-revert-btn js-revert-cell" title="Revert this field to saved value">↺</button>'
362
+ : '';
363
+
364
+ if (isNull) {
365
+ td.innerHTML = '<div class="flex items-center justify-between gap-1"><span class="chip chip-null js-cell-value">NULL</span>' + revertBtnHtml + '</div>';
366
+ return;
367
+ }
368
+
369
+ const col = td.dataset.col;
370
+ const c = colInfo(col);
371
+ const colType = (c?.type || td.dataset.type || '').toUpperCase();
372
+ const isBlob = td.dataset.blob === 'true' || colType.includes('BLOB') || /^0x[0-9a-f]{8,}$/i.test(display);
373
+ const isJson = td.dataset.json === 'true' || ((display.startsWith('{') && display.endsWith('}')) || (display.startsWith('[') && display.endsWith(']')));
374
+
375
+ if (isBlob) {
376
+ const bytesLen = display.startsWith('0x') ? Math.floor((display.length - 2) / 2) : display.length;
377
+ td.innerHTML =
378
+ '<div class="flex items-center justify-between gap-1 js-cell-value">' +
379
+ '<div class="flex items-center gap-1.5 min-w-0">' +
380
+ '<span class="inline-flex items-center gap-1 rounded-md bg-secondary/10 px-1.5 py-0.5 text-[11px] font-mono font-medium text-secondary">🗃️ BLOB · ' + bytesLen + ' B</span>' +
381
+ '<button type="button" class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity js-inspect-btn" title="Inspect BLOB">' +
382
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>' +
383
+ '</button>' +
384
+ '</div>' +
385
+ revertBtnHtml +
386
+ '</div>';
387
+ } else if (isJson) {
388
+ td.innerHTML =
389
+ '<div class="flex items-center justify-between gap-1 js-cell-value">' +
390
+ '<div class="flex items-center gap-1.5 min-w-0">' +
391
+ '<span class="inline-flex items-center gap-1 rounded-md bg-info/10 px-1.5 py-0.5 text-[11px] font-mono font-medium text-info">{ } JSON</span>' +
392
+ '<span class="block max-w-[140px] truncate font-mono text-[12px] text-base-content/70" title="' + escapeHtml(dirtyTitle) + '">' + escapeHtml(display) + '</span>' +
393
+ '<button type="button" class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity js-inspect-btn" title="Inspect JSON">' +
394
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>' +
395
+ '</button>' +
396
+ '</div>' +
397
+ revertBtnHtml +
398
+ '</div>';
399
+ } else {
400
+ td.innerHTML =
401
+ '<div class="flex items-center justify-between gap-1 group/cell">' +
402
+ '<span class="js-cell-value block max-w-xs truncate font-mono text-[13px]" title="' + escapeHtml(dirtyTitle) + '">' + escapeHtml(display.length > 120 ? display.slice(0, 120) + '…' : display) + '</span>' +
403
+ '<div class="flex items-center gap-0.5 shrink-0">' +
404
+ revertBtnHtml +
405
+ (display.length > 30 ?
406
+ '<button type="button" class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover/cell:opacity-100 transition-opacity js-inspect-btn shrink-0" title="Inspect text">' +
407
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>' +
408
+ '</button>' : '') +
409
+ '</div>' +
410
+ '</div>';
411
+ }
300
412
  }
301
413
 
302
414
  function currentCellValue(td) {
@@ -309,10 +421,19 @@
309
421
  if (!c) return null;
310
422
  const fk = fkFor(col);
311
423
  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';
424
+ const isBool = type === 'BOOLEAN' || type === 'BOOL';
425
+ const isDateOnly = type === 'DATE';
426
+ const isTimeOnly = type === 'TIME' || type === 'TIMETZ' || type.startsWith('TIME ') || type.startsWith('TIME(');
427
+ const isTimestamp = type === 'DATETIME' || type.includes('TIMESTAMP');
428
+ const isDate = isDateOnly || isTimeOnly || isTimestamp;
429
+ const isNum = type.startsWith('INT') || type === 'REAL' || type === 'NUMERIC' || type.startsWith('DECIMAL') || type === 'FLOAT' || type === 'DOUBLE' || type.startsWith('BIGINT') || type.startsWith('SMALLINT') || type.startsWith('SERIAL') || type === 'MONEY';
430
+ const isBlob = type === 'BLOB' || type === 'BYTEA' || type === 'BINARY';
431
+ const isUuid = type === 'UUID';
432
+ const isJson = type.includes('JSON');
433
+ const isArray = type.endsWith('[]') || type.startsWith('_') || type.includes('ARRAY');
434
+ const isInterval = type === 'INTERVAL';
435
+ const isGeometry = type === 'POINT' || type === 'GEOMETRY' || type === 'BOX' || type === 'CIRCLE' || type === 'POLYGON';
436
+ const isBit = type === 'BIT' || type === 'VARBIT' || type.startsWith('BIT(') || type.startsWith('VARBIT(');
316
437
  const value = currentCellValue(td);
317
438
  const nullish = value === null || value === '';
318
439
 
@@ -332,35 +453,70 @@
332
453
  });
333
454
  } else if (isDate) {
334
455
  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');
456
+ el.type = isDateOnly ? 'date' : (isTimeOnly ? 'time' : 'datetime-local');
457
+ if (isTimeOnly || isTimestamp) el.step = '1';
458
+ el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl font-mono';
459
+ if (!nullish) el.value = toDateInputValue(String(value), isDateOnly, isTimeOnly);
460
+ setTimeout(() => {
461
+ if (typeof el.showPicker === 'function') {
462
+ try { el.showPicker(); } catch (e) {}
463
+ }
464
+ }, 50);
338
465
  } else if (isNum) {
339
466
  el = document.createElement('input');
340
467
  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';
468
+ const isInteger = type.startsWith('INTEGER') || type === 'INT' || type.startsWith('BIGINT') || type.startsWith('SMALLINT') || type.startsWith('SERIAL');
469
+ el.step = isInteger ? '1' : 'any';
470
+ el.className = 'field-input !w-48 !py-1.5 !text-[13px] js-inline-ctrl font-mono';
471
+ if (!nullish) el.value = String(value);
472
+ } else if (isUuid) {
473
+ el = document.createElement('input');
474
+ el.type = 'text';
475
+ el.className = 'field-input font-mono !w-64 !py-1.5 !text-[12px] js-inline-ctrl';
476
+ el.placeholder = '00000000-0000-0000-0000-000000000000';
477
+ if (!nullish) el.value = String(value);
478
+ } else if (isArray) {
479
+ el = document.createElement('input');
480
+ el.type = 'text';
481
+ el.className = 'field-input font-mono !w-64 !py-1.5 !text-[12px] js-inline-ctrl';
482
+ el.placeholder = '{item1, item2}';
483
+ if (!nullish) el.value = String(value);
484
+ } else if (isInterval) {
485
+ el = document.createElement('input');
486
+ el.type = 'text';
487
+ el.className = 'field-input font-mono !w-64 !py-1.5 !text-[12px] js-inline-ctrl';
488
+ el.placeholder = "e.g. '1 day'";
489
+ if (!nullish) el.value = String(value);
490
+ } else if (isGeometry) {
491
+ el = document.createElement('input');
492
+ el.type = 'text';
493
+ el.className = 'field-input font-mono !w-64 !py-1.5 !text-[12px] js-inline-ctrl';
494
+ el.placeholder = "(x, y)";
495
+ if (!nullish) el.value = String(value);
496
+ } else if (isBit) {
497
+ el = document.createElement('input');
498
+ el.type = 'text';
499
+ el.className = 'field-input font-mono !w-64 !py-1.5 !text-[12px] js-inline-ctrl';
500
+ el.placeholder = "101010";
343
501
  if (!nullish) el.value = String(value);
344
502
  } else {
345
503
  el = document.createElement('input');
346
504
  el.type = 'text';
347
505
  el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
348
- el.placeholder = isBlob ? '0x… hex or text' : '';
506
+ el.placeholder = isBlob ? (type === 'BYTEA' ? '\\x… or 0x… hex' : '0x… hex or text') : (isJson ? '{"key":"value"}' : '');
349
507
  if (!nullish) el.value = String(value);
350
508
  }
351
509
  return el;
352
510
  }
353
511
 
512
+
513
+
354
514
  function restoreCell(td) {
355
515
  renderCellValue(td, td._origNull ? null : td._origValue);
356
516
  td.classList.remove('js-editing');
357
517
  }
358
518
 
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.
519
+ // ---- Staged cell edits --------------------------------------------------
364
520
 
365
521
  const pendingChanges = new Map(); // key: `${pk}::${col}` → staged change
366
522
 
@@ -381,13 +537,11 @@
381
537
  else rawValue = v;
382
538
  }
383
539
 
384
- // Remember the server-loaded value the first time this cell is edited.
385
540
  if (td.dataset.origValue === undefined) {
386
541
  td.dataset.origValue = td.dataset.value;
387
542
  td.dataset.origNull = td.dataset.null === 'true' ? 'true' : 'false';
388
543
  }
389
544
 
390
- // Reflect the staged value in the cell immediately.
391
545
  renderCellValue(td, setNull ? null : rawValue);
392
546
 
393
547
  pendingChanges.set(pk + '::' + col, {
@@ -438,7 +592,16 @@
438
592
  });
439
593
  }
440
594
 
441
- function discardPending() {
595
+ async function discardPending() {
596
+ const n = pendingChanges.size;
597
+ if (!n) return;
598
+ const confirmed = await UI.confirm({
599
+ title: 'Discard Changes',
600
+ message: 'You have ' + n + ' pending inline edit(s). Are you sure you want to discard all staged changes and revert to the saved values?',
601
+ confirmText: 'Discard ' + n + ' change(s)',
602
+ danger: true,
603
+ });
604
+ if (!confirmed) return;
442
605
  for (const ch of pendingChanges.values()) {
443
606
  renderCellValue(ch.td, ch.origNull ? null : ch.origValue);
444
607
  ch.td.classList.remove('cell-dirty');
@@ -447,21 +610,119 @@
447
610
  }
448
611
  pendingChanges.clear();
449
612
  updateChangesBar();
613
+ UI.showToast('Pending edits discarded.', 'info');
614
+ }
615
+
616
+ function openReviewChangesModal() {
617
+ if (!pendingChanges.size) return;
618
+ var overlay = document.createElement('div');
619
+ overlay.className = 'fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 opacity-0 transition-opacity duration-200';
620
+ var card = document.createElement('div');
621
+ card.className = 'app-card w-full max-w-3xl overflow-hidden shadow-2xl border border-base-300 transform scale-95 transition-transform duration-200 max-h-[85vh] flex flex-col';
622
+
623
+ var rowsHtml = '';
624
+ pendingChanges.forEach((ch, key) => {
625
+ var oldDisplay = ch.origNull ? '<span class="chip chip-null text-[10px]">NULL</span>' : escapeHtml(ch.origValue);
626
+ var newDisplay = ch.setNull ? '<span class="chip chip-null text-[10px]">NULL</span>' : escapeHtml(ch.rawValue);
627
+ rowsHtml += '<tr class="hover:bg-base-200/50">' +
628
+ '<td class="font-mono text-xs font-semibold">' + escapeHtml(ch.pk) + '</td>' +
629
+ '<td class="font-mono text-xs text-primary font-medium">' + escapeHtml(ch.col) + '</td>' +
630
+ '<td class="font-mono text-xs text-error line-through opacity-80 max-w-xs truncate">' + oldDisplay + '</td>' +
631
+ '<td class="font-mono text-xs text-success font-medium max-w-xs truncate">' + newDisplay + '</td>' +
632
+ '<td class="text-right">' +
633
+ '<button type="button" class="btn btn-ghost btn-xs text-warning js-modal-revert-item" data-key="' + escapeHtml(key) + '">Revert</button>' +
634
+ '</td>' +
635
+ '</tr>';
636
+ });
637
+
638
+ card.innerHTML =
639
+ '<div class="flex items-center justify-between border-b border-base-200 px-6 py-4">' +
640
+ ' <div class="flex items-center gap-2">' +
641
+ ' <h3 class="text-base font-bold text-base-content">Staged Changes Diff</h3>' +
642
+ ' <span class="chip bg-warning/15 text-warning font-mono">' + pendingChanges.size + ' edit(s)</span>' +
643
+ ' </div>' +
644
+ ' <button type="button" class="btn btn-ghost btn-xs btn-circle js-diff-close text-base-content/40 hover:text-base-content">&times;</button>' +
645
+ '</div>' +
646
+ '<div class="flex-1 overflow-y-auto p-4">' +
647
+ ' <table class="table table-sm">' +
648
+ ' <thead><tr class="text-xs uppercase text-base-content/50"><th>Row PK</th><th>Column</th><th>Original Value</th><th>Staged Value</th><th class="text-right">Action</th></tr></thead>' +
649
+ ' <tbody id="diff-rows-body">' + rowsHtml + '</tbody>' +
650
+ ' </table>' +
651
+ '</div>' +
652
+ '<div class="flex items-center justify-between border-t border-base-200 bg-base-200/30 px-6 py-4">' +
653
+ ' <button type="button" id="diff-modal-discard" class="btn btn-ghost btn-sm text-error">Discard All</button>' +
654
+ ' <div class="flex gap-2">' +
655
+ ' <button type="button" class="btn btn-ghost btn-sm js-diff-close">Cancel</button>' +
656
+ ' <button type="button" id="diff-modal-apply" class="btn btn-primary btn-sm gap-1">' +
657
+ ' <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>' +
658
+ ' Apply ' + pendingChanges.size + ' Change(s)' +
659
+ ' </button>' +
660
+ ' </div>' +
661
+ '</div>';
662
+
663
+ overlay.appendChild(card);
664
+ document.body.appendChild(overlay);
665
+
666
+ requestAnimationFrame(() => {
667
+ overlay.classList.remove('opacity-0');
668
+ overlay.classList.add('opacity-100');
669
+ card.classList.remove('scale-95');
670
+ card.classList.add('scale-100');
671
+ });
672
+
673
+ function closeDiff() {
674
+ overlay.classList.remove('opacity-100');
675
+ overlay.classList.add('opacity-0');
676
+ card.classList.remove('scale-100');
677
+ card.classList.add('scale-95');
678
+ setTimeout(() => overlay.remove(), 180);
679
+ }
680
+
681
+ card.querySelectorAll('.js-diff-close').forEach((b) => b.addEventListener('click', closeDiff));
682
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDiff(); });
683
+
684
+ card.querySelectorAll('.js-modal-revert-item').forEach((b) => {
685
+ b.addEventListener('click', () => {
686
+ const key = b.dataset.key;
687
+ const ch = pendingChanges.get(key);
688
+ if (ch) {
689
+ revertCell(ch.td);
690
+ b.closest('tr').remove();
691
+ if (!pendingChanges.size) closeDiff();
692
+ }
693
+ });
694
+ });
695
+
696
+ card.querySelector('#diff-modal-apply').addEventListener('click', () => {
697
+ closeDiff();
698
+ applyPending();
699
+ });
700
+
701
+ card.querySelector('#diff-modal-discard').addEventListener('click', async () => {
702
+ closeDiff();
703
+ await discardPending();
704
+ });
450
705
  }
451
706
 
452
707
  function buildChangesBar() {
453
708
  const bar = document.createElement('div');
454
709
  bar.id = 'changes-bar';
455
710
  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';
711
+ 'fixed bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-2xl border border-base-300 bg-base-100/95 backdrop-blur-md px-4 py-2.5 shadow-2xl transition-all duration-200 hidden';
457
712
  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>' +
713
+ '<div class="flex items-center gap-2">' +
714
+ '<span class="relative flex h-2.5 w-2.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-warning opacity-75"></span><span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-warning"></span></span>' +
715
+ '<span class="text-sm font-medium text-base-content"><b id="changes-count" class="tabular-nums text-warning font-bold">0</b> pending edit(s)</span>' +
716
+ '</div>' +
717
+ '<div class="h-4 w-px bg-base-300"></div>' +
460
718
  '<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>' +
719
+ '<button type="button" id="changes-review" class="btn btn-ghost btn-sm gap-1.5 text-xs font-medium">' +
720
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>' +
721
+ 'Review Diff</button>' +
722
+ '<button type="button" id="changes-apply" class="btn btn-primary btn-sm gap-1 text-xs font-semibold">' +
723
+ '<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" 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
724
  'Apply</button>' +
464
- '<button type="button" id="changes-discard" class="btn btn-ghost btn-sm">Discard</button>' +
725
+ '<button type="button" id="changes-discard" class="btn btn-ghost btn-sm text-xs text-base-content/60 hover:text-error">Discard</button>' +
465
726
  '</div>';
466
727
  document.body.appendChild(bar);
467
728
  return bar;
@@ -479,7 +740,8 @@
479
740
  const c = colInfo(td.dataset.col);
480
741
  if (!c) return;
481
742
 
482
- // Stage any currently-open editor first — same as blur would.
743
+ setFocusedCell(td);
744
+
483
745
  const open = document.querySelector('.js-editing');
484
746
  if (open && open._finish) open._finish(true);
485
747
 
@@ -493,18 +755,30 @@
493
755
  td.appendChild(el);
494
756
 
495
757
  let committed = false;
496
- const finish = (commit) => {
758
+ const finish = (commit, navigateNext) => {
497
759
  if (committed) return;
498
760
  committed = true;
499
761
  if (commit) stageCell(tr, td, el);
500
762
  else restoreCell(td);
763
+ if (navigateNext === 'down') navigateCell('down');
764
+ else if (navigateNext === 'right') navigateCell('right');
765
+ else if (navigateNext === 'left') navigateCell('left');
501
766
  };
502
767
  td._finish = finish;
503
768
 
504
769
  el.addEventListener('keydown', (e) => {
505
- if (e.key === 'Enter') { e.preventDefault(); finish(true); }
506
- else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
770
+ if (e.key === 'Enter') {
771
+ e.preventDefault();
772
+ finish(true, 'down');
773
+ } else if (e.key === 'Tab') {
774
+ e.preventDefault();
775
+ finish(true, e.shiftKey ? 'left' : 'right');
776
+ } else if (e.key === 'Escape') {
777
+ e.preventDefault();
778
+ finish(false);
779
+ }
507
780
  });
781
+
508
782
  if (el.tagName === 'SELECT') {
509
783
  el.addEventListener('change', () => finish(true));
510
784
  } else if (el.type === 'checkbox') {
@@ -516,17 +790,140 @@
516
790
  if (typeof el.select === 'function') el.select();
517
791
  }
518
792
 
793
+ function getEditableCells() {
794
+ return Array.prototype.slice.call(document.querySelectorAll('#main-table tbody tr td.cell-editable'));
795
+ }
796
+
797
+ function navigateCell(direction) {
798
+ if (!focusedCell) {
799
+ const cells = getEditableCells();
800
+ if (cells.length) setFocusedCell(cells[0]);
801
+ return;
802
+ }
803
+
804
+ const tr = focusedCell.closest('tr');
805
+ if (!tr) return;
806
+ const rowCells = Array.prototype.slice.call(tr.querySelectorAll('td.cell-editable'));
807
+ const colIdx = rowCells.indexOf(focusedCell);
808
+
809
+ if (direction === 'right') {
810
+ if (colIdx < rowCells.length - 1) {
811
+ setFocusedCell(rowCells[colIdx + 1]);
812
+ } else {
813
+ const nextTr = tr.nextElementSibling;
814
+ if (nextTr) {
815
+ const nextCells = Array.prototype.slice.call(nextTr.querySelectorAll('td.cell-editable'));
816
+ if (nextCells.length) setFocusedCell(nextCells[0]);
817
+ }
818
+ }
819
+ } else if (direction === 'left') {
820
+ if (colIdx > 0) {
821
+ setFocusedCell(rowCells[colIdx - 1]);
822
+ } else {
823
+ const prevTr = tr.previousElementSibling;
824
+ if (prevTr) {
825
+ const prevCells = Array.prototype.slice.call(prevTr.querySelectorAll('td.cell-editable'));
826
+ if (prevCells.length) setFocusedCell(prevCells[prevCells.length - 1]);
827
+ }
828
+ }
829
+ } else if (direction === 'down') {
830
+ const nextTr = tr.nextElementSibling;
831
+ if (nextTr) {
832
+ const nextCells = Array.prototype.slice.call(nextTr.querySelectorAll('td.cell-editable'));
833
+ if (nextCells[colIdx]) setFocusedCell(nextCells[colIdx]);
834
+ }
835
+ } else if (direction === 'up') {
836
+ const prevTr = tr.previousElementSibling;
837
+ if (prevTr) {
838
+ const prevCells = Array.prototype.slice.call(prevTr.querySelectorAll('td.cell-editable'));
839
+ if (prevCells[colIdx]) setFocusedCell(prevCells[colIdx]);
840
+ }
841
+ }
842
+ }
843
+
519
844
  function initInlineEditing() {
520
- if (readonly) return;
521
845
  changesBar = buildChangesBar();
522
846
  changesCount = document.getElementById('changes-count');
523
847
  applyBtn = document.getElementById('changes-apply');
524
848
  const discardBtn = document.getElementById('changes-discard');
849
+ const reviewBtn = document.getElementById('changes-review');
850
+
525
851
  if (applyBtn) applyBtn.addEventListener('click', applyPending);
526
852
  if (discardBtn) discardBtn.addEventListener('click', discardPending);
853
+ if (reviewBtn) reviewBtn.addEventListener('click', openReviewChangesModal);
854
+
855
+ // Global click listener for table cells and single-cell revert
856
+ document.addEventListener('click', (e) => {
857
+ const revertBtn = e.target.closest('.js-revert-cell');
858
+ if (revertBtn) {
859
+ e.stopPropagation();
860
+ e.preventDefault();
861
+ const td = revertBtn.closest('td');
862
+ if (td) revertCell(td);
863
+ return;
864
+ }
865
+
866
+ const td = e.target.closest('td.cell-editable');
867
+ if (td && !td.classList.contains('js-editing')) {
868
+ setFocusedCell(td);
869
+ }
870
+ });
871
+
872
+ if (!readonly) {
873
+ document.querySelectorAll('.js-inline-cell').forEach((td) => {
874
+ td.addEventListener('dblclick', () => beginEdit(td));
875
+ });
876
+ }
877
+
878
+ // Grid Keyboard Navigation
879
+ document.addEventListener('keydown', (e) => {
880
+ const isEditing = document.querySelector('.js-editing');
881
+ if (isEditing) return;
527
882
 
528
- document.querySelectorAll('.js-inline-cell').forEach((td) => {
529
- td.addEventListener('dblclick', () => beginEdit(td));
883
+ const activeTag = document.activeElement ? document.activeElement.tagName : '';
884
+ if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || activeTag === 'SELECT') return;
885
+
886
+ if (!focusedCell) return;
887
+
888
+ if (e.key === 'ArrowRight') {
889
+ e.preventDefault();
890
+ navigateCell('right');
891
+ } else if (e.key === 'ArrowLeft') {
892
+ e.preventDefault();
893
+ navigateCell('left');
894
+ } else if (e.key === 'ArrowDown') {
895
+ e.preventDefault();
896
+ navigateCell('down');
897
+ } else if (e.key === 'ArrowUp') {
898
+ e.preventDefault();
899
+ navigateCell('up');
900
+ } else if (e.key === 'Tab') {
901
+ e.preventDefault();
902
+ navigateCell(e.shiftKey ? 'left' : 'right');
903
+ } else if (e.key === 'Enter') {
904
+ e.preventDefault();
905
+ if (!readonly) beginEdit(focusedCell);
906
+ } else if (e.key === 'Escape') {
907
+ clearFocusedCell();
908
+ } else if (e.key === ' ' && !readonly) {
909
+ const col = focusedCell.dataset.col;
910
+ const c = colInfo(col);
911
+ if (c && (c.type || '').toUpperCase() === 'BOOLEAN') {
912
+ e.preventDefault();
913
+ const tr = focusedCell.closest('tr');
914
+ const currentVal = currentCellValue(focusedCell);
915
+ const nextVal = (currentVal === 1 || currentVal === '1' || currentVal === true || currentVal === 'true') ? '0' : '1';
916
+ const fakeEl = { value: nextVal, type: 'checkbox', checked: nextVal === '1' };
917
+ stageCell(tr, focusedCell, fakeEl);
918
+ }
919
+ } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
920
+ const val = currentCellValue(focusedCell);
921
+ if (val !== null && val !== undefined) {
922
+ navigator.clipboard.writeText(String(val)).then(() => {
923
+ UI.showToast('Cell value copied.', 'info');
924
+ });
925
+ }
926
+ }
530
927
  });
531
928
  }
532
929
 
@@ -609,62 +1006,32 @@
609
1006
  const refs = (impact && impact.references) || [];
610
1007
  const details = refs.length
611
1008
  ? '<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">' +
1009
+ '<ul class="mb-2 list-disc space-y-1 pl-5 text-xs">' +
613
1010
  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>'
1011
+ '<li><code class="font-mono font-bold">' + escapeHtml(r.table) + '.' + escapeHtml(r.from) + '</code> — <b class="tabular-nums">' + r.count + '</b> row(s) point at them</li>'
615
1012
  ).join('') +
616
1013
  '</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)',
1014
+ '<p class="text-[11px] text-base-content/55">Depending on foreign key rules, those rows may cascade-delete or block this action.</p>'
1015
+ : '<p class="text-xs text-base-content/60">No other tables reference these rows — safe to delete.</p>';
1016
+
1017
+ const confirmed = await UI.confirm({
1018
+ title: 'Delete Selected Rows',
1019
+ message: 'Delete ' + ids.length + ' selected row(s) from table "' + cfg.table + '"? This action cannot be undone.',
1020
+ item: cfg.table,
1021
+ itemType: 'Table',
1022
+ detailsHtml: details,
1023
+ confirmText: 'Delete ' + ids.length + ' row(s)',
628
1024
  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
- },
638
1025
  });
639
- }
1026
+ if (!confirmed) return;
640
1027
 
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();
1028
+ try {
1029
+ const data = await Api.post('/api/tables/' + t + '/rows/bulk-delete', { ids, confirmImpact: true });
1030
+ UI.showToast(data.message || 'Rows deleted.', 'success');
1031
+ window.location.reload();
1032
+ } catch (e) {
1033
+ UI.showError(e.message);
1034
+ }
668
1035
  }
669
1036
 
670
1037
  // ---- CSV import ---------------------------------------------------------
@@ -741,24 +1108,26 @@
741
1108
 
742
1109
  function renderMiniTable(ref, basePath) {
743
1110
  const href = basePath + '/tables/' + encodeURIComponent(ref.table);
1111
+ const count = ref.count ?? ref.total ?? (ref.rows ? ref.rows.length : 0);
1112
+ const columns = ref.columns || (ref.rows && ref.rows.length > 0 ? Object.keys(ref.rows[0]).filter((k) => k !== '_rowid_') : []);
744
1113
  let html =
745
1114
  '<div class="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">' +
746
1115
  '<span class="min-w-0 truncate">' +
747
1116
  '<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>' +
1117
+ '<span class="ml-1.5 text-xs text-base-content/45">via <code class="font-mono">' + escapeHtml(ref.from) + '</code>' + (ref.value !== undefined ? ' = <code class="font-mono">' + escapeHtml(String(ref.value)) + '</code>' : '') + '</span>' +
749
1118
  '</span>' +
750
- '<span class="chip ' + (ref.count ? 'chip-pk' : 'chip-type') + '">' + escapeHtml(ref.count + ' row' + (ref.count === 1 ? '' : 's')) + '</span>' +
1119
+ '<span class="chip ' + (count ? 'chip-pk' : 'chip-type') + '">' + escapeHtml(count + ' row' + (count === 1 ? '' : 's')) + '</span>' +
751
1120
  '</div>';
752
1121
 
753
- if (!ref.rows.length) {
1122
+ if (!ref.rows || !ref.rows.length || !columns.length) {
754
1123
  return html + '<div class="px-3 py-3 text-xs text-base-content/45">No matching rows.</div>';
755
1124
  }
756
1125
 
757
- const thead = '<tr>' + ref.columns.map(function (c) {
1126
+ const thead = '<tr>' + columns.map(function (c) {
758
1127
  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
1128
  }).join('') + '</tr>';
760
1129
  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) {
1130
+ return '<tr class="border-t border-base-200/60 hover:bg-primary/[0.04]">' + columns.map(function (c) {
762
1131
  const v = r[c];
763
1132
  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
1133
  const s = String(v);
@@ -858,6 +1227,122 @@
858
1227
  });
859
1228
  }
860
1229
 
1230
+ // ---- Cell Inspector Trigger --------------------------------------------
1231
+
1232
+ function initCellInspector() {
1233
+ document.addEventListener('click', (e) => {
1234
+ const btn = e.target.closest('.js-inspect-btn');
1235
+ if (!btn) return;
1236
+ e.stopPropagation();
1237
+ e.preventDefault();
1238
+ const td = btn.closest('td');
1239
+ const tr = btn.closest('tr');
1240
+ if (!td || !tr) return;
1241
+
1242
+ const col = td.dataset.col;
1243
+ const isNull = td.dataset.null === 'true';
1244
+ const val = td.dataset.value;
1245
+ const pk = tr.dataset.pk;
1246
+ const table = tr.dataset.table || cfg.table;
1247
+ const isBlob = td.dataset.blob === 'true';
1248
+ const isJson = td.dataset.json === 'true';
1249
+ const colType = td.dataset.type || (colInfo(col)?.type ?? '');
1250
+
1251
+ if (window.Inspector && window.Inspector.open) {
1252
+ window.Inspector.open({
1253
+ table,
1254
+ pk,
1255
+ col,
1256
+ colType,
1257
+ value: val,
1258
+ isNull,
1259
+ isBlob,
1260
+ isJson,
1261
+ readonly,
1262
+ onSave: (newVal) => {
1263
+ if (!readonly) {
1264
+ const fakeEl = { value: newVal, type: 'text' };
1265
+ stageCell(tr, td, fakeEl);
1266
+ }
1267
+ },
1268
+ });
1269
+ }
1270
+ });
1271
+ }
1272
+
1273
+
1274
+
1275
+ // ---- Row Quick Actions --------------------------------------------------
1276
+
1277
+ function initRowActions() {
1278
+ // Copy Row as JSON
1279
+ document.querySelectorAll('.js-row-copy-json').forEach((btn) => {
1280
+ btn.addEventListener('click', (e) => {
1281
+ e.stopPropagation();
1282
+ const tr = btn.closest('tr');
1283
+ if (!tr) return;
1284
+ const rowData = {};
1285
+ tr.querySelectorAll('td.cell-editable').forEach((td) => {
1286
+ const col = td.dataset.col;
1287
+ if (col) {
1288
+ rowData[col] = td.dataset.null === 'true' ? null : td.dataset.value;
1289
+ }
1290
+ });
1291
+ navigator.clipboard.writeText(JSON.stringify(rowData, null, 2)).then(() => {
1292
+ UI.showToast('Row copied as JSON.', 'success');
1293
+ });
1294
+ });
1295
+ });
1296
+
1297
+ // Copy Row as SQL INSERT
1298
+ document.querySelectorAll('.js-row-copy-sql').forEach((btn) => {
1299
+ btn.addEventListener('click', (e) => {
1300
+ e.stopPropagation();
1301
+ const tr = btn.closest('tr');
1302
+ if (!tr) return;
1303
+ const cols = [];
1304
+ const vals = [];
1305
+ tr.querySelectorAll('td.cell-editable').forEach((td) => {
1306
+ const col = td.dataset.col;
1307
+ if (col) {
1308
+ cols.push('"' + col.replace(/"/g, '""') + '"');
1309
+ if (td.dataset.null === 'true') {
1310
+ vals.push('NULL');
1311
+ } else {
1312
+ const v = td.dataset.value;
1313
+ vals.push("'" + String(v).replace(/'/g, "''") + "'");
1314
+ }
1315
+ }
1316
+ });
1317
+ const sql = 'INSERT INTO "' + cfg.table.replace(/"/g, '""') + '" (' + cols.join(', ') + ') VALUES (' + vals.join(', ') + ');';
1318
+ navigator.clipboard.writeText(sql).then(() => {
1319
+ UI.showToast('SQL INSERT copied to clipboard.', 'success');
1320
+ });
1321
+ });
1322
+ });
1323
+
1324
+ // Duplicate Row
1325
+ document.querySelectorAll('.js-row-duplicate').forEach((btn) => {
1326
+ btn.addEventListener('click', (e) => {
1327
+ e.stopPropagation();
1328
+ const pk = btn.dataset.pk;
1329
+ if (!pk) return;
1330
+ window.location.href = base + '/tables/' + t + '/rows/new?duplicate=' + encodeURIComponent(pk);
1331
+ });
1332
+ });
1333
+
1334
+ // Auto-position dropdown (dropdown-top if near bottom)
1335
+ document.querySelectorAll('.dropdown').forEach((dd) => {
1336
+ const btn = dd.querySelector('label[role="button"]') || dd.querySelector('button');
1337
+ if (!btn) return;
1338
+ btn.addEventListener('click', () => {
1339
+ const rect = btn.getBoundingClientRect();
1340
+ const spaceBelow = window.innerHeight - rect.bottom;
1341
+ dd.classList.toggle('dropdown-top', spaceBelow < 220);
1342
+ });
1343
+ });
1344
+ }
1345
+
861
1346
  // ---- Init ---------------------------------------------------------------
862
1347
 
863
1348
  async function init() {
@@ -872,7 +1357,9 @@
872
1357
  fkOptions = {};
873
1358
  }
874
1359
  initFilters();
1360
+ initRowActions();
875
1361
  initInlineEditing();
1362
+ initCellInspector();
876
1363
  initBulk();
877
1364
  initRowDelete();
878
1365
  initPageSize();