sqlite-hub 1.0.0 → 1.1.1

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 (47) hide show
  1. package/README.md +47 -33
  2. package/bin/sqlite-hub.js +127 -47
  3. package/frontend/js/api.js +6 -0
  4. package/frontend/js/app.js +229 -44
  5. package/frontend/js/components/modal.js +17 -1
  6. package/frontend/js/components/queryChartRenderer.js +28 -3
  7. package/frontend/js/components/queryEditor.js +24 -26
  8. package/frontend/js/components/queryHistoryDetail.js +1 -1
  9. package/frontend/js/components/queryHistoryHeader.js +48 -0
  10. package/frontend/js/components/queryHistoryList.js +132 -0
  11. package/frontend/js/components/queryHistoryPanel.js +72 -136
  12. package/frontend/js/components/structureGraph.js +700 -89
  13. package/frontend/js/components/tableDesignerEditor.js +26 -47
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/store.js +97 -8
  17. package/frontend/js/utils/exportFilenames.js +3 -1
  18. package/frontend/js/views/charts.js +326 -174
  19. package/frontend/js/views/connections.js +59 -64
  20. package/frontend/js/views/data.js +48 -54
  21. package/frontend/js/views/documents.js +62 -23
  22. package/frontend/js/views/editor.js +0 -2
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/settings.js +78 -0
  25. package/frontend/js/views/structure.js +48 -32
  26. package/frontend/js/views/tableDesigner.js +45 -1
  27. package/frontend/styles/components.css +259 -54
  28. package/frontend/styles/structure-graph.css +150 -37
  29. package/frontend/styles/tailwind.generated.css +1 -1
  30. package/frontend/styles/tokens.css +2 -0
  31. package/frontend/styles/views.css +75 -38
  32. package/package.json +3 -2
  33. package/server/routes/export.js +89 -22
  34. package/server/routes/externalApi.js +84 -2
  35. package/server/routes/settings.js +37 -28
  36. package/server/services/appInfoService.js +215 -0
  37. package/server/services/databaseCommandService.js +50 -6
  38. package/server/services/sqlite/dataBrowserService.js +11 -3
  39. package/server/services/sqlite/exportService.js +307 -22
  40. package/tests/api-token-auth.test.js +110 -1
  41. package/tests/cli-args.test.js +16 -3
  42. package/tests/database-command-service.test.js +40 -3
  43. package/tests/export-blob.test.js +54 -1
  44. package/tests/export-filenames.test.js +4 -0
  45. package/tests/settings-api-tokens-route.test.js +22 -1
  46. package/tests/settings-metadata.test.js +99 -1
  47. package/tests/settings-view.test.js +28 -0
@@ -1,39 +1,36 @@
1
- import { renderConnectionCard } from "../components/connectionCard.js";
2
- import { renderPageHeader } from "../components/pageHeader.js";
3
- import { escapeHtml } from "../utils/format.js";
1
+ import { renderConnectionCard } from '../components/connectionCard.js';
2
+ import { renderPageHeader } from '../components/pageHeader.js';
3
+ import { escapeHtml } from '../utils/format.js';
4
4
 
5
5
  function renderConnectionsActionButton({
6
- label,
7
- icon,
8
- action = "open-modal",
9
- modal,
10
- tone = "secondary",
11
- disabled = false,
6
+ label,
7
+ icon,
8
+ action = 'open-modal',
9
+ modal,
10
+ tone = 'secondary',
11
+ disabled = false,
12
12
  }) {
13
- const toneClassName =
14
- tone === "primary"
15
- ? "signature-button"
16
- : "standard-button";
17
- const iconClassName = "";
18
- const modalAttribute = modal ? `data-modal="${modal}"` : "";
13
+ const toneClassName = tone === 'primary' ? 'signature-button' : 'standard-button';
14
+ const iconClassName = '';
15
+ const modalAttribute = modal ? `data-modal="${modal}"` : '';
19
16
 
20
- return `
17
+ return `
21
18
  <button
22
19
  class="${toneClassName}"
23
20
  data-action="${escapeHtml(action)}"
24
21
  ${modalAttribute}
25
22
  type="button"
26
- ${disabled ? "disabled" : ""}
23
+ ${disabled ? 'disabled' : ''}
27
24
  >
28
25
  <span>${label}</span>
29
- <span class="material-symbols-outlined${iconClassName ? ` ${iconClassName}` : ""}">${icon}</span>
26
+ <span class="material-symbols-outlined${iconClassName ? ` ${iconClassName}` : ''}">${icon}</span>
30
27
  </button>
31
28
  `;
32
29
  }
33
30
 
34
31
  function renderConnectionsBody(state) {
35
- if (state.connections.loading && !state.connections.recent.length) {
36
- return `
32
+ if (state.connections.loading && !state.connections.recent.length) {
33
+ return `
37
34
  <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
38
35
  <div class="text-center text-on-surface-variant/40">
39
36
  <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
@@ -41,10 +38,10 @@ function renderConnectionsBody(state) {
41
38
  </div>
42
39
  </div>
43
40
  `;
44
- }
41
+ }
45
42
 
46
- if (state.connections.error) {
47
- return `
43
+ if (state.connections.error) {
44
+ return `
48
45
  <div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
49
46
  <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
50
47
  ${escapeHtml(state.connections.error.code)}
@@ -52,10 +49,10 @@ function renderConnectionsBody(state) {
52
49
  <div class="mt-2">${escapeHtml(state.connections.error.message)}</div>
53
50
  </div>
54
51
  `;
55
- }
52
+ }
56
53
 
57
- if (!state.connections.recent.length) {
58
- return `
54
+ if (!state.connections.recent.length) {
55
+ return `
59
56
  <div class="border border-dashed border-outline-variant/20 bg-surface-container-low px-8 py-10 text-center">
60
57
  <span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">database_off</span>
61
58
  <p class="font-headline text-xl font-black uppercase tracking-tight text-primary-container">
@@ -66,71 +63,69 @@ function renderConnectionsBody(state) {
66
63
  </p>
67
64
  <div class="mt-6 flex flex-wrap items-center justify-center gap-3">
68
65
  ${renderConnectionsActionButton({
69
- label: "Open Database",
70
- icon: "folder_open",
71
- modal: "open-connection",
72
- tone: "primary",
66
+ label: 'Create Database',
67
+ icon: 'note_add',
68
+ modal: 'create-connection',
73
69
  })}
74
70
  ${renderConnectionsActionButton({
75
- label: "Create Database",
76
- icon: "note_add",
77
- modal: "create-connection",
71
+ label: 'Open Database',
72
+ icon: 'folder_open',
73
+ modal: 'open-connection',
74
+ tone: 'primary',
78
75
  })}
79
76
  </div>
80
77
  </div>
81
78
  `;
82
- }
79
+ }
83
80
 
84
- return `
81
+ return `
85
82
  <div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
86
83
  ${state.connections.recent
87
- .map((connection) =>
88
- renderConnectionCard(connection, state.connections.active?.id)
89
- )
90
- .join("")}
84
+ .map(connection => renderConnectionCard(connection, state.connections.active?.id))
85
+ .join('')}
91
86
  </div>
92
87
  `;
93
88
  }
94
89
 
95
90
  export function renderConnectionsView(state) {
96
- const actions = `
91
+ const actions = `
92
+ ${
93
+ state.connections.active
94
+ ? renderConnectionsActionButton({
95
+ label: state.connections.backupLoading ? 'Creating Backup...' : 'Create Backup',
96
+ icon: 'inventory_2',
97
+ action: 'create-backup',
98
+ disabled: state.connections.backupLoading,
99
+ })
100
+ : ''
101
+ }
97
102
  ${renderConnectionsActionButton({
98
- label: "Open Database",
99
- icon: "folder_open",
100
- modal: "open-connection",
101
- tone: "primary",
103
+ label: 'Create Database',
104
+ icon: 'note_add',
105
+ modal: 'create-connection',
102
106
  })}
103
107
  ${renderConnectionsActionButton({
104
- label: "Create Database",
105
- icon: "note_add",
106
- modal: "create-connection",
108
+ label: 'Open Database',
109
+ icon: 'folder_open',
110
+ modal: 'open-connection',
111
+ tone: 'primary',
107
112
  })}
108
- ${
109
- state.connections.active
110
- ? renderConnectionsActionButton({
111
- label: state.connections.backupLoading ? "Creating Backup..." : "Create Backup",
112
- icon: "inventory_2",
113
- action: "create-backup",
114
- disabled: state.connections.backupLoading,
115
- })
116
- : ""
117
- }
118
113
  `;
119
114
 
120
- return {
121
- main: `
115
+ return {
116
+ main: `
122
117
  <section class="view-surface relative min-h-full overflow-hidden">
123
118
  <div class="data-grid-texture pointer-events-none absolute inset-0"></div>
124
119
  <div class="view-frame relative z-10">
125
120
  ${renderPageHeader({
126
- title: "Connections",
127
- subtitle: `Registry // Recent_Targets: ${String(state.connections.recent.length).padStart(2, "0")}`,
128
- actions,
121
+ title: 'Connections',
122
+ subtitle: `Registry // Recent_Targets: ${String(state.connections.recent.length).padStart(2, '0')}`,
123
+ actions,
129
124
  })}
130
125
  ${renderConnectionsBody(state)}
131
126
  </div>
132
127
  </section>
133
128
  `,
134
- panel: "",
135
- };
129
+ panel: '',
130
+ };
136
131
  }
@@ -78,31 +78,32 @@ function renderTableList(state) {
78
78
  }
79
79
 
80
80
  return `
81
- <div class="custom-scrollbar flex-1 overflow-auto px-4 py-4">
82
- <div class="space-y-2">
83
- ${filteredTables
84
- .map(
85
- table => `
86
- <button
87
- class="w-full border px-4 py-3 text-left transition-colors ${
88
- table.name === activeName
89
- ? 'border-primary-container/30 bg-surface-container-high'
90
- : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
91
- }"
92
- data-action="navigate"
93
- data-to="/data/${encodeURIComponent(table.name)}"
94
- type="button"
95
- >
96
- <div class="truncate font-mono text-xs ${
97
- table.name === activeName ? 'text-primary-container' : 'text-on-surface'
98
- }">
99
- ${escapeHtml(table.name)}
100
- </div>
101
- </button>
102
- `,
103
- )
104
- .join('')}
105
- </div>
81
+ <div class="subnavi-list custom-scrollbar">
82
+ ${filteredTables
83
+ .map(
84
+ table => `
85
+ <button
86
+ class="table-designer-sidebar__item subnavi-item border px-4 py-3 text-left transition-colors ${
87
+ table.name === activeName
88
+ ? 'is-active border-primary-container/30 bg-surface-container-high'
89
+ : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
90
+ }"
91
+ data-action="navigate"
92
+ data-to="/data/${encodeURIComponent(table.name)}"
93
+ type="button"
94
+ >
95
+ <div class="table-designer-sidebar__item-name ${table.name === activeName ? 'is-active' : ''}">
96
+ ${escapeHtml(table.name)}
97
+ </div>
98
+ <div class="mt-1 truncate text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
99
+ ${escapeHtml(formatNumber(table.columnCount ?? 0))} column${
100
+ Number(table.columnCount ?? 0) === 1 ? '' : 's'
101
+ }
102
+ </div>
103
+ </button>
104
+ `,
105
+ )
106
+ .join('')}
106
107
  </div>
107
108
  `;
108
109
  }
@@ -112,30 +113,14 @@ function renderWorkspaceHeader(state) {
112
113
  const tablesVisible = state.dataBrowser.tablesVisible !== false;
113
114
 
114
115
  return `
115
- <header class="border-b border-outline-variant/10 bg-surface-container px-6 py-5">
116
- <div class="flex flex-wrap items-end justify-between gap-4">
117
- <div class="data-headline-container">
118
- <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
119
- Data Browser
120
- </div>
121
- <h1 class="mt-2 font-headline text-4xl font-black uppercase tracking-tight text-primary-container">
122
- ${escapeHtml(table?.name ?? 'Table Data')}
123
- </h1>
124
- <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
125
- ${
126
- table
127
- ? `rows ${escapeHtml(formatNumber(table.rowCount ?? 0))} // columns ${escapeHtml(
128
- formatNumber(table.columns?.length ?? 0),
129
- )}`
130
- : `tables ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}`
131
- }
132
- </div>
133
- </div>
116
+ <header class="workspace-header">
117
+ <div class="flex flex-wrap items-center justify-between gap-4">
134
118
  <div class="flex items-center gap-3">
135
119
  ${
136
120
  table
137
121
  ? `<button
138
- class="standard-button"
122
+ class="standard-button panel-toggle-button ${tablesVisible ? '' : 'is-active'}"
123
+ aria-pressed="${tablesVisible ? 'false' : 'true'}"
139
124
  data-action="toggle-data-tables"
140
125
  type="button"
141
126
  >
@@ -145,7 +130,14 @@ function renderWorkspaceHeader(state) {
145
130
  : '<span class="material-symbols-outlined text-sm">visibility</span> Show Tables'
146
131
  }
147
132
  </button>
148
- <button
133
+ `
134
+ : ''
135
+ }
136
+ </div>
137
+ <div class="flex flex-wrap items-center justify-end gap-3">
138
+ ${
139
+ table
140
+ ? `<button
149
141
  class="standard-button"
150
142
  data-action="open-data-export-modal"
151
143
  type="button"
@@ -476,6 +468,8 @@ function renderTableSurface(state) {
476
468
  escapeHtml(formatNumber(totalRows)),
477
469
  filteredRowsText,
478
470
  ' rows',
471
+ ' // columns ',
472
+ escapeHtml(formatNumber(table.columns?.length ?? 0)),
479
473
  '</div>',
480
474
  '<div class="flex flex-wrap items-center gap-4"><div class="flex items-center gap-2">',
481
475
  '<span class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">rows</span>',
@@ -639,17 +633,17 @@ export function renderDataView(state) {
639
633
  return {
640
634
  main: `
641
635
  <section class="view-surface min-h-full bg-surface-container flex h-full min-h-0 flex-col overflow-hidden">
642
- <div class="grid h-full min-h-0 grid-cols-1 ${tablesVisible ? 'md:grid-cols-[18rem_minmax(0,1fr)]' : ''}">
636
+ <div class="data-view-grid ${tablesVisible ? 'data-view-grid--with-subnavi' : ''}">
643
637
  ${
644
638
  tablesVisible
645
639
  ? `
646
- <aside class="flex min-h-0 flex-col border-r border-outline-variant/10 bg-surface-low">
647
- <div class="border-b border-outline-variant/10 px-6 py-5">
648
- <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
649
- Tables
650
- </div>
651
- <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
652
- total ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}
640
+ <aside class="data-view__sidebar subnavi-panel">
641
+ <div class="subnavi-header">
642
+ <div>
643
+ <div class="subnavi-header-title">Tables</div>
644
+ <div class="subnavi-header-details">
645
+ total ${escapeHtml(formatNumber(state.dataBrowser.tables.length))}
646
+ </div>
653
647
  </div>
654
648
  </div>
655
649
  ${renderDataTableSearch(state)}
@@ -24,7 +24,7 @@ function renderDocumentListItem(item, selectedId) {
24
24
 
25
25
  return `
26
26
  <button
27
- class="documents-list-item ${isSelected ? 'is-selected' : ''}"
27
+ class="documents-list-item subnavi-item ${isSelected ? 'is-selected' : ''}"
28
28
  data-action="select-document"
29
29
  data-document-id="${escapeHtml(item.id)}"
30
30
  type="button"
@@ -42,33 +42,58 @@ function renderDocumentListItem(item, selectedId) {
42
42
  `;
43
43
  }
44
44
 
45
+ function getFilteredDocuments(items, searchQuery) {
46
+ const normalizedSearch = String(searchQuery ?? '')
47
+ .trim()
48
+ .toLowerCase();
49
+
50
+ if (!normalizedSearch) {
51
+ return items;
52
+ }
53
+
54
+ return items.filter(item =>
55
+ [item.filename, item.title]
56
+ .filter(Boolean)
57
+ .some(value => String(value).toLowerCase().includes(normalizedSearch)),
58
+ );
59
+ }
60
+
61
+ function renderDocumentsSearch(documents) {
62
+ return `
63
+ <label class="table-designer-sidebar__search">
64
+ <span class="material-symbols-outlined text-sm text-on-surface-variant/55">search</span>
65
+ <input
66
+ class="table-designer-sidebar__search-input"
67
+ data-bind="documents-search"
68
+ placeholder="Search documents..."
69
+ spellcheck="false"
70
+ type="search"
71
+ value="${escapeHtml(documents.searchQuery ?? '')}"
72
+ />
73
+ </label>
74
+ `;
75
+ }
76
+
45
77
  function renderDocumentsSidebar(documents) {
46
78
  const items = documents.items ?? [];
79
+ const filteredItems = getFilteredDocuments(items, documents.searchQuery);
47
80
 
48
81
  return `
49
- <aside class="documents-view__sidebar">
50
- <div class="documents-view__sidebar-header">
82
+ <aside class="documents-view__sidebar subnavi-panel">
83
+ <div class="documents-view__sidebar-header subnavi-header">
51
84
  <div>
52
- <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/50">
53
- Documents
54
- </div>
55
- <div class="mt-1 font-mono text-xs text-primary-container">${formatNumber(items.length)} files</div>
85
+ <div class="subnavi-header-title">Documents</div>
86
+ <div class="subnavi-header-details">${formatNumber(items.length)} files</div>
56
87
  </div>
57
- <button
58
- class="icon-button"
59
- data-action="create-document"
60
- title="New document"
61
- type="button"
62
- ${documents.saving ? 'disabled aria-disabled="true"' : ''}
63
- >
64
- <span class="material-symbols-outlined">add</span>
65
- </button>
66
88
  </div>
67
- <div class="documents-view__sidebar-body custom-scrollbar">
89
+ ${renderDocumentsSearch(documents)}
90
+ <div class="documents-view__sidebar-body subnavi-list custom-scrollbar">
68
91
  ${
69
- items.length
70
- ? items.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
71
- : `<div class="documents-list-empty">No documents yet</div>`
92
+ filteredItems.length
93
+ ? filteredItems.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
94
+ : `<div class="documents-list-empty">${
95
+ items.length ? 'No documents match the current search.' : 'No documents yet'
96
+ }</div>`
72
97
  }
73
98
  </div>
74
99
  </aside>
@@ -78,7 +103,9 @@ function renderDocumentsSidebar(documents) {
78
103
  function renderDocumentToolbar(documents) {
79
104
  const disabled = documents.saving || documents.detailLoading || !documents.selectedId;
80
105
 
81
- return ` <label class="documents-filename-field">
106
+ return `
107
+ <div class="documents-titlebar">
108
+ <label class="documents-filename-field">
82
109
  <input
83
110
  class="control-input"
84
111
  data-bind="document-field"
@@ -90,13 +117,24 @@ function renderDocumentToolbar(documents) {
90
117
  ${!documents.selectedId ? 'disabled' : ''}
91
118
  />
92
119
  </label>
120
+ <button
121
+ class="signature-button documents-create-button"
122
+ data-action="create-document"
123
+ type="button"
124
+ ${documents.saving ? 'disabled aria-disabled="true"' : ''}
125
+ >
126
+ <span class="material-symbols-outlined">add</span>
127
+ New Document
128
+ </button>
129
+ </div>
93
130
  <div class="documents-toolbar">
94
131
 
95
132
 
96
133
 
97
134
  <div class="documents-toolbar__actions">
98
135
  <button
99
- class="standard-button"
136
+ class="standard-button panel-toggle-button ${documents.editorVisible ? '' : 'is-active'}"
137
+ aria-pressed="${documents.editorVisible ? 'false' : 'true'}"
100
138
  data-action="toggle-document-pane"
101
139
  data-pane="editor"
102
140
  type="button"
@@ -105,7 +143,8 @@ function renderDocumentToolbar(documents) {
105
143
  ${documents.editorVisible ? 'Hide Editor' : 'Show Editor'}
106
144
  </button>
107
145
  <button
108
- class="standard-button"
146
+ class="standard-button panel-toggle-button ${documents.previewVisible ? '' : 'is-active'}"
147
+ aria-pressed="${documents.previewVisible ? 'false' : 'true'}"
109
148
  data-action="toggle-document-pane"
110
149
  data-pane="preview"
111
150
  type="button"
@@ -307,8 +307,6 @@ export function renderEditorView(state, { isResultsRoute = false } = {}) {
307
307
  query: state.editor.sqlText,
308
308
  executing: state.editor.executing,
309
309
  exporting: state.editor.exportLoading,
310
- historyLoading: state.editor.historyLoading,
311
- historyTotal: state.editor.historyTotal,
312
310
  editorVisible: state.editor.editorPanelVisible,
313
311
  historyVisible: state.editor.historyPanelVisible,
314
312
  })}
@@ -396,8 +396,8 @@ function renderPreviewMedia(
396
396
 
397
397
  const metadata = getWorkflowMetadataEntries(currentItem);
398
398
  const toggleLabel = detailsVisible
399
- ? '<span class="material-symbols-outlined">visibility_off</span> Shrink Media Viewer'
400
- : 'Show Media Viewer';
399
+ ? '<span class="material-symbols-outlined">visibility_off</span> Hide Viewer'
400
+ : '<span class="material-symbols-outlined">visibility</span> Show Viewer';
401
401
 
402
402
  return `
403
403
  <div class="media-tagging-preview ${detailsVisible ? '' : 'media-tagging-preview--meta-hidden'}">
@@ -450,11 +450,12 @@ function renderPreviewMedia(
450
450
  : ''
451
451
  }
452
452
  <button
453
- class="standard-button media-tagging-preview__toggle"
453
+ class="standard-button panel-toggle-button media-tagging-preview__toggle ${detailsVisible ? '' : 'is-active'}"
454
+ aria-pressed="${detailsVisible ? 'false' : 'true'}"
454
455
  data-action="toggle-media-tagging-current-media"
455
456
  data-next-value="${detailsVisible ? 'false' : 'true'}"
456
- data-expanded-label="Shrink Media Viewer"
457
- data-collapsed-label="Show Media Viewer"
457
+ data-expanded-label="Hide Viewer"
458
+ data-collapsed-label="Show Viewer"
458
459
  aria-expanded="${detailsVisible ? 'true' : 'false'}"
459
460
  type="button"
460
461
  >
@@ -30,6 +30,66 @@ function renderSettingsNavigation(activeSection) {
30
30
  `;
31
31
  }
32
32
 
33
+ function renderVersionCheckStatus(settings) {
34
+ const result = settings.versionCheck;
35
+ const error = settings.versionCheckError;
36
+
37
+ if (error) {
38
+ return `
39
+ <div class="border border-error/20 bg-error-container/10 px-4 py-3 text-sm text-on-surface">
40
+ <div class="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-error">
41
+ ${escapeHtml(error.code ?? 'VERSION_CHECK_FAILED')}
42
+ </div>
43
+ <div class="mt-1 text-on-surface-variant">${escapeHtml(error.message ?? 'Version check failed.')}</div>
44
+ </div>
45
+ `;
46
+ }
47
+
48
+ if (!result) {
49
+ return `
50
+ <div class="border border-outline-variant/10 bg-surface-container-high px-4 py-3 text-sm text-on-surface-variant">
51
+ No version check has been run yet.
52
+ </div>
53
+ `;
54
+ }
55
+
56
+ const updateAvailable = Boolean(result.updateAvailable);
57
+ const releaseLink =
58
+ updateAvailable && result.releaseUrl
59
+ ? `
60
+ <a
61
+ class="standard-button"
62
+ href="${escapeHtml(result.releaseUrl)}"
63
+ rel="noreferrer"
64
+ target="_blank"
65
+ >
66
+ <span class="material-symbols-outlined text-sm">open_in_new</span>
67
+ Open Release
68
+ </a>
69
+ `
70
+ : '';
71
+
72
+ return `
73
+ <div class="border ${
74
+ updateAvailable ? 'border-primary-container/30 bg-primary-container/5' : 'border-outline-variant/10 bg-surface-container-high'
75
+ } px-4 py-3">
76
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
77
+ <div class="min-w-0">
78
+ <div class="font-mono text-[10px] font-bold uppercase tracking-[0.18em] ${
79
+ updateAvailable ? 'text-primary-container' : 'text-on-surface-variant/70'
80
+ }">
81
+ ${updateAvailable ? 'New Version Available' : 'Up To Date'}
82
+ </div>
83
+ <div class="mt-1 text-sm text-on-surface-variant">
84
+ Current v${escapeHtml(result.currentVersion ?? '0.0.0')} · Latest v${escapeHtml(result.latestVersion ?? 'unknown')}
85
+ </div>
86
+ </div>
87
+ ${releaseLink}
88
+ </div>
89
+ </div>
90
+ `;
91
+ }
92
+
33
93
  function renderSettingsContent(state) {
34
94
  if (state.settings.loading && !state.settings.appVersion) {
35
95
  return `
@@ -56,6 +116,7 @@ function renderSettingsContent(state) {
56
116
  const activeSection = state.settings.section === 'api-tokens' ? 'api-tokens' : 'information';
57
117
  const appVersion = escapeHtml(state.settings.appVersion ?? '0.0.0');
58
118
  const sqliteVersion = escapeHtml(state.settings.sqliteVersion ?? 'unknown');
119
+ const versionCheckLoading = Boolean(state.settings.versionCheckLoading);
59
120
  const tokenDatabase = state.settings.tokenDatabase;
60
121
  const apiTokens = state.settings.apiTokens ?? [];
61
122
  const createdApiToken = state.settings.createdApiToken;
@@ -171,6 +232,23 @@ function renderSettingsContent(state) {
171
232
  <div class="border-t border-outline-variant/10 py-4 text-sm leading-6 text-on-surface-variant">
172
233
  SQL functions and syntax are evaluated with this SQLite runtime.
173
234
  </div>
235
+ <div class="border-t border-outline-variant/10 pt-4">
236
+ <div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
237
+ <div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
238
+ Version_Check
239
+ </div>
240
+ <button
241
+ class="standard-button"
242
+ data-action="check-app-version"
243
+ type="button"
244
+ ${versionCheckLoading ? 'disabled aria-disabled="true"' : ''}
245
+ >
246
+ <span class="material-symbols-outlined text-sm">${versionCheckLoading ? 'progress_activity' : 'sync'}</span>
247
+ ${versionCheckLoading ? 'Checking...' : 'Check Updates'}
248
+ </button>
249
+ </div>
250
+ ${renderVersionCheckStatus(state.settings)}
251
+ </div>
174
252
  </div>
175
253
  </section>
176
254