sqlite-hub 0.10.0 → 0.12.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.
@@ -366,6 +366,15 @@ export function getDataTable(tableName, options = {}) {
366
366
  params.set("sortDirection", String(options.sortDirection));
367
367
  }
368
368
 
369
+ const filterColumn = String(options.filterColumn ?? "").trim();
370
+ const filterValue = String(options.filterValue ?? "");
371
+
372
+ if (filterColumn && filterValue.trim()) {
373
+ params.set("filterColumn", filterColumn);
374
+ params.set("filterOperator", String(options.filterOperator ?? "="));
375
+ params.set("filterValue", filterValue);
376
+ }
377
+
369
378
  const query = params.toString();
370
379
 
371
380
  return request(`/api/data/${encodeURIComponent(tableName)}${query ? `?${query}` : ""}`);
@@ -410,22 +419,72 @@ export function patchSettings(settings) {
410
419
  });
411
420
  }
412
421
 
413
- export function downloadQueryCsv(sql) {
414
- return download("/api/export/query.csv", {
422
+ const TEXT_EXPORT_EXTENSIONS = {
423
+ csv: "csv",
424
+ tsv: "tsv",
425
+ md: "md",
426
+ };
427
+
428
+ function normalizeTextExportFormat(format) {
429
+ const normalized = String(format ?? "csv").toLowerCase();
430
+ return TEXT_EXPORT_EXTENSIONS[normalized] ? normalized : "csv";
431
+ }
432
+
433
+ export function getQueryExport(sql, format = "csv") {
434
+ return request("/api/export/query", {
435
+ method: "POST",
436
+ body: {
437
+ sql,
438
+ format: normalizeTextExportFormat(format),
439
+ },
440
+ });
441
+ }
442
+
443
+ export function downloadQueryExport(sql, format = "csv") {
444
+ const normalizedFormat = normalizeTextExportFormat(format);
445
+ const extension = TEXT_EXPORT_EXTENSIONS[normalizedFormat];
446
+
447
+ return download(`/api/export/query.${extension}`, {
415
448
  method: "POST",
416
449
  body: { sql },
417
- fallbackFilename: "query-results.csv",
450
+ fallbackFilename: `query-results.${extension}`,
418
451
  });
419
452
  }
420
453
 
421
- export function downloadTableCsv(tableName, options = {}) {
422
- return download("/api/export/table.csv", {
454
+ export function downloadQueryCsv(sql) {
455
+ return downloadQueryExport(sql, "csv");
456
+ }
457
+
458
+ function buildTableExportBody(tableName, options = {}) {
459
+ return {
460
+ tableName,
461
+ sortColumn: options.sortColumn,
462
+ sortDirection: options.sortDirection,
463
+ filterColumn: options.filterColumn,
464
+ filterOperator: options.filterOperator,
465
+ filterValue: options.filterValue,
466
+ format: normalizeTextExportFormat(options.format),
467
+ };
468
+ }
469
+
470
+ export function getTableExport(tableName, options = {}) {
471
+ return request("/api/export/table", {
423
472
  method: "POST",
424
- body: {
425
- tableName,
426
- sortColumn: options.sortColumn,
427
- sortDirection: options.sortDirection,
428
- },
429
- fallbackFilename: `${tableName || "table"}.csv`,
473
+ body: buildTableExportBody(tableName, options),
430
474
  });
431
475
  }
476
+
477
+ export function downloadTableExport(tableName, options = {}) {
478
+ const normalizedFormat = normalizeTextExportFormat(options.format);
479
+ const extension = TEXT_EXPORT_EXTENSIONS[normalizedFormat];
480
+
481
+ return download(`/api/export/table.${extension}`, {
482
+ method: "POST",
483
+ body: buildTableExportBody(tableName, { ...options, format: normalizedFormat }),
484
+ fallbackFilename: `${tableName || "table"}.${extension}`,
485
+ });
486
+ }
487
+
488
+ export function downloadTableCsv(tableName, options = {}) {
489
+ return downloadTableExport(tableName, { ...options, format: "csv" });
490
+ }
@@ -33,8 +33,10 @@ import {
33
33
  dismissMediaTaggingIssue,
34
34
  dismissToast,
35
35
  executeCurrentQuery,
36
- exportCurrentDataTableCsv,
37
- exportCurrentQueryCsv,
36
+ duplicateCurrentDataTableAsTable,
37
+ exportCurrentDataTableFormat,
38
+ duplicateCurrentQueryAsTable,
39
+ exportCurrentQueryFormat,
38
40
  getState,
39
41
  initializeApp,
40
42
  loadMoreQueryHistory,
@@ -45,6 +47,8 @@ import {
45
47
  openDeleteEditorRowModal,
46
48
  openDeleteQueryHistoryModal,
47
49
  openDeleteQueryChartModal,
50
+ openDataExportModal,
51
+ openQueryExportModal,
48
52
  openDataRowByIdentity,
49
53
  openEditConnectionModal,
50
54
  openCreateQueryChartModal,
@@ -73,6 +77,7 @@ import {
73
77
  toggleStructureTablesPanel,
74
78
  setDataPage,
75
79
  setDataPageSize,
80
+ setDataFilterOperator,
76
81
  setDataSearchColumn,
77
82
  setDataSearchQuery,
78
83
  toggleDataTablesPanel,
@@ -131,10 +136,7 @@ import { renderOverviewView } from './views/overview.js';
131
136
  import { renderSettingsView } from './views/settings.js';
132
137
  import { renderStructureView } from './views/structure.js';
133
138
  import { renderTableDesignerView } from './views/tableDesigner.js';
134
- import {
135
- replaceChildrenFromRenderedMarkup,
136
- replaceElementFromRenderedMarkup,
137
- } from './utils/dom.js';
139
+ import { replaceChildrenFromRenderedMarkup, replaceElementFromRenderedMarkup } from './utils/dom.js';
138
140
  import { highlightSql } from './utils/format.js';
139
141
 
140
142
  const appRoot = document.querySelector('#app');
@@ -332,7 +334,7 @@ function normalizeMediaTaggingRotationDegrees(value) {
332
334
  return 0;
333
335
  }
334
336
 
335
- return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
337
+ return (((Math.round(numericValue / 90) * 90) % 360) + 360) % 360;
336
338
  }
337
339
 
338
340
  function syncMediaTaggingMediaRotationUi(node, rotationDegrees) {
@@ -383,7 +385,9 @@ function syncMediaTaggingTagSearchUi(input) {
383
385
  continue;
384
386
  }
385
387
 
386
- const searchText = String(tagOption.dataset.tagSearchText ?? '').trim().toLowerCase();
388
+ const searchText = String(tagOption.dataset.tagSearchText ?? '')
389
+ .trim()
390
+ .toLowerCase();
387
391
  tagOption.hidden = Boolean(normalizedQuery) && !searchText.includes(normalizedQuery);
388
392
  }
389
393
 
@@ -501,23 +505,18 @@ function syncQueryHistoryUi(historyId) {
501
505
  return false;
502
506
  }
503
507
 
504
- const historyItem = state.editor.history.find(entry => Number(entry.id) === numericId) ?? state.editor.historyDetail ?? null;
505
- const listItemNode = shellRefs.view.querySelector(
506
- [
507
- '[data-action="select-query-history-item"][data-history-id="',
508
- String(numericId),
509
- '"]',
510
- ].join(''),
511
- )?.closest('.query-history-item');
508
+ const historyItem =
509
+ state.editor.history.find(entry => Number(entry.id) === numericId) ?? state.editor.historyDetail ?? null;
510
+ const listItemNode = shellRefs.view
511
+ .querySelector(
512
+ ['[data-action="select-query-history-item"][data-history-id="', String(numericId), '"]'].join(''),
513
+ )
514
+ ?.closest('.query-history-item');
512
515
 
513
516
  if (historyItem && listItemNode instanceof HTMLElement) {
514
517
  replaceElementFromRenderedMarkup(
515
518
  listItemNode,
516
- renderQueryHistoryListItem(
517
- historyItem,
518
- state.editor.historyActiveId,
519
- state.editor.historySelectedId,
520
- ),
519
+ renderQueryHistoryListItem(historyItem, state.editor.historyActiveId, state.editor.historySelectedId),
521
520
  );
522
521
  }
523
522
 
@@ -1255,6 +1254,43 @@ async function executeEditorQueryAndNavigate() {
1255
1254
  router.navigate(success && activeTab === 'results' ? '/editor/results' : '/editor');
1256
1255
  }
1257
1256
 
1257
+ const OPENABLE_URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
1258
+
1259
+ function getOpenableUrl(value) {
1260
+ const text = String(value ?? '').trim();
1261
+
1262
+ if (!OPENABLE_URL_PATTERN.test(text)) {
1263
+ return null;
1264
+ }
1265
+
1266
+ try {
1267
+ const url = new URL(text);
1268
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : null;
1269
+ } catch (error) {
1270
+ return null;
1271
+ }
1272
+ }
1273
+
1274
+ function openRowEditorUrl(actionNode) {
1275
+ const field = actionNode.closest('[data-row-editor-url-field]');
1276
+ const inputValue = field?.querySelector('[data-row-editor-url-input]')?.value;
1277
+ const url = getOpenableUrl(inputValue ?? actionNode.dataset.url);
1278
+
1279
+ if (!url) {
1280
+ showToast('Field value is not a valid URL.', 'alert');
1281
+ return;
1282
+ }
1283
+
1284
+ const link = document.createElement('a');
1285
+
1286
+ link.href = url;
1287
+ link.target = '_blank';
1288
+ link.rel = 'noopener noreferrer';
1289
+ document.body.appendChild(link);
1290
+ link.click();
1291
+ link.remove();
1292
+ }
1293
+
1258
1294
  async function handleAction(actionNode) {
1259
1295
  const { action } = actionNode.dataset;
1260
1296
 
@@ -1265,6 +1301,9 @@ async function handleAction(actionNode) {
1265
1301
  case 'refresh-view':
1266
1302
  await refreshCurrentRoute();
1267
1303
  return;
1304
+ case 'open-row-editor-url':
1305
+ openRowEditorUrl(actionNode);
1306
+ return;
1268
1307
  case 'open-modal':
1269
1308
  openModal(actionNode.dataset.modal);
1270
1309
  return;
@@ -1463,12 +1502,44 @@ async function handleAction(actionNode) {
1463
1502
  router.navigate(tab === 'results' ? '/editor/results' : '/editor');
1464
1503
  return;
1465
1504
  }
1466
- case 'export-query-csv':
1467
- await exportCurrentQueryCsv();
1505
+ case 'open-query-export-modal':
1506
+ openQueryExportModal();
1468
1507
  return;
1469
- case 'export-data-csv':
1470
- await exportCurrentDataTableCsv();
1508
+ case 'export-query-format': {
1509
+ const format = actionNode.dataset.exportFormat;
1510
+
1511
+ if (format === 'table') {
1512
+ const imported = await duplicateCurrentQueryAsTable();
1513
+
1514
+ if (imported) {
1515
+ router.navigate('/table-designer/new');
1516
+ }
1517
+
1518
+ return;
1519
+ }
1520
+
1521
+ await exportCurrentQueryFormat(format);
1522
+ return;
1523
+ }
1524
+ case 'open-data-export-modal':
1525
+ openDataExportModal();
1471
1526
  return;
1527
+ case 'export-data-format': {
1528
+ const format = actionNode.dataset.exportFormat;
1529
+
1530
+ if (format === 'table') {
1531
+ const imported = await duplicateCurrentDataTableAsTable();
1532
+
1533
+ if (imported) {
1534
+ router.navigate('/table-designer/new');
1535
+ }
1536
+
1537
+ return;
1538
+ }
1539
+
1540
+ await exportCurrentDataTableFormat(format);
1541
+ return;
1542
+ }
1472
1543
  case 'toggle-data-tables':
1473
1544
  toggleDataTablesPanel();
1474
1545
  return;
@@ -1564,11 +1635,7 @@ async function handleAction(actionNode) {
1564
1635
  const currentRotation = getState().mediaTagging.workflowMediaRotationDegrees ?? 0;
1565
1636
  const command = actionNode.dataset.rotationCommand;
1566
1637
  const nextRotation =
1567
- command === 'left'
1568
- ? currentRotation - 90
1569
- : command === 'right'
1570
- ? currentRotation + 90
1571
- : 0;
1638
+ command === 'left' ? currentRotation - 90 : command === 'right' ? currentRotation + 90 : 0;
1572
1639
 
1573
1640
  syncMediaTaggingMediaRotationUi(actionNode, nextRotation);
1574
1641
  setMediaTaggingWorkflowMediaRotationDegrees(nextRotation, { notify: false });
@@ -1774,7 +1841,7 @@ document.addEventListener('input', event => {
1774
1841
  }
1775
1842
 
1776
1843
  if (bindNode.dataset.bind === 'data-search-query') {
1777
- setDataSearchQuery(bindNode.value);
1844
+ void setDataSearchQuery(bindNode.value);
1778
1845
  return;
1779
1846
  }
1780
1847
 
@@ -1883,7 +1950,12 @@ document.addEventListener('change', event => {
1883
1950
  }
1884
1951
 
1885
1952
  if (bindNode.dataset.bind === 'data-search-column') {
1886
- setDataSearchColumn(bindNode.value);
1953
+ void setDataSearchColumn(bindNode.value);
1954
+ return;
1955
+ }
1956
+
1957
+ if (bindNode.dataset.bind === 'data-filter-operator') {
1958
+ void setDataFilterOperator(bindNode.value);
1887
1959
  return;
1888
1960
  }
1889
1961
 
@@ -1,67 +1,61 @@
1
- import {
2
- escapeHtml,
3
- formatBytes,
4
- formatDateTime,
5
- truncateMiddle,
6
- } from "../utils/format.js";
7
- import { renderConnectionLogo } from "./connectionLogo.js";
1
+ import { escapeHtml, formatBytes, formatDateTime, truncateMiddle } from '../utils/format.js';
2
+ import { renderConnectionLogo } from './connectionLogo.js';
8
3
 
9
4
  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("");
5
+ return [
6
+ '<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="',
7
+ escapeHtml(connection.id),
8
+ '" type="button">',
9
+ renderConnectionLogo(connection, {
10
+ containerClass: 'flex h-8 w-8 items-center justify-center overflow-hidden bg-surface-container-highest',
11
+ imageClassName: 'h-full w-full object-cover',
12
+ iconClassName: 'text-sm text-primary-container',
13
+ }),
14
+ '<span class="min-w-0"><span class="block truncate font-mono text-xs">',
15
+ escapeHtml(connection.label),
16
+ '</span><span class="block truncate text-[10px] text-on-surface-variant/45">',
17
+ escapeHtml(truncateMiddle(connection.path, 34)),
18
+ '</span></span></button>',
19
+ ].join('');
26
20
  }
27
21
 
28
22
  function renderRecentConnections(recentConnections = []) {
29
- if (!recentConnections.length) {
30
- return `
23
+ if (!recentConnections.length) {
24
+ return `
31
25
  <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/40">
32
26
  No recent SQLite databases recorded yet.
33
27
  </div>
34
28
  `;
35
- }
29
+ }
36
30
 
37
- return `
31
+ return `
38
32
  <div class="flex flex-wrap justify-center gap-4">
39
33
  ${recentConnections
40
- .slice(0, 4)
41
- .map((connection) => renderRecentConnectionButton(connection))
42
- .join("")}
34
+ .slice(0, 4)
35
+ .map(connection => renderRecentConnectionButton(connection))
36
+ .join('')}
43
37
  </div>
44
38
  `;
45
39
  }
46
40
 
47
41
  function renderActiveConnection(activeConnection) {
48
- if (!activeConnection) {
49
- return `
42
+ if (!activeConnection) {
43
+ return `
50
44
  <p class="font-light text-lg uppercase tracking-wide text-on-surface-variant">
51
45
  No database connected
52
46
  </p>
53
47
  `;
54
- }
48
+ }
55
49
 
56
- return `
50
+ return `
57
51
  <div class="mx-auto max-w-2xl border border-outline-variant/15 bg-surface-container-low px-6 py-5 text-left">
58
52
  <div class="flex flex-wrap items-start justify-between gap-4">
59
53
  <div class="flex items-start gap-4">
60
54
  ${renderConnectionLogo(activeConnection, {
61
- containerClass:
62
- "flex h-14 w-14 items-center justify-center overflow-hidden border border-outline-variant/15 bg-surface-container-highest",
63
- imageClassName: "h-full w-full object-cover",
64
- iconClassName: "text-2xl text-primary-container",
55
+ containerClass:
56
+ 'flex h-14 w-14 items-center justify-center overflow-hidden border border-outline-variant/15 bg-surface-container-highest',
57
+ imageClassName: 'h-full w-full object-cover',
58
+ iconClassName: 'text-2xl text-primary-container',
65
59
  })}
66
60
  <div>
67
61
  <p class="text-[10px] font-mono uppercase tracking-[0.24em] text-primary-container/70">
@@ -71,14 +65,14 @@ function renderActiveConnection(activeConnection) {
71
65
  ${escapeHtml(activeConnection.label)}
72
66
  </h2>
73
67
  <p class="mt-2 font-mono text-[10px] text-on-surface-variant/55">${escapeHtml(
74
- truncateMiddle(activeConnection.path, 72)
68
+ truncateMiddle(activeConnection.path, 72),
75
69
  )}</p>
76
70
  </div>
77
71
  </div>
78
72
  <div class="text-right text-xs text-on-surface-variant/65">
79
73
  <div>${escapeHtml(formatBytes(activeConnection.sizeBytes))}</div>
80
74
  <div class="mt-1">${escapeHtml(formatDateTime(activeConnection.lastModifiedAt))}</div>
81
- <div class="mt-1">${activeConnection.readOnly ? "READ_ONLY" : "READ_WRITE"}</div>
75
+ <div class="mt-1">${activeConnection.readOnly ? 'READ_ONLY' : 'READ_WRITE'}</div>
82
76
  </div>
83
77
  </div>
84
78
  </div>
@@ -86,9 +80,9 @@ function renderActiveConnection(activeConnection) {
86
80
  }
87
81
 
88
82
  export function renderEmptyState({ activeConnection, recentConnections = [] }) {
89
- const hasActive = Boolean(activeConnection);
83
+ const hasActive = Boolean(activeConnection);
90
84
 
91
- return `
85
+ return `
92
86
  <section class="landing-view machined-grid px-6">
93
87
  <div class="landing-accent landing-accent--a"></div>
94
88
  <div class="landing-accent landing-accent--b"></div>
@@ -96,12 +90,14 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
96
90
  <div class="empty-state-shell z-10 text-center">
97
91
  <div class="mb-2">
98
92
  <span class="font-mono text-[10px] tracking-[0.3em] text-primary-container/40">
99
- SYSTEM_READY // ${hasActive ? "ACTIVE_CONTEXT" : "IDLE_STATE"}
93
+ SYSTEM_READY // ${hasActive ? 'ACTIVE_CONTEXT' : 'IDLE_STATE'}
100
94
  </span>
101
95
  </div>
102
96
  <h1 class="mb-4 font-headline text-7xl font-black tracking-tighter text-primary-container opacity-90 md:text-9xl">
103
97
  SQLite Hub
104
98
  </h1>
99
+ <p class="font-mono text-primary-container/40">The only fucking SQLite Manager you'll ever need.</p>
100
+ <br/>
105
101
  <div class="mx-auto mb-12 max-w-3xl space-y-4">
106
102
  ${renderActiveConnection(activeConnection)}
107
103
  <div class="h-[2px] w-12 bg-primary-container mx-auto"></div>
@@ -128,8 +124,8 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
128
124
  </button>
129
125
  </div>
130
126
  ${
131
- hasActive
132
- ? `
127
+ hasActive
128
+ ? `
133
129
  <div class="mx-auto mt-8 grid w-full max-w-3xl grid-cols-1 gap-4 px-6 md:grid-cols-3">
134
130
  <button
135
131
  class="standard-button"
@@ -157,7 +153,7 @@ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
157
153
  </button>
158
154
  </div>
159
155
  `
160
- : ""
156
+ : ''
161
157
  }
162
158
  <div class="mt-16 flex flex-col items-center gap-4 opacity-70 transition-opacity hover:opacity-100">
163
159
  <p class="font-mono text-[10px] tracking-widest text-on-surface-variant">RECENT_TARGETS</p>
@@ -800,6 +800,91 @@ function renderDeleteQueryHistoryForm(modal) {
800
800
  ].join("");
801
801
  }
802
802
 
803
+ function renderQueryExportPreview(lines = []) {
804
+ return lines.map((line) => `<span class="block whitespace-pre">${escapeHtml(line)}</span>`).join("");
805
+ }
806
+
807
+ function getExportOptions() {
808
+ return [
809
+ { label: "CSV", format: "csv", icon: "table_rows", meta: ".csv", preview: ["id,name", "1,Acme"] },
810
+ { label: "TSV", format: "tsv", icon: "view_column", meta: ".tsv", preview: ["id\tname", "1\tAcme"] },
811
+ {
812
+ label: "Markdown",
813
+ format: "md",
814
+ icon: "article",
815
+ meta: ".md",
816
+ preview: ["| id | name |", "| --- | --- |"],
817
+ },
818
+ {
819
+ label: "Duplicate",
820
+ format: "table",
821
+ icon: "table_chart",
822
+ meta: "as table",
823
+ preview: ["columns inferred", "rows prefilled"],
824
+ },
825
+ ];
826
+ }
827
+
828
+ function renderTextExportModal(modal, action) {
829
+ const disabledAttribute = modal.submitting ? 'disabled aria-disabled="true"' : "";
830
+
831
+ return `
832
+ <div class="space-y-5">
833
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
834
+ ${getExportOptions()
835
+ .map(
836
+ (option) => `
837
+ <button
838
+ class="group flex min-h-36 flex-col justify-between border border-outline-variant/20 bg-surface-container-low px-5 py-5 text-left text-on-surface transition-colors hover:border-primary-container/45 hover:bg-surface-container-high focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary-container disabled:cursor-wait disabled:opacity-60"
839
+ data-action="${escapeHtml(action)}"
840
+ data-export-format="${escapeHtml(option.format)}"
841
+ type="button"
842
+ ${disabledAttribute}
843
+ >
844
+ <span class="flex w-full items-start justify-between gap-4">
845
+ <span class="flex min-w-0 items-center gap-3">
846
+ <span class="flex h-10 w-10 shrink-0 items-center justify-center border border-outline-variant/20 bg-surface-container-highest text-primary-container group-hover:border-primary-container/35">
847
+ <span class="material-symbols-outlined text-xl">${escapeHtml(option.icon)}</span>
848
+ </span>
849
+ <span class="min-w-0">
850
+ <span class="block truncate font-headline text-lg font-black uppercase tracking-normal text-primary-container">
851
+ ${escapeHtml(option.label)}
852
+ </span>
853
+ <span class="mt-1 block font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
854
+ ${escapeHtml(option.meta)}
855
+ </span>
856
+ </span>
857
+ </span>
858
+ <span class="material-symbols-outlined mt-1 text-base text-on-surface-variant/35 group-hover:text-primary-container">
859
+ arrow_forward
860
+ </span>
861
+ </span>
862
+ <span class="mt-5 block w-full overflow-hidden border border-outline-variant/10 bg-surface-container-lowest px-3 py-3 font-mono text-[11px] leading-5 text-on-surface-variant/70">
863
+ ${renderQueryExportPreview(option.preview)}
864
+ </span>
865
+ </button>
866
+ `,
867
+ )
868
+ .join("")}
869
+ </div>
870
+ ${renderError(modal.error)}
871
+ <div class="flex justify-end">
872
+ <button class="standard-button" data-action="close-modal" type="button">
873
+ Cancel
874
+ </button>
875
+ </div>
876
+ </div>
877
+ `;
878
+ }
879
+
880
+ function renderQueryExportModal(modal) {
881
+ return renderTextExportModal(modal, "export-query-format");
882
+ }
883
+
884
+ function renderDataExportModal(modal) {
885
+ return renderTextExportModal(modal, "export-data-format");
886
+ }
887
+
803
888
  function renderCreateMediaTaggingMappingTableForm(modal, state) {
804
889
  const mappingExists = hasDefaultMediaTaggingMappingTable(state.mediaTagging.schemaTables ?? []);
805
890
  const readOnly = Boolean(state.mediaTagging.connection?.readOnly);
@@ -981,6 +1066,16 @@ export function renderModal(state) {
981
1066
  title: "Delete Query",
982
1067
  body: renderDeleteQueryHistoryForm(modal),
983
1068
  },
1069
+ "query-export": {
1070
+ eyebrow: "SQL Editor // Export query result",
1071
+ title: "Export Query",
1072
+ body: renderQueryExportModal(modal),
1073
+ },
1074
+ "data-export": {
1075
+ eyebrow: "Data Browser // Export table data",
1076
+ title: "Export Table",
1077
+ body: renderDataExportModal(modal),
1078
+ },
984
1079
  "create-media-tagging-tag-table": {
985
1080
  eyebrow: "Media Tagging // Create default tag table",
986
1081
  title: "Create Tag Table",
@@ -81,10 +81,11 @@ export function renderQueryEditor({
81
81
  </button>
82
82
  <button
83
83
  class="${secondaryButtonClass}"
84
- data-action="export-query-csv"
84
+ data-action="open-query-export-modal"
85
85
  type="button"
86
86
  >
87
- ${exporting ? "Exporting..." : "Export CSV"}
87
+ <span class="material-symbols-outlined text-sm">download</span>
88
+ ${exporting ? "Exporting..." : "Export"}
88
89
  </button>
89
90
  <button
90
91
  class="signature-button"