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,47 @@
1
+ export function renderDataGrid({
2
+ columns,
3
+ rows,
4
+ tableClass = "",
5
+ theadClass = "",
6
+ headerRowClass = "",
7
+ tbodyClass = "",
8
+ getRowClass = () => "",
9
+ getRowAttrs = () => "",
10
+ }) {
11
+ return `
12
+ <table class="${tableClass}">
13
+ <thead class="${theadClass}">
14
+ <tr class="${headerRowClass}">
15
+ ${columns
16
+ .map(
17
+ (column) => `
18
+ <th class="${column.headerClassName ?? ""}">
19
+ ${column.label}
20
+ </th>
21
+ `
22
+ )
23
+ .join("")}
24
+ </tr>
25
+ </thead>
26
+ <tbody class="${tbodyClass}">
27
+ ${rows
28
+ .map(
29
+ (row, index) => `
30
+ <tr class="${getRowClass(row, index)}" ${getRowAttrs(row, index)}>
31
+ ${columns
32
+ .map(
33
+ (column) => `
34
+ <td class="${column.cellClassName ?? ""}">
35
+ ${column.render ? column.render(row, index) : row[column.key]}
36
+ </td>
37
+ `
38
+ )
39
+ .join("")}
40
+ </tr>
41
+ `
42
+ )
43
+ .join("")}
44
+ </tbody>
45
+ </table>
46
+ `;
47
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ escapeHtml,
3
+ formatBytes,
4
+ formatDateTime,
5
+ truncateMiddle,
6
+ } from "../utils/format.js";
7
+
8
+ function renderRecentConnections(recentConnections = []) {
9
+ if (!recentConnections.length) {
10
+ return `
11
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/40">
12
+ No recent SQLite databases recorded yet.
13
+ </div>
14
+ `;
15
+ }
16
+
17
+ return `
18
+ <div class="flex flex-wrap justify-center gap-4">
19
+ ${recentConnections
20
+ .slice(0, 4)
21
+ .map(
22
+ (connection) => `
23
+ <button
24
+ class="flex items-center gap-2 border border-outline-variant/15 bg-surface-container-low px-4 py-3 text-left text-on-surface transition-colors hover:border-primary-container/30 hover:bg-surface-container-high"
25
+ data-action="select-connection"
26
+ data-connection-id="${escapeHtml(connection.id)}"
27
+ type="button"
28
+ >
29
+ <span class="material-symbols-outlined text-sm text-primary-container">database</span>
30
+ <span class="min-w-0">
31
+ <span class="block truncate font-mono text-xs">${escapeHtml(connection.label)}</span>
32
+ <span class="block truncate text-[10px] text-on-surface-variant/45">${escapeHtml(
33
+ truncateMiddle(connection.path, 34)
34
+ )}</span>
35
+ </span>
36
+ </button>
37
+ `
38
+ )
39
+ .join("")}
40
+ </div>
41
+ `;
42
+ }
43
+
44
+ function renderActiveConnection(activeConnection) {
45
+ if (!activeConnection) {
46
+ return `
47
+ <p class="font-light text-lg uppercase tracking-wide text-on-surface-variant">
48
+ No database connected
49
+ </p>
50
+ `;
51
+ }
52
+
53
+ return `
54
+ <div class="mx-auto max-w-2xl border border-outline-variant/15 bg-surface-container-low px-6 py-5 text-left">
55
+ <div class="flex flex-wrap items-start justify-between gap-4">
56
+ <div>
57
+ <p class="text-[10px] font-mono uppercase tracking-[0.24em] text-primary-container/70">
58
+ ACTIVE_DATABASE
59
+ </p>
60
+ <h2 class="mt-2 font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
61
+ ${escapeHtml(activeConnection.label)}
62
+ </h2>
63
+ <p class="mt-2 font-mono text-[10px] text-on-surface-variant/55">${escapeHtml(
64
+ truncateMiddle(activeConnection.path, 72)
65
+ )}</p>
66
+ </div>
67
+ <div class="text-right text-xs text-on-surface-variant/65">
68
+ <div>${escapeHtml(formatBytes(activeConnection.sizeBytes))}</div>
69
+ <div class="mt-1">${escapeHtml(formatDateTime(activeConnection.lastModifiedAt))}</div>
70
+ <div class="mt-1">${activeConnection.readOnly ? "READ_ONLY" : "READ_WRITE"}</div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ `;
75
+ }
76
+
77
+ export function renderEmptyState({ activeConnection, recentConnections = [] }) {
78
+ const hasActive = Boolean(activeConnection);
79
+
80
+ return `
81
+ <section class="landing-view machined-grid px-6">
82
+ <div class="landing-accent landing-accent--a"></div>
83
+ <div class="landing-accent landing-accent--b"></div>
84
+ <div class="landing-accent--c absolute"></div>
85
+ <div class="empty-state-shell z-10 text-center">
86
+ <div class="mb-2">
87
+ <span class="font-mono text-[10px] tracking-[0.3em] text-primary-container/40">
88
+ SYSTEM_READY // ${hasActive ? "ACTIVE_CONTEXT" : "IDLE_STATE"}
89
+ </span>
90
+ </div>
91
+ <h1 class="mb-4 font-headline text-7xl font-black tracking-tighter text-primary-container opacity-90 md:text-9xl">
92
+ SQLite Hub
93
+ </h1>
94
+ <div class="mx-auto mb-12 max-w-3xl space-y-4">
95
+ ${renderActiveConnection(activeConnection)}
96
+ <div class="h-[2px] w-12 bg-primary-container mx-auto"></div>
97
+ </div>
98
+ <div class="mx-auto grid w-full max-w-3xl grid-cols-1 gap-4 px-6 md:grid-cols-2">
99
+ <button
100
+ class="clipped-btn group flex items-center justify-between bg-primary-container px-8 py-6 font-headline text-lg font-bold text-on-primary transition-all duration-300 hover:shadow-[0_0_20px_rgba(252,227,0,0.3)]"
101
+ data-action="open-modal"
102
+ data-modal="open-connection"
103
+ style="--clip-path: polygon(0 0, 90% 0, 100% 25%, 100% 100%, 0 100%);"
104
+ type="button"
105
+ >
106
+ <span>CONNECT DATABASE</span>
107
+ <span class="material-symbols-outlined transition-transform group-hover:translate-x-1">add_circle</span>
108
+ </button>
109
+ <button
110
+ class="flex items-center justify-between border-l-2 border-primary-container bg-surface-container-highest px-8 py-6 font-headline text-lg font-bold text-primary-container transition-colors duration-150 hover:bg-surface-bright"
111
+ data-action="open-modal"
112
+ data-modal="create-connection"
113
+ type="button"
114
+ >
115
+ <span>CREATE DATABASE</span>
116
+ <span class="material-symbols-outlined">note_add</span>
117
+ </button>
118
+ </div>
119
+ ${
120
+ hasActive
121
+ ? `
122
+ <div class="mx-auto mt-8 grid w-full max-w-3xl grid-cols-1 gap-4 px-6 md:grid-cols-3">
123
+ <button
124
+ class="border border-outline-variant/20 bg-surface-container-low px-5 py-4 font-headline text-sm font-bold uppercase tracking-[0.16em] text-on-surface transition-colors hover:border-primary-container/30 hover:text-primary-container"
125
+ data-action="navigate"
126
+ data-to="/overview"
127
+ type="button"
128
+ >
129
+ Overview
130
+ </button>
131
+ <button
132
+ class="border border-outline-variant/20 bg-surface-container-low px-5 py-4 font-headline text-sm font-bold uppercase tracking-[0.16em] text-on-surface transition-colors hover:border-primary-container/30 hover:text-primary-container"
133
+ data-action="navigate"
134
+ data-to="/structure"
135
+ type="button"
136
+ >
137
+ Structure
138
+ </button>
139
+ <button
140
+ class="border border-outline-variant/20 bg-surface-container-low px-5 py-4 font-headline text-sm font-bold uppercase tracking-[0.16em] text-on-surface transition-colors hover:border-primary-container/30 hover:text-primary-container"
141
+ data-action="navigate"
142
+ data-to="/editor"
143
+ type="button"
144
+ >
145
+ SQL Editor
146
+ </button>
147
+ </div>
148
+ `
149
+ : ""
150
+ }
151
+ <div class="mt-16 flex flex-col items-center gap-4 opacity-70 transition-opacity hover:opacity-100">
152
+ <p class="font-mono text-[10px] tracking-widest text-on-surface-variant">RECENT_TARGETS</p>
153
+ ${renderRecentConnections(recentConnections)}
154
+ </div>
155
+ </div>
156
+ <div class="pointer-events-none absolute bottom-0 left-0 h-1/3 w-full bg-gradient-to-t from-surface-container-lowest to-transparent"></div>
157
+ </section>
158
+ `;
159
+ }
@@ -0,0 +1,32 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderMetricCard({
4
+ label,
5
+ value,
6
+ subtext = "",
7
+ accent = false,
8
+ progress = "",
9
+ }) {
10
+ return `
11
+ <div class="metric-card ${accent ? "metric-card--accent" : ""}">
12
+ <span class="text-[10px] font-mono text-on-surface/40 uppercase">${escapeHtml(label)}</span>
13
+ <span class="text-3xl font-headline font-bold text-on-surface">${escapeHtml(value)}</span>
14
+ ${
15
+ progress
16
+ ? `
17
+ <div class="w-full bg-surface-container-highest h-1 mt-2">
18
+ <div class="bg-primary-container h-full" style="width: ${escapeHtml(progress)}"></div>
19
+ </div>
20
+ `
21
+ : ""
22
+ }
23
+ ${
24
+ subtext
25
+ ? `<span class="text-[10px] ${accent ? "text-primary-container" : "text-on-surface/40"}">${escapeHtml(
26
+ subtext
27
+ )}</span>`
28
+ : ""
29
+ }
30
+ </div>
31
+ `;
32
+ }
@@ -0,0 +1,317 @@
1
+ import { escapeHtml, truncateMiddle } from "../utils/format.js";
2
+
3
+ function renderField({ label, name, type = "text", placeholder = "", value = "" }) {
4
+ return `
5
+ <label class="block space-y-2">
6
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
7
+ ${escapeHtml(label)}
8
+ </span>
9
+ <input
10
+ class="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"
11
+ name="${escapeHtml(name)}"
12
+ placeholder="${escapeHtml(placeholder)}"
13
+ type="${escapeHtml(type)}"
14
+ value="${escapeHtml(value)}"
15
+ />
16
+ </label>
17
+ `;
18
+ }
19
+
20
+ function renderCheckboxField({ label, name, checked = false, text }) {
21
+ return `
22
+ <label class="flex items-center gap-3 border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface">
23
+ <input
24
+ class="rounded-none border-outline bg-surface-container-lowest text-primary-container focus:ring-primary-container"
25
+ ${checked ? "checked" : ""}
26
+ name="${escapeHtml(name)}"
27
+ type="checkbox"
28
+ />
29
+ ${escapeHtml(text || label)}
30
+ </label>
31
+ `;
32
+ }
33
+
34
+ function renderError(error) {
35
+ if (!error) {
36
+ return "";
37
+ }
38
+
39
+ return `
40
+ <div class="border border-error/20 bg-error-container/20 px-4 py-3 text-sm text-error">
41
+ <div class="font-headline text-xs font-bold uppercase tracking-[0.18em]">${escapeHtml(
42
+ error.code || "Request failed"
43
+ )}</div>
44
+ <div class="mt-1 text-on-surface">${escapeHtml(error.message)}</div>
45
+ </div>
46
+ `;
47
+ }
48
+
49
+ function renderOpenConnectionForm(modal) {
50
+ return `
51
+ <form class="space-y-5" data-form="open-connection">
52
+ ${renderField({
53
+ label: "SQLite File Path",
54
+ name: "path",
55
+ placeholder: "/absolute/path/to/database.sqlite",
56
+ })}
57
+ ${renderField({
58
+ label: "Label",
59
+ name: "label",
60
+ placeholder: "Optional display name",
61
+ })}
62
+ ${renderCheckboxField({
63
+ label: "Open read-only",
64
+ name: "readOnly",
65
+ text: "Open read-only",
66
+ })}
67
+ ${renderError(modal.error)}
68
+ <div class="flex items-center justify-end gap-3 pt-2">
69
+ <button
70
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
71
+ data-action="close-modal"
72
+ type="button"
73
+ >
74
+ Cancel
75
+ </button>
76
+ <button
77
+ class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
78
+ type="submit"
79
+ >
80
+ ${modal.submitting ? "Opening..." : "Open Database"}
81
+ </button>
82
+ </div>
83
+ </form>
84
+ `;
85
+ }
86
+
87
+ function renderEditConnectionForm(modal) {
88
+ const connection = modal.connection ?? {};
89
+
90
+ return `
91
+ <form class="space-y-5" data-form="edit-connection">
92
+ <input name="connectionId" type="hidden" value="${escapeHtml(connection.id ?? "")}" />
93
+ ${renderField({
94
+ label: "SQLite File Path",
95
+ name: "path",
96
+ placeholder: "/absolute/path/to/database.sqlite",
97
+ value: connection.path ?? "",
98
+ })}
99
+ ${renderField({
100
+ label: "Label",
101
+ name: "label",
102
+ placeholder: "Optional display name",
103
+ value: connection.label ?? "",
104
+ })}
105
+ ${renderCheckboxField({
106
+ label: "Open read-only",
107
+ name: "readOnly",
108
+ checked: Boolean(connection.readOnly),
109
+ text: "Open read-only",
110
+ })}
111
+ ${renderError(modal.error)}
112
+ <div class="flex items-center justify-end gap-3 pt-2">
113
+ <button
114
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
115
+ data-action="close-modal"
116
+ type="button"
117
+ >
118
+ Cancel
119
+ </button>
120
+ <button
121
+ class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
122
+ type="submit"
123
+ >
124
+ ${modal.submitting ? "Saving..." : "Save Changes"}
125
+ </button>
126
+ </div>
127
+ </form>
128
+ `;
129
+ }
130
+
131
+ function renderCreateDatabaseForm(modal) {
132
+ return `
133
+ <form class="space-y-5" data-form="create-connection">
134
+ ${renderField({
135
+ label: "New SQLite File Path",
136
+ name: "path",
137
+ placeholder: "/absolute/path/to/new-database.sqlite",
138
+ })}
139
+ ${renderField({
140
+ label: "Label",
141
+ name: "label",
142
+ placeholder: "Optional display name",
143
+ })}
144
+ ${renderError(modal.error)}
145
+ <div class="flex items-center justify-end gap-3 pt-2">
146
+ <button
147
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
148
+ data-action="close-modal"
149
+ type="button"
150
+ >
151
+ Cancel
152
+ </button>
153
+ <button
154
+ class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
155
+ type="submit"
156
+ >
157
+ ${modal.submitting ? "Creating..." : "Create Database"}
158
+ </button>
159
+ </div>
160
+ </form>
161
+ `;
162
+ }
163
+
164
+ function renderImportTargetOptions(state) {
165
+ const recentOptions = state.connections.recent
166
+ .map(
167
+ (connection) => `
168
+ <option value="${escapeHtml(connection.id)}">
169
+ ${escapeHtml(connection.label)} • ${escapeHtml(truncateMiddle(connection.path, 42))}
170
+ </option>
171
+ `
172
+ )
173
+ .join("");
174
+
175
+ return `
176
+ <label class="block space-y-2">
177
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
178
+ Import Target
179
+ </span>
180
+ <select
181
+ class="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"
182
+ name="targetMode"
183
+ >
184
+ ${
185
+ state.connections.active
186
+ ? '<option value="active">Use active database</option>'
187
+ : ""
188
+ }
189
+ ${state.connections.recent.length ? '<option value="recent">Use recent connection</option>' : ""}
190
+ <option value="create">Create new database from dump</option>
191
+ <option value="path">Open explicit target path</option>
192
+ </select>
193
+ </label>
194
+ ${
195
+ state.connections.recent.length
196
+ ? `
197
+ <label class="block space-y-2">
198
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
199
+ Recent Connection
200
+ </span>
201
+ <select
202
+ class="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"
203
+ name="targetConnectionId"
204
+ >
205
+ ${recentOptions}
206
+ </select>
207
+ </label>
208
+ `
209
+ : ""
210
+ }
211
+ ${renderField({
212
+ label: "Target Path",
213
+ name: "targetPath",
214
+ placeholder: "/absolute/path/to/target.sqlite",
215
+ })}
216
+ ${renderField({
217
+ label: "Target Label",
218
+ name: "label",
219
+ placeholder: "Optional display name",
220
+ })}
221
+ `;
222
+ }
223
+
224
+ function renderImportSqlForm(modal, state) {
225
+ return `
226
+ <form class="space-y-5" data-form="import-sql">
227
+ ${renderField({
228
+ label: "SQL Dump Path",
229
+ name: "sqlFilePath",
230
+ placeholder: "/absolute/path/to/dump.sql",
231
+ })}
232
+ ${renderImportTargetOptions(state)}
233
+ <p class="text-[11px] leading-6 text-on-surface-variant/60">
234
+ Use an absolute filesystem path. Browsers do not expose local file paths, so SQLite Hub imports by
235
+ explicit path instead of file upload.
236
+ </p>
237
+ ${renderError(modal.error)}
238
+ <div class="flex items-center justify-end gap-3 pt-2">
239
+ <button
240
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
241
+ data-action="close-modal"
242
+ type="button"
243
+ >
244
+ Cancel
245
+ </button>
246
+ <button
247
+ class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary"
248
+ type="submit"
249
+ >
250
+ ${modal.submitting ? "Importing..." : "Import SQL Dump"}
251
+ </button>
252
+ </div>
253
+ </form>
254
+ `;
255
+ }
256
+
257
+ export function renderModal(state) {
258
+ const modal = state.modal;
259
+
260
+ if (!modal) {
261
+ return "";
262
+ }
263
+
264
+ const contentByKind = {
265
+ "open-connection": {
266
+ eyebrow: "Filesystem // Open existing SQLite database",
267
+ title: "Connect Database",
268
+ body: renderOpenConnectionForm(modal),
269
+ },
270
+ "create-connection": {
271
+ eyebrow: "Filesystem // Create a new SQLite database",
272
+ title: "Create Database",
273
+ body: renderCreateDatabaseForm(modal),
274
+ },
275
+ "import-sql": {
276
+ eyebrow: "Import // Execute SQL dump into SQLite",
277
+ title: "Import SQL Dump",
278
+ body: renderImportSqlForm(modal, state),
279
+ },
280
+ "edit-connection": {
281
+ eyebrow: "Registry // Update saved SQLite target",
282
+ title: "Edit Connection",
283
+ body: renderEditConnectionForm(modal),
284
+ },
285
+ };
286
+
287
+ const config = contentByKind[modal.kind];
288
+
289
+ if (!config) {
290
+ return "";
291
+ }
292
+
293
+ return `
294
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-background/85 px-4 backdrop-blur-sm">
295
+ <div class="w-full max-w-xl border border-outline-variant/20 bg-surface-container shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
296
+ <div class="flex items-start justify-between gap-4 border-b border-outline-variant/10 bg-surface-container-low px-6 py-5">
297
+ <div>
298
+ <div class="text-[10px] font-mono uppercase tracking-[0.26em] text-primary-container/70">
299
+ ${escapeHtml(config.eyebrow)}
300
+ </div>
301
+ <h2 class="mt-2 font-headline text-3xl font-black uppercase tracking-tight text-primary-container">
302
+ ${escapeHtml(config.title)}
303
+ </h2>
304
+ </div>
305
+ <button
306
+ class="flex h-10 w-10 items-center justify-center border border-outline-variant/20 text-on-surface-variant hover:bg-surface-container-highest hover:text-primary-container"
307
+ data-action="close-modal"
308
+ type="button"
309
+ >
310
+ <span class="material-symbols-outlined">close</span>
311
+ </button>
312
+ </div>
313
+ <div class="space-y-5 px-6 py-6">${config.body}</div>
314
+ </div>
315
+ </div>
316
+ `;
317
+ }
@@ -0,0 +1,33 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderPageHeader({ eyebrow = "", title, subtitle = "", actions = "" }) {
4
+ return `
5
+ <div class="mb-10 flex flex-wrap items-end justify-between gap-6">
6
+ <div>
7
+ ${
8
+ eyebrow
9
+ ? `
10
+ <div class="page-eyebrow">
11
+ <span class="text-[#FCE300] text-xs font-mono font-bold tracking-widest uppercase">${escapeHtml(
12
+ eyebrow
13
+ )}</span>
14
+ <div class="page-eyebrow-line"></div>
15
+ </div>
16
+ `
17
+ : ""
18
+ }
19
+ <h1 class="text-5xl font-['Space_Grotesk'] font-bold text-[#FCE300] tracking-tighter uppercase">${escapeHtml(
20
+ title
21
+ )}</h1>
22
+ ${
23
+ subtitle
24
+ ? `<p class="text-xs font-mono text-on-surface/40 mt-1 uppercase tracking-widest">${escapeHtml(
25
+ subtitle
26
+ )}</p>`
27
+ : ""
28
+ }
29
+ </div>
30
+ ${actions ? `<div class="flex gap-3">${actions}</div>` : ""}
31
+ </div>
32
+ `;
33
+ }