sqlite-hub 0.1.3

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 (76) hide show
  1. package/.npmingnore +4 -0
  2. package/README.md +46 -0
  3. package/assets/images/logo.webp +0 -0
  4. package/assets/images/logo_extrasmall.webp +0 -0
  5. package/assets/images/logo_raw.png +0 -0
  6. package/assets/images/logo_small.webp +0 -0
  7. package/assets/mockups/connections.png +0 -0
  8. package/assets/mockups/data.png +0 -0
  9. package/assets/mockups/data_edit.png +0 -0
  10. package/assets/mockups/home.png +0 -0
  11. package/assets/mockups/overview.png +0 -0
  12. package/assets/mockups/sql_editor.png +0 -0
  13. package/assets/mockups/structure.png +0 -0
  14. package/bin/sqlite-hub.js +116 -0
  15. package/changelog.md +3 -0
  16. package/data/.gitkeep +0 -0
  17. package/index.html +100 -0
  18. package/js/api.js +193 -0
  19. package/js/app.js +520 -0
  20. package/js/components/actionBar.js +8 -0
  21. package/js/components/appShell.js +17 -0
  22. package/js/components/badges.js +5 -0
  23. package/js/components/bottomTabs.js +37 -0
  24. package/js/components/connectionCard.js +106 -0
  25. package/js/components/dataGrid.js +47 -0
  26. package/js/components/emptyState.js +159 -0
  27. package/js/components/metricCard.js +32 -0
  28. package/js/components/modal.js +317 -0
  29. package/js/components/pageHeader.js +33 -0
  30. package/js/components/queryEditor.js +121 -0
  31. package/js/components/queryResults.js +107 -0
  32. package/js/components/rowEditorPanel.js +164 -0
  33. package/js/components/sidebar.js +57 -0
  34. package/js/components/statusBar.js +39 -0
  35. package/js/components/toast.js +39 -0
  36. package/js/components/topNav.js +27 -0
  37. package/js/router.js +66 -0
  38. package/js/store.js +1092 -0
  39. package/js/utils/format.js +179 -0
  40. package/js/views/connections.js +133 -0
  41. package/js/views/data.js +400 -0
  42. package/js/views/editor.js +259 -0
  43. package/js/views/landing.js +11 -0
  44. package/js/views/overview.js +220 -0
  45. package/js/views/settings.js +109 -0
  46. package/js/views/structure.js +242 -0
  47. package/package.json +18 -0
  48. package/publish_brew.sh +444 -0
  49. package/publish_npm.sh +241 -0
  50. package/server/routes/connections.js +146 -0
  51. package/server/routes/data.js +59 -0
  52. package/server/routes/export.js +25 -0
  53. package/server/routes/overview.js +39 -0
  54. package/server/routes/settings.js +50 -0
  55. package/server/routes/sql.js +50 -0
  56. package/server/routes/structure.js +38 -0
  57. package/server/server.js +136 -0
  58. package/server/services/sqlite/connectionManager.js +306 -0
  59. package/server/services/sqlite/dataBrowserService.js +255 -0
  60. package/server/services/sqlite/exportService.js +34 -0
  61. package/server/services/sqlite/importService.js +111 -0
  62. package/server/services/sqlite/introspection.js +302 -0
  63. package/server/services/sqlite/overviewService.js +109 -0
  64. package/server/services/sqlite/sqlExecutor.js +434 -0
  65. package/server/services/sqlite/structureService.js +60 -0
  66. package/server/services/storage/appStateStore.js +530 -0
  67. package/server/utils/csv.js +34 -0
  68. package/server/utils/errors.js +175 -0
  69. package/server/utils/fileValidation.js +135 -0
  70. package/server/utils/identifier.js +38 -0
  71. package/server/utils/sqliteTypes.js +112 -0
  72. package/styles/base.css +176 -0
  73. package/styles/components.css +323 -0
  74. package/styles/layout.css +101 -0
  75. package/styles/tokens.css +49 -0
  76. package/styles/views.css +84 -0
@@ -0,0 +1,121 @@
1
+ import { escapeHtml, highlightSql } from "../utils/format.js";
2
+ import { renderActionBar } from "./actionBar.js";
3
+
4
+ function renderLineNumbers(query) {
5
+ const lineCount = Math.max(1, String(query || "").split("\n").length);
6
+
7
+ return Array.from({ length: lineCount }, (_, index) => index + 1)
8
+ .map((value) => `<span>${String(value).padStart(2, "0")}</span>`)
9
+ .join("");
10
+ }
11
+
12
+ function renderHistoryOptions(history) {
13
+ if (!history.length) {
14
+ return '<option value="">No recent statements</option>';
15
+ }
16
+
17
+ return [
18
+ '<option value="">Load recent statement...</option>',
19
+ ...history.map(
20
+ (entry) => `
21
+ <option value="${escapeHtml(entry.id)}">
22
+ ${escapeHtml(entry.sql.replace(/\s+/g, " ").slice(0, 96))}
23
+ </option>
24
+ `
25
+ ),
26
+ ].join("");
27
+ }
28
+
29
+ function renderHighlightedQuery(query) {
30
+ if (query) {
31
+ return highlightSql(query);
32
+ }
33
+
34
+ return '<span class="text-on-surface-variant/35">SELECT name FROM sqlite_master WHERE type = \'table\';</span>';
35
+ }
36
+
37
+ export function renderQueryEditor({
38
+ query,
39
+ title,
40
+ executing = false,
41
+ history = [],
42
+ historyLoading = false,
43
+ }) {
44
+ const left = `
45
+ <div class="flex items-center gap-2 bg-surface-container-lowest px-3 py-1">
46
+ <span class="material-symbols-outlined text-xs text-[#FCE300]">database</span>
47
+ <span class="text-[10px] font-mono uppercase tracking-widest text-on-surface-variant">${escapeHtml(
48
+ title
49
+ )}</span>
50
+ </div>
51
+ <div class="hidden items-center gap-2 text-[10px] font-mono uppercase tracking-widest text-on-surface-variant/40 md:flex">
52
+ <span class="material-symbols-outlined text-xs">history</span>
53
+ ${historyLoading ? "Loading history..." : `${history.length} statements in history`}
54
+ </div>
55
+ `;
56
+
57
+ const right = `
58
+ <select
59
+ class="min-w-[220px] border border-outline-variant/20 bg-surface-container-lowest px-3 py-2 text-[10px] font-mono uppercase tracking-[0.14em] text-on-surface-variant outline-none"
60
+ data-bind="history-entry"
61
+ >
62
+ ${renderHistoryOptions(history)}
63
+ </select>
64
+ <button
65
+ class="px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest text-on-surface hover:bg-surface-container-highest transition-colors"
66
+ data-action="clear-sql-history"
67
+ type="button"
68
+ >
69
+ Clear History
70
+ </button>
71
+ <button
72
+ class="px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest text-on-surface hover:bg-surface-container-highest transition-colors"
73
+ data-action="clear-query"
74
+ type="button"
75
+ >
76
+ Clear
77
+ </button>
78
+ <button
79
+ class="bg-primary-container px-6 py-1.5 text-xs font-black uppercase tracking-tighter text-on-primary shadow-[0_0_15px_-5px_rgba(252,227,0,0.4)] transition-all hover:brightness-110"
80
+ data-action="execute-query"
81
+ type="button"
82
+ >
83
+ ${executing ? "RUNNING..." : "EXECUTE"}
84
+ </button>
85
+ `;
86
+
87
+ return `
88
+ <div class="flex h-full flex-col">
89
+ <div class="bg-surface-container-low px-6 py-3">
90
+ ${renderActionBar({
91
+ left,
92
+ right,
93
+ className: "flex-wrap",
94
+ })}
95
+ </div>
96
+ <div class="flex flex-1 overflow-hidden">
97
+ <div class="flex w-12 flex-col items-center bg-surface-container-lowest py-4 font-mono text-xs select-none text-outline-variant/30">
98
+ ${renderLineNumbers(query)}
99
+ </div>
100
+ <div class="relative flex-1 overflow-hidden bg-surface-container p-6 font-mono text-sm leading-relaxed">
101
+ <div class="pointer-events-none absolute right-0 top-0 p-4 opacity-5">
102
+ <span class="material-symbols-outlined text-[120px] font-thin">terminal</span>
103
+ </div>
104
+ <div class="query-editor-layer relative z-10 h-full min-h-[140px]">
105
+ <pre
106
+ aria-hidden="true"
107
+ class="query-editor-highlight"
108
+ data-query-editor-highlight
109
+ >${renderHighlightedQuery(query)}</pre>
110
+ <textarea
111
+ class="query-editor-input custom-scrollbar relative z-10 h-full min-h-[140px] w-full resize-none border-none focus:ring-0"
112
+ data-bind="current-query"
113
+ placeholder="SELECT name FROM sqlite_master WHERE type = 'table';"
114
+ spellcheck="false"
115
+ >${escapeHtml(query)}</textarea>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ `;
121
+ }
@@ -0,0 +1,107 @@
1
+ import { renderDataGrid } from "./dataGrid.js";
2
+ import { escapeHtml, formatCellValue, formatNumber } from "../utils/format.js";
3
+
4
+ export function renderQueryResultsPane(
5
+ result,
6
+ { exporting = false, selectedRowIndex = null, editable = false, editStatusMessage = "" } = {}
7
+ ) {
8
+ if (!result) {
9
+ return `
10
+ <div class="flex h-full flex-col items-center justify-center bg-surface-container-lowest text-on-surface-variant/30">
11
+ <span class="material-symbols-outlined mb-3 text-5xl">database_off</span>
12
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">
13
+ NO_QUERY_RESULTS_AVAILABLE
14
+ </p>
15
+ </div>
16
+ `;
17
+ }
18
+
19
+ const columns = (result.columns ?? []).map((columnName) => ({
20
+ label: escapeHtml(columnName),
21
+ headerClassName:
22
+ "border-b-2 border-primary-container px-4 py-3 text-[10px] font-bold uppercase tracking-widest",
23
+ cellClassName: "px-4 py-3 align-top text-on-surface",
24
+ render: (row) => {
25
+ const value = formatCellValue(row[columnName]);
26
+ const isNull = value === "NULL";
27
+ return `<span class="${
28
+ isNull ? "text-on-surface-variant/40" : "text-on-surface"
29
+ }">${escapeHtml(value)}</span>`;
30
+ },
31
+ }));
32
+
33
+ return `
34
+ <div class="relative flex h-full min-h-0 flex-col overflow-hidden bg-surface-container">
35
+ <div class="flex items-center justify-between gap-4 bg-surface-container-high px-4 py-2">
36
+ <div class="flex items-center gap-4">
37
+ <div class="flex items-center gap-2 text-primary-container">
38
+ <span class="material-symbols-outlined text-sm">database</span>
39
+ <span class="text-[10px] font-bold uppercase tracking-widest">Query Results</span>
40
+ </div>
41
+ <div class="h-3 w-px bg-outline-variant/30"></div>
42
+ <div class="text-[10px] font-mono text-on-surface-variant/60">
43
+ ROWS_RETURNED: ${escapeHtml(formatNumber(result.rows?.length ?? 0))}
44
+ </div>
45
+ <div class="text-[10px] font-mono text-on-surface-variant/60">
46
+ AFFECTED: ${escapeHtml(formatNumber(result.affectedRowCount ?? 0))}
47
+ </div>
48
+ <div class="text-[10px] font-mono text-on-surface-variant/60">
49
+ EXEC: ${escapeHtml(String(result.timingMs ?? 0))}ms
50
+ </div>
51
+ ${
52
+ editStatusMessage
53
+ ? `
54
+ <div class="h-3 w-px bg-outline-variant/30"></div>
55
+ <div class="text-[10px] font-mono text-on-surface-variant/60">
56
+ ${escapeHtml(editStatusMessage)}
57
+ </div>
58
+ `
59
+ : ""
60
+ }
61
+ </div>
62
+ <div class="flex gap-4 text-[10px] font-mono uppercase">
63
+ <button
64
+ class="text-on-surface-variant transition-colors hover:text-primary-container"
65
+ data-action="export-query-csv"
66
+ type="button"
67
+ >
68
+ ${exporting ? "Exporting..." : "Export CSV"}
69
+ </button>
70
+ <button
71
+ class="text-on-surface-variant transition-colors hover:text-primary-container"
72
+ data-action="clear-results"
73
+ type="button"
74
+ >
75
+ Clear Results
76
+ </button>
77
+ </div>
78
+ </div>
79
+ <div class="custom-scrollbar min-h-0 flex-1 overflow-auto bg-surface-container-lowest">
80
+ ${
81
+ result.columns?.length
82
+ ? renderDataGrid({
83
+ columns,
84
+ rows: result.rows ?? [],
85
+ tableClass: "min-w-full border-collapse text-left font-mono text-xs",
86
+ theadClass: "sticky top-0 z-10 bg-surface-container-highest text-on-surface",
87
+ tbodyClass: "divide-y divide-outline-variant/5",
88
+ getRowClass: (_, index) =>
89
+ `${selectedRowIndex === index ? "bg-surface-bright" : index % 2 === 0 ? "bg-surface-container-low" : "bg-surface-container-lowest"} transition-colors ${
90
+ editable ? "cursor-pointer hover:bg-surface-bright" : "hover:bg-surface-bright"
91
+ }`,
92
+ getRowAttrs: (_, index) =>
93
+ editable ? `data-action="select-editor-row" data-row-index="${index}"` : "",
94
+ })
95
+ : `
96
+ <div class="flex h-full flex-col items-center justify-center text-center text-on-surface-variant/35">
97
+ <span class="material-symbols-outlined mb-3 text-4xl">rule</span>
98
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">
99
+ STATEMENT_RETURNED_NO_RESULT_SET
100
+ </p>
101
+ </div>
102
+ `
103
+ }
104
+ </div>
105
+ </div>
106
+ `;
107
+ }
@@ -0,0 +1,164 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ function renderReadonlyField(label, value) {
4
+ return `
5
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
6
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
7
+ ${escapeHtml(label)}
8
+ </div>
9
+ <div class="mt-2 text-sm text-on-surface">${escapeHtml(value)}</div>
10
+ </div>
11
+ `;
12
+ }
13
+
14
+ export function renderRowEditorPanel({
15
+ title,
16
+ sectionLabel = "Row Editor",
17
+ subtitle = "",
18
+ closeAction,
19
+ formName,
20
+ hiddenFields = [],
21
+ editableFields = [],
22
+ readonlyFields = [],
23
+ disabledMessage = "",
24
+ saveError = null,
25
+ saving = false,
26
+ reloadAction = "",
27
+ submitLabel = "Save Row",
28
+ emptyEditableMessage = "This row has no editable scalar columns.",
29
+ }) {
30
+ const canSubmit = !disabledMessage && editableFields.length > 0;
31
+
32
+ return `
33
+ <section class="flex h-full min-h-0 flex-col bg-surface-low">
34
+ <header class="border-b border-outline-variant/10 bg-surface-container px-6 py-5">
35
+ <div class="flex items-start justify-between gap-4">
36
+ <div>
37
+ <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
38
+ ${escapeHtml(sectionLabel)}
39
+ </div>
40
+ <h2 class="mt-2 font-headline text-3xl font-black uppercase tracking-tight text-primary-container">
41
+ ${escapeHtml(title)}
42
+ </h2>
43
+ ${
44
+ subtitle
45
+ ? `
46
+ <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
47
+ ${escapeHtml(subtitle)}
48
+ </div>
49
+ `
50
+ : ""
51
+ }
52
+ </div>
53
+ <button
54
+ class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
55
+ data-action="${escapeHtml(closeAction)}"
56
+ type="button"
57
+ >
58
+ Close
59
+ </button>
60
+ </div>
61
+ </header>
62
+ <div class="custom-scrollbar flex-1 overflow-auto px-6 py-6">
63
+ ${
64
+ disabledMessage
65
+ ? `
66
+ <div class="border border-error/20 bg-error-container/10 px-4 py-4 text-sm text-on-surface">
67
+ ${escapeHtml(disabledMessage)}
68
+ </div>
69
+ `
70
+ : `
71
+ <form class="space-y-6" data-form="${escapeHtml(formName)}">
72
+ ${hiddenFields
73
+ .map(
74
+ (field) => `
75
+ <input
76
+ name="${escapeHtml(field.name)}"
77
+ type="hidden"
78
+ value="${escapeHtml(field.value ?? "")}"
79
+ />
80
+ `
81
+ )
82
+ .join("")}
83
+ ${
84
+ editableFields.length
85
+ ? `
86
+ <div class="space-y-4">
87
+ ${editableFields
88
+ .map(
89
+ (field) => `
90
+ <label class="block space-y-2">
91
+ <span class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
92
+ ${escapeHtml(field.label ?? field.name)}
93
+ </span>
94
+ <textarea
95
+ class="min-h-[112px] w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
96
+ name="field:${escapeHtml(field.name)}"
97
+ >${escapeHtml(field.value ?? "")}</textarea>
98
+ </label>
99
+ `
100
+ )
101
+ .join("")}
102
+ </div>
103
+ `
104
+ : `<div class="text-sm text-on-surface-variant/55">${escapeHtml(
105
+ emptyEditableMessage
106
+ )}</div>`
107
+ }
108
+ ${
109
+ saveError
110
+ ? `
111
+ <div class="border border-error/20 bg-error-container/10 px-4 py-4 text-sm text-on-surface">
112
+ <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
113
+ ${escapeHtml(saveError.code)}
114
+ </div>
115
+ <div class="mt-2">${escapeHtml(saveError.message)}</div>
116
+ </div>
117
+ `
118
+ : ""
119
+ }
120
+ <div class="flex items-center justify-end gap-3 border-t border-outline-variant/10 pt-6">
121
+ ${
122
+ reloadAction
123
+ ? `
124
+ <button
125
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface hover:bg-surface-container-highest"
126
+ data-action="${escapeHtml(reloadAction)}"
127
+ type="button"
128
+ >
129
+ Reload
130
+ </button>
131
+ `
132
+ : ""
133
+ }
134
+ <button
135
+ class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary disabled:cursor-default disabled:opacity-40"
136
+ type="submit"
137
+ ${canSubmit ? "" : "disabled"}
138
+ >
139
+ ${escapeHtml(saving ? "Saving..." : submitLabel)}
140
+ </button>
141
+ </div>
142
+ </form>
143
+ `
144
+ }
145
+ ${
146
+ readonlyFields.length
147
+ ? `
148
+ <div class="mt-8 space-y-3">
149
+ <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
150
+ Locked Fields
151
+ </div>
152
+ <div class="space-y-3">
153
+ ${readonlyFields
154
+ .map((field) => renderReadonlyField(field.label ?? field.name, field.value))
155
+ .join("")}
156
+ </div>
157
+ </div>
158
+ `
159
+ : ""
160
+ }
161
+ </div>
162
+ </section>
163
+ `;
164
+ }
@@ -0,0 +1,57 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ const sidebarItems = [
4
+ { label: "Connections", href: "#/connections", key: "connections", icon: "database" },
5
+ { label: "Overview", href: "#/overview", key: "overview", icon: "dashboard" },
6
+ { label: "Data", href: "#/data", key: "data", icon: "table_rows" },
7
+ { label: "SQL Editor", href: "#/editor", key: "editor", icon: "terminal" },
8
+ { label: "Structure", href: "#/structure", key: "structure", icon: "account_tree" },
9
+ { label: "Settings", href: "#/settings", key: "settings", icon: "settings" },
10
+ ];
11
+
12
+ function getActiveSidebarKey(routeName) {
13
+ if (routeName === "landing") {
14
+ return "connections";
15
+ }
16
+
17
+ if (routeName === "editorResults") {
18
+ return "editor";
19
+ }
20
+
21
+ return routeName;
22
+ }
23
+
24
+ export function renderSidebar(state) {
25
+ const activeKey = getActiveSidebarKey(state.route.name);
26
+ const activeConnection = state.connections.active;
27
+
28
+ return `
29
+ <nav class="sidebar-links">
30
+ ${sidebarItems
31
+ .map(
32
+ (item) => `
33
+ <a class="sidebar-link ${item.key === activeKey ? "is-active" : ""}" href="${item.href}">
34
+ <span class="material-symbols-outlined">${item.icon}</span>
35
+ <span>${item.label}</span>
36
+ </a>
37
+ `
38
+ )
39
+ .join("")}
40
+ </nav>
41
+ <div class="sidebar-footer">
42
+ <div class="sidebar-footer-card">
43
+ <div class="sidebar-footer-mark">
44
+ <span class="material-symbols-outlined text-[15px]">memory</span>
45
+ </div>
46
+ <div class="min-w-0">
47
+ <p class="truncate text-[10px] font-bold text-on-surface">
48
+ ${escapeHtml(activeConnection?.label ?? "NO_ACTIVE_DATABASE")}
49
+ </p>
50
+ <p class="text-[8px] text-on-surface-variant/60">
51
+ ${activeConnection?.readOnly ? "READ_ONLY" : "READ_WRITE"}
52
+ </p>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ `;
57
+ }
@@ -0,0 +1,39 @@
1
+ import { escapeHtml, formatBytes, formatDateTime } from "../utils/format.js";
2
+
3
+ export function renderStatusBar(state) {
4
+ const active = state.connections.active;
5
+ const overview = state.overview.data;
6
+ const result = state.editor.result;
7
+
8
+ return `
9
+ <div class="status-bar-shell">
10
+ <div class="status-bar-primary">
11
+ <span class="status-bar-text">
12
+ ${
13
+ active
14
+ ? `ACTIVE_DB // ${escapeHtml(active.label)}`
15
+ : "NO_ACTIVE_DATABASE // CONNECT_SQLITE_FILE"
16
+ }
17
+ </span>
18
+ <div class="status-bar-dot"></div>
19
+ </div>
20
+ <div class="status-bar-secondary">
21
+ <span class="status-bar-link">
22
+ ${active ? (active.readOnly ? "READ_ONLY" : "READ_WRITE") : "IDLE"}
23
+ </span>
24
+ <span class="status-bar-link">
25
+ ${overview?.sqlite?.journalMode ? escapeHtml(overview.sqlite.journalMode) : "journal:n/a"}
26
+ </span>
27
+ <span class="status-bar-link">
28
+ ${overview?.file?.sizeBytes ? escapeHtml(formatBytes(overview.file.sizeBytes)) : "size:n/a"}
29
+ </span>
30
+ <span class="status-bar-link">
31
+ ${result ? `last query ${escapeHtml(String(result.timingMs ?? 0))}ms` : "no query executed"}
32
+ </span>
33
+ <span class="status-bar-link">
34
+ ${active?.lastOpenedAt ? escapeHtml(formatDateTime(active.lastOpenedAt)) : "waiting"}
35
+ </span>
36
+ </div>
37
+ </div>
38
+ `;
39
+ }
@@ -0,0 +1,39 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderToasts(toasts = []) {
4
+ if (!toasts.length) {
5
+ return "";
6
+ }
7
+
8
+ return `
9
+ <div class="pointer-events-none fixed bottom-5 right-5 z-50 flex w-full max-w-sm flex-col gap-3">
10
+ ${toasts
11
+ .map(
12
+ (toast) => `
13
+ <div
14
+ class="pointer-events-auto border border-outline-variant/20 bg-surface-container px-4 py-3 shadow-[0_18px_40px_rgba(0,0,0,0.35)] ${
15
+ toast.tone === "success"
16
+ ? "border-primary-container/30"
17
+ : toast.tone === "alert"
18
+ ? "border-error/30"
19
+ : ""
20
+ }"
21
+ >
22
+ <div class="flex items-start justify-between gap-3">
23
+ <div class="text-sm text-on-surface">${escapeHtml(toast.message)}</div>
24
+ <button
25
+ class="text-on-surface-variant hover:text-primary-container"
26
+ data-action="dismiss-toast"
27
+ data-toast-id="${escapeHtml(toast.id)}"
28
+ type="button"
29
+ >
30
+ <span class="material-symbols-outlined text-base">close</span>
31
+ </button>
32
+ </div>
33
+ </div>
34
+ `
35
+ )
36
+ .join("")}
37
+ </div>
38
+ `;
39
+ }
@@ -0,0 +1,27 @@
1
+ export function renderTopNav() {
2
+ return `
3
+ <div class="top-nav-shell">
4
+ <a class="top-nav-brand" href="#/">SQLite Hub</a>
5
+ <div class="top-nav-actions">
6
+ <button class="top-nav-icon" data-action="refresh-view" type="button" aria-label="Refresh">
7
+ <span class="material-symbols-outlined">refresh</span>
8
+ </button>
9
+ <button
10
+ class="top-nav-icon"
11
+ data-action="open-modal"
12
+ data-modal="open-connection"
13
+ type="button"
14
+ aria-label="Open Database"
15
+ >
16
+ <span class="material-symbols-outlined">folder_open</span>
17
+ </button>
18
+ <button class="top-nav-icon" data-action="navigate" data-to="/editor" type="button" aria-label="SQL Editor">
19
+ <span class="material-symbols-outlined">terminal</span>
20
+ </button>
21
+ <button class="top-nav-icon" data-action="navigate" data-to="/settings" type="button" aria-label="Settings">
22
+ <span class="material-symbols-outlined">settings</span>
23
+ </button>
24
+ </div>
25
+ </div>
26
+ `;
27
+ }
package/js/router.js ADDED
@@ -0,0 +1,66 @@
1
+ export function parseHash(hash = window.location.hash) {
2
+ const normalized = hash.startsWith("#") ? hash.slice(1) : hash;
3
+ const [pathname = "/"] = normalized.split("?");
4
+ const cleanPath = pathname || "/";
5
+ const segments = cleanPath.split("/").filter(Boolean);
6
+
7
+ if (segments.length === 0) {
8
+ return { name: "landing", path: "/", params: {} };
9
+ }
10
+
11
+ switch (segments[0]) {
12
+ case "connections":
13
+ return { name: "connections", path: "/connections", params: {} };
14
+ case "overview":
15
+ return { name: "overview", path: "/overview", params: {} };
16
+ case "editor":
17
+ if (segments[1] === "results") {
18
+ return { name: "editorResults", path: "/editor/results", params: {} };
19
+ }
20
+
21
+ return { name: "editor", path: "/editor", params: {} };
22
+ case "data":
23
+ return {
24
+ name: "data",
25
+ path: cleanPath,
26
+ params: {
27
+ tableName: segments[1] ? decodeURIComponent(segments[1]) : null,
28
+ },
29
+ };
30
+ case "structure":
31
+ return { name: "structure", path: "/structure", params: {} };
32
+ case "settings":
33
+ return { name: "settings", path: "/settings", params: {} };
34
+ default:
35
+ return { name: "notFound", path: cleanPath, params: {} };
36
+ }
37
+ }
38
+
39
+ export function createRouter(onRouteChange) {
40
+ const handleRouteChange = () => {
41
+ onRouteChange(parseHash(window.location.hash));
42
+ };
43
+
44
+ return {
45
+ start() {
46
+ window.addEventListener("hashchange", handleRouteChange);
47
+
48
+ if (!window.location.hash) {
49
+ window.location.hash = "#/";
50
+ return;
51
+ }
52
+
53
+ handleRouteChange();
54
+ },
55
+ navigate(path) {
56
+ const nextHash = path.startsWith("#") ? path : `#${path}`;
57
+
58
+ if (window.location.hash === nextHash) {
59
+ handleRouteChange();
60
+ return;
61
+ }
62
+
63
+ window.location.hash = nextHash;
64
+ },
65
+ };
66
+ }