sqlite-hub 0.9.1 → 0.9.4

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 (56) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +3 -158
  5. package/frontend/js/app.js +231 -5
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +158 -199
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +90 -100
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +76 -56
  15. package/frontend/js/components/tableDesignerEditor.js +91 -116
  16. package/frontend/js/lib/queryChartOptions.js +5 -1
  17. package/frontend/js/lib/queryCharts.js +50 -2
  18. package/frontend/js/store.js +161 -23
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +126 -73
  21. package/frontend/js/views/data.js +147 -133
  22. package/frontend/js/views/editor.js +1 -0
  23. package/frontend/js/views/mediaTagging.js +131 -164
  24. package/frontend/js/views/structure.js +52 -48
  25. package/frontend/styles/base.css +57 -13
  26. package/frontend/styles/components.css +166 -96
  27. package/frontend/styles/layout.css +1 -1
  28. package/frontend/styles/structure-graph.css +11 -11
  29. package/frontend/styles/tailwind.css +81 -0
  30. package/frontend/styles/tailwind.generated.css +1 -0
  31. package/frontend/styles/tokens.css +50 -7
  32. package/frontend/styles/utilities.css +21 -0
  33. package/frontend/styles/views.css +94 -86
  34. package/package.json +16 -3
  35. package/server/routes/mediaTagging.js +2 -10
  36. package/server/routes/sql.js +35 -10
  37. package/server/server.js +24 -0
  38. package/server/services/sqlite/dataBrowserService.js +25 -5
  39. package/server/services/sqlite/exportService.js +4 -2
  40. package/server/services/sqlite/introspection.js +2 -2
  41. package/server/services/sqlite/mediaTaggingService.js +166 -53
  42. package/server/services/sqlite/structureService.js +2 -2
  43. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  44. package/server/services/storage/appStateStore.js +227 -87
  45. package/server/services/storage/queryHistoryChartUtils.js +19 -2
  46. package/server/utils/appPaths.js +55 -19
  47. package/server/utils/fileValidation.js +94 -8
  48. package/tailwind.config.cjs +73 -0
  49. package/tests/security-paths.test.js +84 -0
  50. package/tests/sql-identifier-safety.test.js +66 -0
  51. package/.npmingnore +0 -4
  52. package/changelog.md +0 -77
  53. package/docs/DESIGN_GUIDELINES.md +0 -36
  54. package/scripts/publish_brew.sh +0 -466
  55. package/scripts/publish_npm.sh +0 -241
  56. package/shortkeys.md +0 -5
@@ -13,92 +13,67 @@ export function renderConnectionCard(connection, activeConnectionId) {
13
13
  : Boolean(connection.isActive);
14
14
  const clipPath = "polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%)";
15
15
  const primaryActionLabel = isActive ? "Open Overview" : "Set Active";
16
+ const connectionId = escapeHtml(connection.id);
17
+ const logoMarkup = renderConnectionLogo(connection, {
18
+ containerClass: [
19
+ "clipped-corner flex h-10 w-10 items-center justify-center overflow-hidden transition-colors",
20
+ isActive ? "bg-primary-container" : "bg-surface-container-highest",
21
+ ].join(" "),
22
+ containerAttributes: ['style="--clip-path: ', clipPath, ';"'].join(""),
23
+ imageClassName: "h-full w-full object-cover",
24
+ iconClassName: isActive ? "text-on-primary" : "text-outline-variant",
25
+ });
16
26
 
17
- return `
18
- <article
19
- class="connection-card clipped-corner ${isActive ? "is-active" : ""}"
20
- style="--clip-path: ${clipPath};"
21
- >
22
- <div class="flex-1 p-6">
23
- <div class="mb-6 flex items-start justify-between">
24
- ${renderConnectionLogo(connection, {
25
- containerClass: `clipped-corner flex h-10 w-10 items-center justify-center overflow-hidden transition-colors ${
26
- isActive ? "bg-primary-container" : "bg-surface-container-highest"
27
- }`,
28
- containerAttributes: `style="--clip-path: ${clipPath};"`,
29
- imageClassName: "h-full w-full object-cover",
30
- iconClassName: isActive ? "text-on-primary" : "text-outline-variant",
31
- })}
32
- <div class="flex items-center gap-2">
33
- ${renderStatusBadge(isActive ? "ACTIVE" : "RECENT", isActive ? "primary" : "muted")}
34
- ${connection.readOnly ? renderStatusBadge("READ_ONLY", "alert") : ""}
35
- </div>
36
- </div>
37
- <h3 class="mb-1 font-headline text-xl font-bold uppercase ${
38
- isActive ? "text-[#FCE300]" : "text-on-surface"
39
- }">
40
- ${escapeHtml(connection.label)}
41
- </h3>
42
- <p
43
- class="block overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10px] text-outline-variant"
44
- title="${escapeHtml(connection.path)}"
45
- >
46
- ${escapeHtml(truncateMiddle(connection.path, 68))}
47
- </p>
48
- <div class="mt-8 grid grid-cols-2 gap-4">
49
- <div>
50
- <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Allocation</div>
51
- <div class="text-xs font-bold text-on-surface">${escapeHtml(
52
- formatBytes(connection.sizeBytes)
53
- )}</div>
54
- </div>
55
- <div>
56
- <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Modified</div>
57
- <div class="text-xs font-bold text-on-surface">${escapeHtml(
58
- formatDateTime(connection.lastModifiedAt)
59
- )}</div>
60
- </div>
61
- <div>
62
- <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Opened</div>
63
- <div class="text-xs font-bold text-on-surface">${escapeHtml(
64
- formatDateTime(connection.lastOpenedAt)
65
- )}</div>
66
- </div>
67
- <div>
68
- <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div>
69
- <div class="text-xs font-bold text-on-surface">${connection.readOnly ? "Read only" : "Read / Write"}</div>
70
- </div>
71
- </div>
72
- </div>
73
- <div class="border-t border-outline-variant/10 bg-surface-container-low px-4 py-3">
74
- <div class="grid grid-cols-[minmax(0,1fr)_5.1rem_5.8rem] gap-2">
75
- <button
76
- class="signature-button"
77
- data-action="select-connection"
78
- data-connection-id="${escapeHtml(connection.id)}"
79
- type="button"
80
- title="${primaryActionLabel}"
81
- >
82
- ${primaryActionLabel}
83
- </button>
84
- <button
85
- class="standard-button"
86
- data-action="edit-connection"
87
- data-connection-id="${escapeHtml(connection.id)}"
88
- type="button"
89
- >
90
- Edit
91
- </button>
92
- <button
93
- class="delete-button"
94
- data-action="remove-connection"
95
- data-connection-id="${escapeHtml(connection.id)}"
96
- type="button"
97
- >
98
- Remove
99
- </button>
100
- </div>
101
- </div>
102
- </article>
103
- `;
27
+ return [
28
+ '<article class="connection-card clipped-corner ',
29
+ isActive ? "is-active" : "",
30
+ '" style="--clip-path: ',
31
+ clipPath,
32
+ ';">',
33
+ '<div class="flex-1 p-6"><div class="mb-6 flex items-start justify-between">',
34
+ logoMarkup,
35
+ '<div class="flex items-center gap-2">',
36
+ renderStatusBadge(isActive ? "ACTIVE" : "RECENT", isActive ? "primary" : "muted"),
37
+ connection.readOnly ? renderStatusBadge("READ_ONLY", "alert") : "",
38
+ "</div></div>",
39
+ '<h3 class="mb-1 font-headline text-xl font-bold uppercase ',
40
+ isActive ? "text-[#FCE300]" : "text-on-surface",
41
+ '">',
42
+ escapeHtml(connection.label),
43
+ "</h3>",
44
+ '<p class="block overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10px] text-outline-variant" title="',
45
+ escapeHtml(connection.path),
46
+ '">',
47
+ escapeHtml(truncateMiddle(connection.path, 68)),
48
+ "</p>",
49
+ '<div class="mt-8 grid grid-cols-2 gap-4">',
50
+ '<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Allocation</div><div class="text-xs font-bold text-on-surface">',
51
+ escapeHtml(formatBytes(connection.sizeBytes)),
52
+ "</div></div>",
53
+ '<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Modified</div><div class="text-xs font-bold text-on-surface">',
54
+ escapeHtml(formatDateTime(connection.lastModifiedAt)),
55
+ "</div></div>",
56
+ '<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Opened</div><div class="text-xs font-bold text-on-surface">',
57
+ escapeHtml(formatDateTime(connection.lastOpenedAt)),
58
+ "</div></div>",
59
+ '<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div><div class="text-xs font-bold text-on-surface">',
60
+ connection.readOnly ? "Read only" : "Read / Write",
61
+ "</div></div></div></div>",
62
+ '<div class="border-t border-outline-variant/10 bg-surface-container-low px-4 py-3">',
63
+ '<div class="grid grid-cols-[minmax(0,1fr)_5.1rem_5.8rem] gap-2">',
64
+ '<button class="signature-button" data-action="select-connection" data-connection-id="',
65
+ connectionId,
66
+ '" type="button" title="',
67
+ primaryActionLabel,
68
+ '">',
69
+ primaryActionLabel,
70
+ "</button>",
71
+ '<button class="standard-button" data-action="edit-connection" data-connection-id="',
72
+ connectionId,
73
+ '" type="button">Edit</button>',
74
+ '<button class="delete-button" data-action="remove-connection" data-connection-id="',
75
+ connectionId,
76
+ '" type="button">Remove</button>',
77
+ "</div></div></article>",
78
+ ].join("");
104
79
  }
@@ -6,6 +6,25 @@ import {
6
6
  } from "../utils/format.js";
7
7
  import { renderConnectionLogo } from "./connectionLogo.js";
8
8
 
9
+ function renderRecentConnectionButton(connection) {
10
+ return [
11
+ '<button class="control-button flex items-center gap-2 border border-outline-variant/15 bg-surface-container-low px-4 text-left text-on-surface transition-colors hover:border-primary-container/30 hover:bg-surface-container-high" data-action="select-connection" data-connection-id="',
12
+ escapeHtml(connection.id),
13
+ '" type="button">',
14
+ renderConnectionLogo(connection, {
15
+ containerClass:
16
+ "flex h-8 w-8 items-center justify-center overflow-hidden bg-surface-container-highest",
17
+ imageClassName: "h-full w-full object-cover",
18
+ iconClassName: "text-sm text-primary-container",
19
+ }),
20
+ '<span class="min-w-0"><span class="block truncate font-mono text-xs">',
21
+ escapeHtml(connection.label),
22
+ '</span><span class="block truncate text-[10px] text-on-surface-variant/45">',
23
+ escapeHtml(truncateMiddle(connection.path, 34)),
24
+ "</span></span></button>",
25
+ ].join("");
26
+ }
27
+
9
28
  function renderRecentConnections(recentConnections = []) {
10
29
  if (!recentConnections.length) {
11
30
  return `
@@ -19,29 +38,7 @@ function renderRecentConnections(recentConnections = []) {
19
38
  <div class="flex flex-wrap justify-center gap-4">
20
39
  ${recentConnections
21
40
  .slice(0, 4)
22
- .map(
23
- (connection) => `
24
- <button
25
- class="control-button flex items-center gap-2 border border-outline-variant/15 bg-surface-container-low px-4 text-left text-on-surface transition-colors hover:border-primary-container/30 hover:bg-surface-container-high"
26
- data-action="select-connection"
27
- data-connection-id="${escapeHtml(connection.id)}"
28
- type="button"
29
- >
30
- ${renderConnectionLogo(connection, {
31
- containerClass:
32
- "flex h-8 w-8 items-center justify-center overflow-hidden bg-surface-container-highest",
33
- imageClassName: "h-full w-full object-cover",
34
- iconClassName: "text-sm text-primary-container",
35
- })}
36
- <span class="min-w-0">
37
- <span class="block truncate font-mono text-xs">${escapeHtml(connection.label)}</span>
38
- <span class="block truncate text-[10px] text-on-surface-variant/45">${escapeHtml(
39
- truncateMiddle(connection.path, 34)
40
- )}</span>
41
- </span>
42
- </button>
43
- `
44
- )
41
+ .map((connection) => renderRecentConnectionButton(connection))
45
42
  .join("")}
46
43
  </div>
47
44
  `;
@@ -97,28 +97,37 @@ function renderFileField({
97
97
  }
98
98
 
99
99
  function renderSelectField({ label, name, value = "", options = [], bind = "" }) {
100
- return `
101
- <label class="block space-y-2">
102
- <span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
103
- ${escapeHtml(label)}
104
- </span>
105
- <select
106
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
107
- ${bind ? `data-bind="${escapeHtml(bind)}"` : ""}
108
- ${name ? `name="${escapeHtml(name)}"` : ""}
109
- >
110
- ${options
111
- .map(
112
- (option) => `
113
- <option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? "selected" : ""}>
114
- ${escapeHtml(option.label)}
115
- </option>
116
- `
117
- )
118
- .join("")}
119
- </select>
120
- </label>
121
- `;
100
+ const attributes = [
101
+ bind ? ['data-bind="', escapeHtml(bind), '"'].join("") : "",
102
+ name ? ['name="', escapeHtml(name), '"'].join("") : "",
103
+ ]
104
+ .filter(Boolean)
105
+ .join(" ");
106
+ const optionMarkup = options
107
+ .map((option) =>
108
+ [
109
+ '<option value="',
110
+ escapeHtml(option.value),
111
+ '" ',
112
+ String(option.value) === String(value) ? "selected" : "",
113
+ ">",
114
+ escapeHtml(option.label),
115
+ "</option>",
116
+ ].join("")
117
+ )
118
+ .join("");
119
+
120
+ return [
121
+ '<label class="block space-y-2">',
122
+ '<span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">',
123
+ escapeHtml(label),
124
+ "</span>",
125
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" ',
126
+ attributes,
127
+ ">",
128
+ optionMarkup,
129
+ "</select></label>",
130
+ ].join("");
122
131
  }
123
132
 
124
133
  function renderError(error) {
@@ -283,62 +292,55 @@ function renderCreateDatabaseForm(modal) {
283
292
 
284
293
  function renderImportTargetOptions(state) {
285
294
  const recentOptions = state.connections.recent
286
- .map(
287
- (connection) => `
288
- <option value="${escapeHtml(connection.id)}">
289
- ${escapeHtml(connection.label)} • ${escapeHtml(truncateMiddle(connection.path, 42))}
290
- </option>
291
- `
295
+ .map((connection) =>
296
+ [
297
+ '<option value="',
298
+ escapeHtml(connection.id),
299
+ '">',
300
+ escapeHtml(connection.label),
301
+ " • ",
302
+ escapeHtml(truncateMiddle(connection.path, 42)),
303
+ "</option>",
304
+ ].join("")
292
305
  )
293
306
  .join("");
294
-
295
- return `
296
- <label class="block space-y-2">
297
- <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
298
- Import Target
299
- </span>
300
- <select
301
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
302
- name="targetMode"
303
- >
304
- ${
305
- state.connections.active
306
- ? '<option value="active">Use active database</option>'
307
- : ""
308
- }
309
- ${state.connections.recent.length ? '<option value="recent">Use recent connection</option>' : ""}
310
- <option value="create">Create new database from dump</option>
311
- <option value="path">Open explicit target path</option>
312
- </select>
313
- </label>
314
- ${
315
- state.connections.recent.length
316
- ? `
317
- <label class="block space-y-2">
318
- <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
319
- Recent Connection
320
- </span>
321
- <select
322
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
323
- name="targetConnectionId"
324
- >
325
- ${recentOptions}
326
- </select>
327
- </label>
328
- `
329
- : ""
330
- }
331
- ${renderField({
307
+ const activeOption = state.connections.active
308
+ ? '<option value="active">Use active database</option>'
309
+ : "";
310
+ const recentModeOption = state.connections.recent.length
311
+ ? '<option value="recent">Use recent connection</option>'
312
+ : "";
313
+ const recentConnectionSelect = state.connections.recent.length
314
+ ? [
315
+ '<label class="block space-y-2">',
316
+ '<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">Recent Connection</span>',
317
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" name="targetConnectionId">',
318
+ recentOptions,
319
+ "</select></label>",
320
+ ].join("")
321
+ : "";
322
+
323
+ return [
324
+ '<label class="block space-y-2">',
325
+ '<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">Import Target</span>',
326
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" name="targetMode">',
327
+ activeOption,
328
+ recentModeOption,
329
+ '<option value="create">Create new database from dump</option>',
330
+ '<option value="path">Open explicit target path</option>',
331
+ "</select></label>",
332
+ recentConnectionSelect,
333
+ renderField({
332
334
  label: "Target Path",
333
335
  name: "targetPath",
334
336
  placeholder: "/absolute/path/to/target.sqlite",
335
- })}
336
- ${renderField({
337
+ }),
338
+ renderField({
337
339
  label: "Target Label",
338
340
  name: "label",
339
341
  placeholder: "Optional display name",
340
- })}
341
- `;
342
+ }),
343
+ ].join("");
342
344
  }
343
345
 
344
346
  function renderImportSqlForm(modal, state) {
@@ -376,71 +378,51 @@ function renderImportSqlForm(modal, state) {
376
378
 
377
379
  function renderDeleteRowConfirmForm(modal) {
378
380
  const rowPreview = modal.rowPreview ?? [];
379
-
380
- return `
381
- <form class="space-y-5" data-form="delete-row-confirm">
382
- <div class="space-y-3">
383
- <p class="text-sm leading-7 text-on-surface">
384
- Delete this row from <span class="font-bold text-primary-container">${escapeHtml(
385
- modal.tableName ?? "the current table"
386
- )}</span>?
387
- </p>
388
- <p class="text-sm leading-7 text-on-surface-variant/65">
389
- This action cannot be undone.
390
- </p>
391
- ${
392
- modal.rowLabel
393
- ? `
394
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
395
- ${escapeHtml(modal.rowLabel)}
396
- </div>
397
- `
398
- : ""
399
- }
400
- ${
401
- rowPreview.length
402
- ? `
403
- <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
404
- ${rowPreview
405
- .map(
406
- (field) => `
407
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
408
- <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
409
- ${escapeHtml(field.label)}
410
- </div>
411
- <div
412
- class="mt-2 text-sm text-on-surface"
413
- title="${escapeHtml(field.fullValue ?? field.value ?? "")}"
414
- >
415
- ${escapeHtml(field.value ?? "")}
416
- </div>
417
- </div>
418
- `
419
- )
420
- .join("")}
421
- </div>
422
- `
423
- : ""
424
- }
425
- </div>
426
- ${renderError(modal.error)}
427
- <div class="flex items-center justify-end gap-3 pt-2">
428
- <button
429
- class="standard-button"
430
- data-action="close-modal"
431
- type="button"
432
- >
433
- Cancel
434
- </button>
435
- <button
436
- class="delete-button"
437
- type="submit"
438
- >
439
- ${modal.submitting ? "Deleting..." : "Delete Row"}
440
- </button>
441
- </div>
442
- </form>
443
- `;
381
+ const rowLabelMarkup = modal.rowLabel
382
+ ? [
383
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">',
384
+ escapeHtml(modal.rowLabel),
385
+ "</div>",
386
+ ].join("")
387
+ : "";
388
+ const rowPreviewMarkup = rowPreview.length
389
+ ? [
390
+ '<div class="grid grid-cols-1 gap-3 md:grid-cols-2">',
391
+ rowPreview
392
+ .map((field) =>
393
+ [
394
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">',
395
+ '<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">',
396
+ escapeHtml(field.label),
397
+ "</div>",
398
+ '<div class="mt-2 text-sm text-on-surface" title="',
399
+ escapeHtml(field.fullValue ?? field.value ?? ""),
400
+ '">',
401
+ escapeHtml(field.value ?? ""),
402
+ "</div></div>",
403
+ ].join("")
404
+ )
405
+ .join(""),
406
+ "</div>",
407
+ ].join("")
408
+ : "";
409
+
410
+ return [
411
+ '<form class="space-y-5" data-form="delete-row-confirm"><div class="space-y-3">',
412
+ '<p class="text-sm leading-7 text-on-surface">Delete this row from <span class="font-bold text-primary-container">',
413
+ escapeHtml(modal.tableName ?? "the current table"),
414
+ "</span>?</p>",
415
+ '<p class="text-sm leading-7 text-on-surface-variant/65">This action cannot be undone.</p>',
416
+ rowLabelMarkup,
417
+ rowPreviewMarkup,
418
+ "</div>",
419
+ renderError(modal.error),
420
+ '<div class="flex items-center justify-end gap-3 pt-2">',
421
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
422
+ '<button class="delete-button" type="submit">',
423
+ modal.submitting ? "Deleting..." : "Delete Row",
424
+ "</button></div></form>",
425
+ ].join("");
444
426
  }
445
427
 
446
428
  function renderChartColumnOptions(analysis, { allowEmpty = false, includeNumericHint = false } = {}) {
@@ -481,13 +463,22 @@ function renderChartEditorForm(modal, state) {
481
463
  bind: "query-chart-draft-config:y_column",
482
464
  })}
483
465
  </div>
484
- <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
466
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
467
+ ${renderSelectField({
468
+ label: "Sort By",
469
+ value: draft.config?.sort_by ?? "y",
470
+ options: [
471
+ { value: "x", label: "X column" },
472
+ { value: "y", label: "Y value" },
473
+ ],
474
+ bind: "query-chart-draft-config:sort_by",
475
+ })}
485
476
  ${renderSelectField({
486
477
  label: "Sort Direction",
487
- value: draft.config?.sort_direction ?? "asc",
478
+ value: draft.config?.sort_direction ?? "desc",
488
479
  options: [
489
- { value: "asc", label: "Ascending" },
490
- { value: "desc", label: "Descending" },
480
+ { value: "asc", label: "Ascending / smallest first" },
481
+ { value: "desc", label: "Descending / largest first" },
491
482
  ],
492
483
  bind: "query-chart-draft-config:sort_direction",
493
484
  })}
@@ -684,69 +675,37 @@ function renderChartEditorForm(modal, state) {
684
675
  }
685
676
 
686
677
  function renderDeleteChartForm(modal) {
687
- return `
688
- <form class="space-y-5" data-form="delete-query-chart">
689
- <div class="space-y-3">
690
- <p class="text-sm leading-7 text-on-surface">
691
- Delete chart <span class="font-bold text-primary-container">${escapeHtml(
692
- modal.chartName ?? "Chart"
693
- )}</span>?
694
- </p>
695
- <p class="text-sm leading-7 text-on-surface-variant/65">
696
- The linked query-history entry stays intact. Only this chart definition is removed.
697
- </p>
698
- </div>
699
- ${renderError(modal.error)}
700
- <div class="flex items-center justify-end gap-3 pt-2">
701
- <button
702
- class="standard-button"
703
- data-action="close-modal"
704
- type="button"
705
- >
706
- Cancel
707
- </button>
708
- <button
709
- class="delete-button"
710
- type="submit"
711
- >
712
- ${modal.submitting ? "Deleting..." : "Delete Chart"}
713
- </button>
714
- </div>
715
- </form>
716
- `;
678
+ return [
679
+ '<form class="space-y-5" data-form="delete-query-chart"><div class="space-y-3">',
680
+ '<p class="text-sm leading-7 text-on-surface">Delete chart <span class="font-bold text-primary-container">',
681
+ escapeHtml(modal.chartName ?? "Chart"),
682
+ "</span>?</p>",
683
+ '<p class="text-sm leading-7 text-on-surface-variant/65">The linked query-history entry stays intact. Only this chart definition is removed.</p>',
684
+ "</div>",
685
+ renderError(modal.error),
686
+ '<div class="flex items-center justify-end gap-3 pt-2">',
687
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
688
+ '<button class="delete-button" type="submit">',
689
+ modal.submitting ? "Deleting..." : "Delete Chart",
690
+ "</button></div></form>",
691
+ ].join("");
717
692
  }
718
693
 
719
694
  function renderDeleteQueryHistoryForm(modal) {
720
- return `
721
- <form class="space-y-5" data-form="delete-query-history-confirm">
722
- <div class="space-y-3">
723
- <p class="text-sm leading-7 text-on-surface">
724
- Delete query <span class="font-bold text-primary-container">${escapeHtml(
725
- modal.queryTitle ?? "SQL query"
726
- )}</span>?
727
- </p>
728
- <p class="text-sm leading-7 text-on-surface-variant/65">
729
- This removes the query-history entry and all recorded runs linked to it.
730
- </p>
731
- </div>
732
- ${renderError(modal.error)}
733
- <div class="flex items-center justify-end gap-3 pt-2">
734
- <button
735
- class="standard-button"
736
- data-action="close-modal"
737
- type="button"
738
- >
739
- Cancel
740
- </button>
741
- <button
742
- class="delete-button"
743
- type="submit"
744
- >
745
- ${modal.submitting ? "Deleting..." : "Delete Query"}
746
- </button>
747
- </div>
748
- </form>
749
- `;
695
+ return [
696
+ '<form class="space-y-5" data-form="delete-query-history-confirm"><div class="space-y-3">',
697
+ '<p class="text-sm leading-7 text-on-surface">Delete query <span class="font-bold text-primary-container">',
698
+ escapeHtml(modal.queryTitle ?? "SQL query"),
699
+ "</span>?</p>",
700
+ '<p class="text-sm leading-7 text-on-surface-variant/65">This removes the query-history entry and all recorded runs linked to it.</p>',
701
+ "</div>",
702
+ renderError(modal.error),
703
+ '<div class="flex items-center justify-end gap-3 pt-2">',
704
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
705
+ '<button class="delete-button" type="submit">',
706
+ modal.submitting ? "Deleting..." : "Delete Query",
707
+ "</button></div></form>",
708
+ ].join("");
750
709
  }
751
710
 
752
711
  function renderCreateMediaTaggingMappingTableForm(modal, state) {
@@ -16,7 +16,7 @@ export function renderPageHeader({ eyebrow = "", title, subtitle = "", actions =
16
16
  `
17
17
  : ""
18
18
  }
19
- <h1 class="text-5xl font-['Space_Grotesk'] font-bold text-[#FCE300] tracking-tighter uppercase">${escapeHtml(
19
+ <h1 class="text-5xl font-headline font-bold text-[#FCE300] tracking-tighter uppercase">${escapeHtml(
20
20
  title
21
21
  )}</h1>
22
22
  ${