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
package/js/app.js ADDED
@@ -0,0 +1,520 @@
1
+ import { renderAppShell } from "./components/appShell.js";
2
+ import { renderModal } from "./components/modal.js";
3
+ import { renderSidebar } from "./components/sidebar.js";
4
+ import { renderStatusBar } from "./components/statusBar.js";
5
+ import { renderToasts } from "./components/toast.js";
6
+ import { renderTopNav } from "./components/topNav.js";
7
+ import { createRouter } from "./router.js";
8
+ import {
9
+ clearCurrentQuery,
10
+ clearDataRowSelection,
11
+ clearEditorRowSelection,
12
+ clearEditorResults,
13
+ clearSqlHistoryStateAndData,
14
+ closeModal,
15
+ dismissToast,
16
+ executeCurrentQuery,
17
+ exportCurrentQueryCsv,
18
+ getState,
19
+ initializeApp,
20
+ loadQueryFromHistory,
21
+ openModal,
22
+ openEditConnectionModal,
23
+ refreshCurrentRoute,
24
+ removeConnection,
25
+ selectDataRow,
26
+ selectEditorRow,
27
+ selectConnection,
28
+ selectStructureEntry,
29
+ setDataPage,
30
+ setDataPageSize,
31
+ setCurrentQuery,
32
+ setEditorTab,
33
+ setRoute,
34
+ submitCreateConnection,
35
+ submitDataRowUpdate,
36
+ submitEditorRowUpdate,
37
+ submitEditConnection,
38
+ submitImportSql,
39
+ submitOpenConnection,
40
+ subscribe,
41
+ } from "./store.js";
42
+ import { renderConnectionsView } from "./views/connections.js";
43
+ import { renderDataView } from "./views/data.js";
44
+ import { renderEditorView } from "./views/editor.js";
45
+ import { renderLandingView } from "./views/landing.js";
46
+ import { renderOverviewView } from "./views/overview.js";
47
+ import { renderSettingsView } from "./views/settings.js";
48
+ import { renderStructureView } from "./views/structure.js";
49
+ import { highlightSql } from "./utils/format.js";
50
+
51
+ const appRoot = document.querySelector("#app");
52
+
53
+ appRoot.innerHTML = renderAppShell();
54
+
55
+ const shellRefs = {
56
+ shell: document.querySelector(".app-shell"),
57
+ topNav: document.querySelector("#top-nav"),
58
+ sidebar: document.querySelector("#sidebar"),
59
+ view: document.querySelector("#app-view"),
60
+ panel: document.querySelector("#app-panel"),
61
+ statusBar: document.querySelector("#status-bar"),
62
+ modal: document.querySelector("#modal-root"),
63
+ toast: document.querySelector("#toast-root"),
64
+ };
65
+
66
+ function renderQueryHighlightMarkup(query) {
67
+ if (query) {
68
+ return highlightSql(query);
69
+ }
70
+
71
+ return '<span class="text-on-surface-variant/35">SELECT name FROM sqlite_master WHERE type = \'table\';</span>';
72
+ }
73
+
74
+ function syncQueryEditorHighlight(textarea) {
75
+ if (!(textarea instanceof HTMLTextAreaElement)) {
76
+ return;
77
+ }
78
+
79
+ const layer = textarea.closest(".query-editor-layer");
80
+ const highlightNode = layer?.querySelector("[data-query-editor-highlight]");
81
+
82
+ if (!(highlightNode instanceof HTMLElement)) {
83
+ return;
84
+ }
85
+
86
+ highlightNode.innerHTML = renderQueryHighlightMarkup(textarea.value);
87
+ }
88
+
89
+ function syncQueryEditorScroll(textarea) {
90
+ if (!(textarea instanceof HTMLTextAreaElement)) {
91
+ return;
92
+ }
93
+
94
+ const layer = textarea.closest(".query-editor-layer");
95
+ const highlightNode = layer?.querySelector("[data-query-editor-highlight]");
96
+
97
+ if (!(highlightNode instanceof HTMLElement)) {
98
+ return;
99
+ }
100
+
101
+ highlightNode.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
102
+ }
103
+
104
+ function renderNotFoundView() {
105
+ return {
106
+ main: `
107
+ <section class="landing-view machined-grid px-6">
108
+ <div class="text-center z-10">
109
+ <p class="font-mono text-[10px] uppercase tracking-[0.3em] text-primary-container/40">
110
+ ROUTE_LOST // HASH_NOT_RECOGNIZED
111
+ </p>
112
+ <h1 class="mt-4 font-headline text-6xl font-black uppercase tracking-tight text-primary-container">
113
+ 404_SIGNAL
114
+ </h1>
115
+ <button
116
+ class="mt-8 bg-primary-container px-6 py-3 font-headline text-sm font-bold uppercase tracking-[0.2em] text-on-primary"
117
+ data-action="navigate"
118
+ data-to="/"
119
+ type="button"
120
+ >
121
+ Return_Home
122
+ </button>
123
+ </div>
124
+ </section>
125
+ `,
126
+ panel: "",
127
+ };
128
+ }
129
+
130
+ function resolveView(state) {
131
+ switch (state.route.name) {
132
+ case "landing":
133
+ return renderLandingView(state);
134
+ case "connections":
135
+ return renderConnectionsView(state);
136
+ case "overview":
137
+ return renderOverviewView(state);
138
+ case "data":
139
+ return renderDataView(state);
140
+ case "editor":
141
+ return renderEditorView(state, { isResultsRoute: false });
142
+ case "editorResults":
143
+ return renderEditorView(state, { isResultsRoute: true });
144
+ case "structure":
145
+ return renderStructureView(state);
146
+ case "settings":
147
+ return renderSettingsView(state);
148
+ default:
149
+ return renderNotFoundView();
150
+ }
151
+ }
152
+
153
+ function captureFocusedInputState() {
154
+ const activeElement = document.activeElement;
155
+
156
+ if (
157
+ !activeElement ||
158
+ !(activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement)
159
+ ) {
160
+ return null;
161
+ }
162
+
163
+ const { bind } = activeElement.dataset;
164
+ if (!bind) {
165
+ return null;
166
+ }
167
+
168
+ return {
169
+ bind,
170
+ selectionStart: activeElement.selectionStart,
171
+ selectionEnd: activeElement.selectionEnd,
172
+ selectionDirection: activeElement.selectionDirection,
173
+ scrollTop: activeElement.scrollTop,
174
+ scrollLeft: activeElement.scrollLeft,
175
+ };
176
+ }
177
+
178
+ function restoreFocusedInputState(snapshot) {
179
+ if (!snapshot) {
180
+ return;
181
+ }
182
+
183
+ const selector = `[data-bind="${CSS.escape(snapshot.bind)}"]`;
184
+ const nextElement = document.querySelector(selector);
185
+
186
+ if (
187
+ !nextElement ||
188
+ !(nextElement instanceof HTMLInputElement || nextElement instanceof HTMLTextAreaElement)
189
+ ) {
190
+ return;
191
+ }
192
+
193
+ nextElement.focus({ preventScroll: true });
194
+
195
+ if (
196
+ typeof snapshot.selectionStart === "number" &&
197
+ typeof snapshot.selectionEnd === "number"
198
+ ) {
199
+ nextElement.setSelectionRange(
200
+ snapshot.selectionStart,
201
+ snapshot.selectionEnd,
202
+ snapshot.selectionDirection || "none"
203
+ );
204
+ }
205
+
206
+ nextElement.scrollTop = snapshot.scrollTop;
207
+ nextElement.scrollLeft = snapshot.scrollLeft;
208
+ }
209
+
210
+ function renderApp(state) {
211
+ const focusedInput = captureFocusedInputState();
212
+ const { main, panel } = resolveView(state);
213
+ const isLockedRoute = ["editor", "editorResults", "data"].includes(state.route.name);
214
+
215
+ shellRefs.topNav.innerHTML = renderTopNav(state);
216
+ shellRefs.sidebar.innerHTML = renderSidebar(state);
217
+ shellRefs.statusBar.innerHTML = renderStatusBar(state);
218
+ shellRefs.view.innerHTML = main;
219
+ shellRefs.view.classList.toggle("app-main-scroll--locked", isLockedRoute);
220
+ shellRefs.panel.innerHTML = panel;
221
+ shellRefs.modal.innerHTML = renderModal(state);
222
+ shellRefs.toast.innerHTML = renderToasts(state.toasts);
223
+ shellRefs.shell.classList.toggle("panel-open", Boolean(panel));
224
+ restoreFocusedInputState(focusedInput);
225
+ }
226
+
227
+ const router = createRouter((route) => {
228
+ setRoute(route);
229
+ });
230
+
231
+ async function handleAction(actionNode) {
232
+ const { action } = actionNode.dataset;
233
+
234
+ switch (action) {
235
+ case "navigate":
236
+ router.navigate(actionNode.dataset.to ?? "/");
237
+ return;
238
+ case "refresh-view":
239
+ await refreshCurrentRoute();
240
+ return;
241
+ case "open-modal":
242
+ openModal(actionNode.dataset.modal);
243
+ return;
244
+ case "edit-connection":
245
+ openEditConnectionModal(actionNode.dataset.connectionId);
246
+ return;
247
+ case "close-modal":
248
+ closeModal();
249
+ return;
250
+ case "dismiss-toast":
251
+ dismissToast(actionNode.dataset.toastId);
252
+ return;
253
+ case "select-connection": {
254
+ const next = await selectConnection(actionNode.dataset.connectionId);
255
+ if (next) {
256
+ router.navigate("/overview");
257
+ }
258
+ return;
259
+ }
260
+ case "remove-connection": {
261
+ const removed = await removeConnection(actionNode.dataset.connectionId);
262
+ if (removed) {
263
+ const nextState = getState();
264
+ if (!nextState.connections.active && nextState.route.name !== "connections") {
265
+ router.navigate("/connections");
266
+ } else {
267
+ await refreshCurrentRoute();
268
+ }
269
+ }
270
+ return;
271
+ }
272
+ case "execute-query": {
273
+ const success = await executeCurrentQuery();
274
+ router.navigate(success ? "/editor/results" : "/editor");
275
+ return;
276
+ }
277
+ case "clear-query":
278
+ clearCurrentQuery();
279
+ return;
280
+ case "clear-results":
281
+ clearEditorResults();
282
+ router.navigate("/editor");
283
+ return;
284
+ case "set-editor-tab": {
285
+ const tab = actionNode.dataset.tab;
286
+ if (!tab) {
287
+ return;
288
+ }
289
+ setEditorTab(tab);
290
+ router.navigate(tab === "results" ? "/editor/results" : "/editor");
291
+ return;
292
+ }
293
+ case "clear-sql-history":
294
+ await clearSqlHistoryStateAndData();
295
+ return;
296
+ case "export-query-csv":
297
+ await exportCurrentQueryCsv();
298
+ return;
299
+ case "select-structure-entry":
300
+ if (actionNode.dataset.entryName) {
301
+ await selectStructureEntry(actionNode.dataset.entryName);
302
+ }
303
+ return;
304
+ case "select-data-row":
305
+ if (actionNode.dataset.rowIndex) {
306
+ selectDataRow(actionNode.dataset.rowIndex);
307
+ }
308
+ return;
309
+ case "select-editor-row":
310
+ if (actionNode.dataset.rowIndex) {
311
+ selectEditorRow(actionNode.dataset.rowIndex);
312
+ }
313
+ return;
314
+ case "clear-data-row-selection":
315
+ clearDataRowSelection();
316
+ return;
317
+ case "clear-editor-row-selection":
318
+ clearEditorRowSelection();
319
+ return;
320
+ case "set-data-page":
321
+ if (actionNode.dataset.page) {
322
+ await setDataPage(actionNode.dataset.page);
323
+ }
324
+ return;
325
+ case "set-data-page-size":
326
+ if (actionNode.dataset.pageSize) {
327
+ await setDataPageSize(actionNode.dataset.pageSize);
328
+ }
329
+ return;
330
+ case "reload-data-route":
331
+ await refreshCurrentRoute();
332
+ return;
333
+ default:
334
+ }
335
+ }
336
+
337
+ document.addEventListener("click", (event) => {
338
+ const actionNode = event.target.closest("[data-action]");
339
+
340
+ if (!actionNode) {
341
+ return;
342
+ }
343
+
344
+ handleAction(actionNode);
345
+ });
346
+
347
+ document.addEventListener("keydown", (event) => {
348
+ if (event.key !== "Escape" || event.defaultPrevented) {
349
+ return;
350
+ }
351
+
352
+ const state = getState();
353
+
354
+ if (state.modal) {
355
+ event.preventDefault();
356
+ closeModal();
357
+ return;
358
+ }
359
+
360
+ if (state.route.name === "data" && typeof state.dataBrowser.selectedRowIndex === "number") {
361
+ event.preventDefault();
362
+ clearDataRowSelection();
363
+ return;
364
+ }
365
+
366
+ if (state.route.name === "editorResults" && typeof state.editor.selectedRowIndex === "number") {
367
+ event.preventDefault();
368
+ clearEditorRowSelection();
369
+ }
370
+ });
371
+
372
+ document.addEventListener("input", (event) => {
373
+ const bindNode = event.target.closest("[data-bind]");
374
+
375
+ if (!bindNode) {
376
+ return;
377
+ }
378
+
379
+ if (bindNode.dataset.bind === "current-query") {
380
+ syncQueryEditorHighlight(bindNode);
381
+ syncQueryEditorScroll(bindNode);
382
+ setCurrentQuery(bindNode.value);
383
+ }
384
+ });
385
+
386
+ document.addEventListener(
387
+ "scroll",
388
+ (event) => {
389
+ const target = event.target;
390
+
391
+ if (!(target instanceof HTMLTextAreaElement) || target.dataset.bind !== "current-query") {
392
+ return;
393
+ }
394
+
395
+ syncQueryEditorScroll(target);
396
+ },
397
+ true
398
+ );
399
+
400
+ document.addEventListener("change", (event) => {
401
+ const bindNode = event.target.closest("[data-bind]");
402
+
403
+ if (!bindNode) {
404
+ return;
405
+ }
406
+
407
+ if (bindNode.dataset.bind === "history-entry" && bindNode.value) {
408
+ loadQueryFromHistory(bindNode.value);
409
+ bindNode.value = "";
410
+ }
411
+ });
412
+
413
+ document.addEventListener("submit", async (event) => {
414
+ const form = event.target.closest("[data-form]");
415
+
416
+ if (!form) {
417
+ return;
418
+ }
419
+
420
+ event.preventDefault();
421
+ const formData = new FormData(form);
422
+
423
+ switch (form.dataset.form) {
424
+ case "open-connection": {
425
+ const connection = await submitOpenConnection({
426
+ path: String(formData.get("path") ?? ""),
427
+ label: String(formData.get("label") ?? ""),
428
+ readOnly: formData.get("readOnly") === "on",
429
+ });
430
+
431
+ if (connection) {
432
+ router.navigate("/overview");
433
+ }
434
+ return;
435
+ }
436
+ case "create-connection": {
437
+ const connection = await submitCreateConnection({
438
+ path: String(formData.get("path") ?? ""),
439
+ label: String(formData.get("label") ?? ""),
440
+ });
441
+
442
+ if (connection) {
443
+ router.navigate("/overview");
444
+ }
445
+ return;
446
+ }
447
+ case "import-sql": {
448
+ const targetMode = String(formData.get("targetMode") ?? "active");
449
+ const payload = {
450
+ sqlFilePath: String(formData.get("sqlFilePath") ?? ""),
451
+ label: String(formData.get("label") ?? ""),
452
+ };
453
+
454
+ if (targetMode === "recent") {
455
+ payload.targetConnectionId = String(formData.get("targetConnectionId") ?? "");
456
+ } else if (targetMode === "create") {
457
+ payload.createNew = true;
458
+ payload.targetPath = String(formData.get("targetPath") ?? "");
459
+ } else if (targetMode === "path") {
460
+ payload.targetPath = String(formData.get("targetPath") ?? "");
461
+ }
462
+
463
+ const result = await submitImportSql(payload);
464
+ if (result) {
465
+ router.navigate("/overview");
466
+ }
467
+ return;
468
+ }
469
+ case "edit-connection": {
470
+ await submitEditConnection(String(formData.get("connectionId") ?? ""), {
471
+ path: String(formData.get("path") ?? ""),
472
+ label: String(formData.get("label") ?? ""),
473
+ readOnly: formData.get("readOnly") === "on",
474
+ });
475
+
476
+ return;
477
+ }
478
+ case "save-data-row": {
479
+ const values = {};
480
+
481
+ for (const [key, value] of formData.entries()) {
482
+ if (!key.startsWith("field:")) {
483
+ continue;
484
+ }
485
+
486
+ values[key.slice("field:".length)] = String(value ?? "");
487
+ }
488
+
489
+ await submitDataRowUpdate(
490
+ String(formData.get("rowIndex") ?? ""),
491
+ values
492
+ );
493
+ return;
494
+ }
495
+ case "save-editor-row": {
496
+ const values = {};
497
+
498
+ for (const [key, value] of formData.entries()) {
499
+ if (!key.startsWith("field:")) {
500
+ continue;
501
+ }
502
+
503
+ values[key.slice("field:".length)] = String(value ?? "");
504
+ }
505
+
506
+ await submitEditorRowUpdate(
507
+ String(formData.get("rowIndex") ?? ""),
508
+ values
509
+ );
510
+ return;
511
+ }
512
+ default:
513
+ }
514
+ });
515
+
516
+ subscribe(renderApp);
517
+ renderApp(getState());
518
+ initializeApp().then(() => {
519
+ router.start();
520
+ });
@@ -0,0 +1,8 @@
1
+ export function renderActionBar({ left = "", right = "", className = "" }) {
2
+ return `
3
+ <div class="flex items-center justify-between gap-4 ${className}">
4
+ <div class="flex items-center gap-4">${left}</div>
5
+ <div class="flex items-center gap-3">${right}</div>
6
+ </div>
7
+ `;
8
+ }
@@ -0,0 +1,17 @@
1
+ export function renderAppShell() {
2
+ return `
3
+ <div class="app-shell">
4
+ <header id="top-nav" class="app-top-nav bg-[#131313]"></header>
5
+ <div class="app-body">
6
+ <aside id="sidebar" class="app-sidebar sidebar-shell"></aside>
7
+ <main class="app-main">
8
+ <div id="app-view" class="app-main-scroll app-view custom-scrollbar"></div>
9
+ </main>
10
+ <aside id="app-panel" class="app-right-panel"></aside>
11
+ </div>
12
+ <footer id="status-bar" class="app-status-bar"></footer>
13
+ </div>
14
+ <div id="modal-root"></div>
15
+ <div id="toast-root"></div>
16
+ `;
17
+ }
@@ -0,0 +1,5 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderStatusBadge(label, tone = "muted") {
4
+ return `<span class="status-badge status-badge--${tone}">${escapeHtml(label)}</span>`;
5
+ }
@@ -0,0 +1,37 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderBottomTabs(activeTab, counts = {}) {
4
+ const tabs = [
5
+ { key: "results", label: "results", meta: counts.resultRows ?? 0 },
6
+ { key: "messages", label: "messages", meta: counts.messages ?? 0 },
7
+ { key: "performance", label: "performance", meta: counts.statementCount ?? 0 },
8
+ ];
9
+
10
+ return `
11
+ <div class="flex items-center justify-between border-b border-outline-variant/5 bg-surface-container-low/50 px-4 h-10">
12
+ <div class="flex items-center gap-6">
13
+ ${tabs
14
+ .map(
15
+ (tab) => `
16
+ <button
17
+ class="bottom-tab ${activeTab === tab.key ? "is-active" : ""}"
18
+ data-action="set-editor-tab"
19
+ data-tab="${tab.key}"
20
+ type="button"
21
+ >
22
+ ${tab.label}
23
+ <span class="ml-2 text-[9px] opacity-50">${escapeHtml(String(tab.meta))}</span>
24
+ </button>
25
+ `
26
+ )
27
+ .join("")}
28
+ </div>
29
+ <div class="flex items-center gap-2">
30
+ <span class="material-symbols-outlined text-xs text-on-surface-variant/30">database</span>
31
+ <span class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45">
32
+ sqlite
33
+ </span>
34
+ </div>
35
+ </div>
36
+ `;
37
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ escapeHtml,
3
+ formatBytes,
4
+ formatDateTime,
5
+ truncateMiddle,
6
+ } from "../utils/format.js";
7
+ import { renderStatusBadge } from "./badges.js";
8
+
9
+ export function renderConnectionCard(connection, activeConnectionId) {
10
+ const isActive = activeConnectionId
11
+ ? connection.id === activeConnectionId
12
+ : Boolean(connection.isActive);
13
+ const clipPath = "polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%)";
14
+ const primaryActionLabel = isActive ? "Open Overview" : "Set Active";
15
+
16
+ return `
17
+ <article
18
+ class="connection-card clipped-corner ${isActive ? "is-active" : ""}"
19
+ style="--clip-path: ${clipPath};"
20
+ >
21
+ <div class="flex-1 p-6">
22
+ <div class="mb-6 flex items-start justify-between">
23
+ <div
24
+ class="clipped-corner flex h-10 w-10 items-center justify-center transition-colors ${
25
+ isActive ? "bg-primary-container" : "bg-surface-container-highest"
26
+ }"
27
+ style="--clip-path: ${clipPath};"
28
+ >
29
+ <span class="material-symbols-outlined ${
30
+ isActive ? "text-on-primary" : "text-outline-variant"
31
+ }">database</span>
32
+ </div>
33
+ <div class="flex items-center gap-2">
34
+ ${renderStatusBadge(isActive ? "ACTIVE" : "RECENT", isActive ? "primary" : "muted")}
35
+ ${connection.readOnly ? renderStatusBadge("READ_ONLY", "alert") : ""}
36
+ </div>
37
+ </div>
38
+ <h3 class="mb-1 font-headline text-xl font-bold uppercase ${
39
+ isActive ? "text-[#FCE300]" : "text-on-surface"
40
+ }">
41
+ ${escapeHtml(connection.label)}
42
+ </h3>
43
+ <p
44
+ class="block overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10px] text-outline-variant"
45
+ title="${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>
51
+ <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Allocation</div>
52
+ <div class="text-xs font-bold text-on-surface">${escapeHtml(
53
+ formatBytes(connection.sizeBytes)
54
+ )}</div>
55
+ </div>
56
+ <div>
57
+ <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Modified</div>
58
+ <div class="text-xs font-bold text-on-surface">${escapeHtml(
59
+ formatDateTime(connection.lastModifiedAt)
60
+ )}</div>
61
+ </div>
62
+ <div>
63
+ <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Opened</div>
64
+ <div class="text-xs font-bold text-on-surface">${escapeHtml(
65
+ formatDateTime(connection.lastOpenedAt)
66
+ )}</div>
67
+ </div>
68
+ <div>
69
+ <div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div>
70
+ <div class="text-xs font-bold text-on-surface">${connection.readOnly ? "Read only" : "Read / Write"}</div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ <div class="border-t border-outline-variant/10 bg-surface-container-low px-4 py-3">
75
+ <div class="grid grid-cols-[minmax(0,1fr)_5.1rem_5.8rem] gap-2">
76
+ <button
77
+ class="clipped-corner min-w-0 bg-primary-container px-3 py-2.5 text-[10px] font-black uppercase tracking-[0.18em] text-on-primary shadow-[0_0_18px_-10px_rgba(252,227,0,0.7)] transition-[filter,box-shadow] hover:brightness-105"
78
+ data-action="select-connection"
79
+ data-connection-id="${escapeHtml(connection.id)}"
80
+ style="--clip-path: ${clipPath};"
81
+ type="button"
82
+ title="${primaryActionLabel}"
83
+ >
84
+ ${primaryActionLabel}
85
+ </button>
86
+ <button
87
+ class="border border-outline-variant/20 bg-surface-container px-3 py-2.5 text-[10px] font-bold uppercase tracking-[0.18em] text-on-surface-variant transition-colors hover:bg-surface-container-highest"
88
+ data-action="edit-connection"
89
+ data-connection-id="${escapeHtml(connection.id)}"
90
+ type="button"
91
+ >
92
+ Edit
93
+ </button>
94
+ <button
95
+ class="border border-outline-variant/20 bg-surface-container px-3 py-2.5 text-[10px] font-bold uppercase tracking-[0.18em] text-on-surface-variant transition-colors hover:bg-surface-container-highest"
96
+ data-action="remove-connection"
97
+ data-connection-id="${escapeHtml(connection.id)}"
98
+ type="button"
99
+ >
100
+ Remove
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </article>
105
+ `;
106
+ }