sqlite-hub 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,7 @@ import {
46
46
  exportCurrentQueryFormat,
47
47
  getState,
48
48
  initializeApp,
49
+ insertMarkdownIntoLastOpenDocument,
49
50
  loadMoreQueryHistory,
50
51
  openModal,
51
52
  openOverviewInFinder,
@@ -239,6 +240,7 @@ let lastRenderedPanelMarkup = '';
239
240
  let lastRenderedModalMarkup = '';
240
241
  let lastRenderedToastMarkup = '';
241
242
  let lastRenderedChartsHistorySignature = '';
243
+ let lastRenderedChartsDetailSignature = '';
242
244
  let lastRenderedChartsCardSignature = '';
243
245
  let lastRenderedPanelOpen = false;
244
246
  let lastRenderedLockedRoute = false;
@@ -786,6 +788,27 @@ function buildChartsCardSignature(state) {
786
788
  });
787
789
  }
788
790
 
791
+ function buildChartsDetailSignature(state) {
792
+ if (state.route.name !== 'charts') {
793
+ return '';
794
+ }
795
+
796
+ const detail = state.charts.detail;
797
+ const historyVisible = state.charts.historyPanelVisible !== false || !state.charts.selectedHistoryId;
798
+
799
+ return JSON.stringify({
800
+ selectedHistoryId: state.charts.selectedHistoryId ?? null,
801
+ historyVisible,
802
+ detailLoading: Boolean(state.charts.detailLoading),
803
+ detailError: state.charts.detailError
804
+ ? [state.charts.detailError.code ?? '', state.charts.detailError.message ?? '']
805
+ : null,
806
+ hasDetailItem: Boolean(detail?.item),
807
+ displayTitle: detail?.item?.displayTitle ?? '',
808
+ cards: buildChartsCardSignature(state),
809
+ });
810
+ }
811
+
789
812
  function syncChartsHistorySelectionUi(state) {
790
813
  const selectedHistoryId = String(state.charts.selectedHistoryId ?? '');
791
814
  const historyButtons = shellRefs.view.querySelectorAll(
@@ -1368,6 +1391,7 @@ function renderApp(state) {
1368
1391
  const modalMarkup = renderModal(state);
1369
1392
  const toastMarkup = renderToasts(state.toasts);
1370
1393
  const chartsHistorySignature = buildChartsHistorySignature(state);
1394
+ const chartsDetailSignature = buildChartsDetailSignature(state);
1371
1395
  const chartsCardSignature = buildChartsCardSignature(state);
1372
1396
  const isLockedRoute = [
1373
1397
  'editor',
@@ -1388,6 +1412,7 @@ function renderApp(state) {
1388
1412
  const panelChanged = panel !== lastRenderedPanelMarkup;
1389
1413
  const modalChanged = modalMarkup !== lastRenderedModalMarkup;
1390
1414
  const chartsHistoryChanged = chartsHistorySignature !== lastRenderedChartsHistorySignature;
1415
+ const chartsDetailChanged = chartsDetailSignature !== lastRenderedChartsDetailSignature;
1391
1416
  const chartsCardsChanged = chartsCardSignature !== lastRenderedChartsCardSignature;
1392
1417
  const panelOpenChanged = panelOpen !== lastRenderedPanelOpen;
1393
1418
  const lockedRouteChanged = isLockedRoute !== lastRenderedLockedRoute;
@@ -1436,12 +1461,17 @@ function renderApp(state) {
1436
1461
  const historyPatched = !chartsHistoryChanged || patchChartsHistoryUi(state);
1437
1462
 
1438
1463
  if (historyPatched) {
1439
- if (chartsCardsChanged) {
1440
- teardownQueryChartRenderer();
1441
- }
1464
+ if (chartsDetailChanged) {
1465
+ if (chartsCardsChanged) {
1466
+ teardownQueryChartRenderer();
1467
+ }
1442
1468
 
1443
- preservedChartsDom = !chartsCardsChanged;
1444
- mainPatched = patchChartsDetailUi(state, { preserveCharts: preservedChartsDom });
1469
+ preservedChartsDom = !chartsCardsChanged;
1470
+ mainPatched = patchChartsDetailUi(state, { preserveCharts: preservedChartsDom });
1471
+ } else {
1472
+ preservedChartsDom = true;
1473
+ mainPatched = syncChartsHistorySelectionUi(state);
1474
+ }
1445
1475
  }
1446
1476
 
1447
1477
  if (!mainPatched) {
@@ -1542,6 +1572,7 @@ function renderApp(state) {
1542
1572
  lastRenderedModalMarkup = modalMarkup;
1543
1573
  lastRenderedToastMarkup = toastMarkup;
1544
1574
  lastRenderedChartsHistorySignature = chartsHistorySignature;
1575
+ lastRenderedChartsDetailSignature = chartsDetailSignature;
1545
1576
  lastRenderedChartsCardSignature = chartsCardSignature;
1546
1577
  lastRenderedPanelOpen = panelOpen;
1547
1578
  lastRenderedLockedRoute = isLockedRoute;
@@ -1567,6 +1598,22 @@ async function executeEditorQueryAndNavigate() {
1567
1598
  router.navigate(success && activeTab === 'results' ? '/editor/results' : '/editor');
1568
1599
  }
1569
1600
 
1601
+ function quoteSqlIdentifier(identifier) {
1602
+ return `"${String(identifier ?? '').replace(/"/g, '""')}"`;
1603
+ }
1604
+
1605
+ async function openTableInSqlEditor(tableName) {
1606
+ const normalizedTableName = String(tableName ?? '').trim();
1607
+
1608
+ if (!normalizedTableName) {
1609
+ showToast('No table selected for SQL Editor.', 'alert');
1610
+ return;
1611
+ }
1612
+
1613
+ setCurrentQuery(`SELECT * FROM ${quoteSqlIdentifier(normalizedTableName)};`);
1614
+ router.navigate('/editor');
1615
+ }
1616
+
1570
1617
  function formatCurrentQuery() {
1571
1618
  const currentQuery = getState().editor.sqlText ?? '';
1572
1619
  const formattedQuery = formatSqlQuery(currentQuery);
@@ -2103,6 +2150,39 @@ function exportRowEditorJson() {
2103
2150
  showToast(`Row from "${payload.label}" exported as JSON.`, 'success');
2104
2151
  }
2105
2152
 
2153
+ async function insertRowEditorJsonIntoDocument() {
2154
+ const payload = buildRowEditorJsonPayload(getState());
2155
+
2156
+ if (!payload) {
2157
+ showToast('No row is selected.', 'alert');
2158
+ return;
2159
+ }
2160
+
2161
+ const markdown = ['```json', payload.text, '```'].join('\n');
2162
+
2163
+ try {
2164
+ const result = await insertMarkdownIntoLastOpenDocument(markdown);
2165
+
2166
+ if (!result.documentId) {
2167
+ showToast('Open a document before inserting row JSON.', 'alert');
2168
+ return;
2169
+ }
2170
+
2171
+ if (!result.inserted) {
2172
+ showToast('Row JSON could not be inserted.', 'alert');
2173
+ return;
2174
+ }
2175
+
2176
+ if (!result.saved) {
2177
+ scheduleDocumentAutosave(result.documentId);
2178
+ }
2179
+
2180
+ showToast(`Row from "${payload.label}" inserted into document.`, 'success');
2181
+ } catch (error) {
2182
+ showToast('Open a document before inserting row JSON.', 'alert');
2183
+ }
2184
+ }
2185
+
2106
2186
  function exportCurrentDocumentMarkdown() {
2107
2187
  const documents = getState().documents;
2108
2188
 
@@ -2319,6 +2399,9 @@ async function handleAction(actionNode) {
2319
2399
  case 'export-row-editor-json':
2320
2400
  exportRowEditorJson();
2321
2401
  return;
2402
+ case 'insert-row-editor-json-into-document':
2403
+ await insertRowEditorJsonIntoDocument();
2404
+ return;
2322
2405
  case 'open-modal':
2323
2406
  openModal(actionNode.dataset.modal, {
2324
2407
  columnId: actionNode.dataset.columnId,
@@ -2496,6 +2579,9 @@ async function handleAction(actionNode) {
2496
2579
  case 'refresh-backups':
2497
2580
  await refreshBackups();
2498
2581
  return;
2582
+ case 'open-table-in-sql-editor':
2583
+ await openTableInSqlEditor(actionNode.dataset.tableName);
2584
+ return;
2499
2585
  case 'open-restore-backup-modal':
2500
2586
  openRestoreBackupModal(actionNode.dataset.backupId);
2501
2587
  return;
@@ -147,7 +147,7 @@ export function renderQueryResultsPane(
147
147
  ? renderDataGrid({
148
148
  columns,
149
149
  rows: result.rows ?? [],
150
- tableClass: "min-w-full border-collapse text-left font-mono text-xs",
150
+ tableClass: "data-table min-w-full border-collapse text-left font-mono text-xs",
151
151
  theadClass: "sticky top-0 z-10 bg-surface-container-highest text-on-surface",
152
152
  tbodyClass: "divide-y divide-outline-variant/5",
153
153
  getRowClass: (_, index) =>
@@ -1,4 +1,6 @@
1
1
  import { escapeHtml } from "../utils/format.js";
2
+ import { renderDropdownButton } from "./dropdownButton.js";
3
+ import { detectEmailValue } from "../utils/emailPreview.js";
2
4
  import {
3
5
  compactPathForDisplay,
4
6
  detectFilePathValue,
@@ -49,6 +51,19 @@ function withUrlBadge(badges = [], url) {
49
51
  return hasUrlBadge ? badges : [...badges, { label: "URL", tone: "url" }];
50
52
  }
51
53
 
54
+ function withEmailBadge(badges = [], email) {
55
+ if (!email) {
56
+ return badges;
57
+ }
58
+
59
+ const hasEmailBadge = badges.some((badge) => {
60
+ const label = typeof badge === "object" ? badge.label : badge;
61
+ return String(label ?? "").toUpperCase() === "EMAIL";
62
+ });
63
+
64
+ return hasEmailBadge ? badges : [...badges, { label: "EMAIL", tone: "email" }];
65
+ }
66
+
52
67
  function getAllowedValues(field) {
53
68
  const seen = new Set();
54
69
 
@@ -277,7 +292,11 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
277
292
  const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
278
293
  const filePathPreview = getFieldFilePathPreview({ ...field, rawValue }, tableMeta);
279
294
  const url = getUrlValue(value);
280
- const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
295
+ const email = detectEmailValue(rawValue);
296
+ const badges = withEmailBadge(
297
+ withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview),
298
+ email
299
+ );
281
300
  const displayLabel = typeof label === "object" ? label.label : label;
282
301
  const jsonPreview = formatJsonPreview(value);
283
302
  const valueState = getRowEditorValueState(rawValue);
@@ -290,7 +309,9 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
290
309
  data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
291
310
  ${
292
311
  url ? "data-row-editor-url-field" : ""
293
- }>
312
+ }
313
+ ${email ? "data-row-editor-email-field" : ""}
314
+ >
294
315
  <div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
295
316
  <span>${escapeHtml(displayLabel)}</span>
296
317
  ${renderFieldBadgesWithCharacterCount(badges, rawValue)}
@@ -334,10 +355,11 @@ function renderEditableField(field, tableMeta = {}) {
334
355
  const columnName = getFieldColumnName(field);
335
356
  const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
336
357
  const url = getUrlValue(field.value);
358
+ const email = detectEmailValue(getFieldRawValue(field));
337
359
  const allowedValues = getAllowedValues(field);
338
360
  const filePathPreview = getFieldFilePathPreview(field, tableMeta);
339
361
  const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
340
- const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
362
+ const badges = withEmailBadge(withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview), email);
341
363
  const jsonPreview = formatJsonPreview(field.value);
342
364
  const inputType = field.inputType === "number" ? "number" : "text";
343
365
  const numberStep = field.numberStep === "1" ? "1" : "any";
@@ -351,6 +373,7 @@ function renderEditableField(field, tableMeta = {}) {
351
373
  data-row-editor-initial-state="${escapeHtml(valueState)}"
352
374
  data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
353
375
  ${url ? "data-row-editor-url-field" : ""}
376
+ ${email ? "data-row-editor-email-field" : ""}
354
377
  >
355
378
  <span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
356
379
  <span>${escapeHtml(field.label ?? field.name)}</span>
@@ -427,6 +450,10 @@ function getFieldBadgeClassName(tone) {
427
450
  return "border-primary-container/35 bg-primary-container/15 text-primary-container";
428
451
  }
429
452
 
453
+ if (tone === "email") {
454
+ return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
455
+ }
456
+
430
457
  if (tone === "check") {
431
458
  return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
432
459
  }
@@ -488,16 +515,28 @@ export function renderRowEditorPanel({
488
515
  ].join("")
489
516
  : "",
490
517
  jsonActionsEnabled
491
- ? [
492
- '<button class="standard-button" data-action="copy-row-editor-json" type="button">',
493
- '<span class="material-symbols-outlined text-sm">content_copy</span>',
494
- "Copy as JSON",
495
- "</button>",
496
- '<button class="standard-button" data-action="export-row-editor-json" type="button">',
497
- '<span class="material-symbols-outlined text-sm">download</span>',
498
- "Export as JSON",
499
- "</button>",
500
- ].join("")
518
+ ? renderDropdownButton({
519
+ icon: "download",
520
+ label: "Export",
521
+ title: "Export row",
522
+ items: [
523
+ {
524
+ action: "copy-row-editor-json",
525
+ icon: "content_copy",
526
+ label: "Copy to clipboard",
527
+ },
528
+ {
529
+ action: "export-row-editor-json",
530
+ icon: "download",
531
+ label: "JSON file",
532
+ },
533
+ {
534
+ action: "insert-row-editor-json-into-document",
535
+ icon: "description",
536
+ label: "Markdown document",
537
+ },
538
+ ],
539
+ })
501
540
  : "",
502
541
  canSubmit
503
542
  ? [
@@ -1,11 +1,8 @@
1
1
  export function renderTopNav() {
2
- return `
2
+ return `
3
3
  <div class="top-nav-shell">
4
4
  <a class="top-nav-brand" href="#/">SQLite Hub</a>
5
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
6
  <button
10
7
  class="top-nav-icon"
11
8
  data-action="open-modal"
@@ -0,0 +1,52 @@
1
+ import { renderDropdownButton } from "./dropdownButton.js";
2
+
3
+ function buildOpenItem({ disabled, icon, label, target, tableName }) {
4
+ return {
5
+ action: "navigate",
6
+ dataAttributes: {
7
+ to: target,
8
+ },
9
+ disabled,
10
+ icon,
11
+ label,
12
+ };
13
+ }
14
+
15
+ export function renderWorkspaceOpenDropdown({
16
+ align = "right",
17
+ destinations = [],
18
+ disabled = false,
19
+ tableName = "",
20
+ } = {}) {
21
+ const safeTableName = String(tableName ?? "").trim();
22
+ const items = destinations.map((destination) => {
23
+ if (destination.key === "sql-editor") {
24
+ return {
25
+ action: "open-table-in-sql-editor",
26
+ dataAttributes: {
27
+ tableName: safeTableName,
28
+ },
29
+ disabled: disabled || !safeTableName,
30
+ icon: "terminal",
31
+ label: "SQL Editor",
32
+ };
33
+ }
34
+
35
+ return buildOpenItem({
36
+ disabled: disabled || !safeTableName,
37
+ icon: destination.icon,
38
+ label: destination.label,
39
+ target: destination.target(safeTableName),
40
+ tableName: safeTableName,
41
+ });
42
+ });
43
+
44
+ return renderDropdownButton({
45
+ align,
46
+ disabled: disabled || !safeTableName,
47
+ icon: "open_in_new",
48
+ items,
49
+ label: "Open",
50
+ title: safeTableName ? `Open ${safeTableName}` : "Open table",
51
+ });
52
+ }
@@ -82,6 +82,7 @@ let chartsLoadVersion = 0;
82
82
  let chartsDetailLoadVersion = 0;
83
83
  let mediaTaggingPreviewVersion = 0;
84
84
  let documentsLoadVersion = 0;
85
+ let lastOpenDocumentId = null;
85
86
 
86
87
  function readStoredDataPageSize(fallback = DEFAULT_DATA_PAGE_SIZE) {
87
88
  try {
@@ -1003,6 +1004,10 @@ function applyCurrentDocument(document) {
1003
1004
  state.documents.draftContent = document?.content ?? '';
1004
1005
  state.documents.dirty = false;
1005
1006
  state.documents.saveError = null;
1007
+
1008
+ if (document?.id) {
1009
+ lastOpenDocumentId = document.id;
1010
+ }
1006
1011
  }
1007
1012
 
1008
1013
  function upsertDocumentSummary(document) {
@@ -2425,6 +2430,10 @@ async function previewMediaTaggingDraft(options = {}) {
2425
2430
  function invalidateDatabaseCaches(options = {}) {
2426
2431
  const preserveDataBrowserState = options.preserveDataBrowserState === true;
2427
2432
 
2433
+ if (!preserveDataBrowserState) {
2434
+ lastOpenDocumentId = null;
2435
+ }
2436
+
2428
2437
  state.overview.data = null;
2429
2438
  state.dataBrowser.tables = [];
2430
2439
  if (!preserveDataBrowserState) {
@@ -2932,7 +2941,7 @@ function buildDocumentMarkdownInsertion(currentContent, insertion, range = null)
2932
2941
  return `${before}${prefix}${text}${suffix}${after}`;
2933
2942
  }
2934
2943
 
2935
- function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2944
+ export function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2936
2945
  if (!state.documents.selectedId) {
2937
2946
  return false;
2938
2947
  }
@@ -2950,6 +2959,63 @@ function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2950
2959
  return true;
2951
2960
  }
2952
2961
 
2962
+ export async function insertMarkdownIntoLastOpenDocument(markdown) {
2963
+ if (state.route.name === 'documents' && state.documents.selectedId) {
2964
+ const inserted = insertMarkdownIntoCurrentDocumentDraft(markdown);
2965
+
2966
+ return {
2967
+ documentId: state.documents.selectedId,
2968
+ inserted,
2969
+ saved: false,
2970
+ };
2971
+ }
2972
+
2973
+ const documentId = lastOpenDocumentId ?? state.documents.selectedId;
2974
+
2975
+ if (!documentId) {
2976
+ return {
2977
+ documentId: null,
2978
+ inserted: false,
2979
+ saved: false,
2980
+ };
2981
+ }
2982
+
2983
+ const response = await api.getDocument(documentId);
2984
+ const document = response.data ?? null;
2985
+
2986
+ if (!document?.id) {
2987
+ return {
2988
+ documentId: null,
2989
+ inserted: false,
2990
+ saved: false,
2991
+ };
2992
+ }
2993
+
2994
+ const nextContent = buildDocumentMarkdownInsertion(document.content, markdown);
2995
+
2996
+ if (nextContent === document.content) {
2997
+ return {
2998
+ documentId: document.id,
2999
+ inserted: false,
3000
+ saved: false,
3001
+ };
3002
+ }
3003
+
3004
+ const updateResponse = await api.updateDocument(document.id, {
3005
+ content: nextContent,
3006
+ });
3007
+ const updatedDocument = updateResponse.data ?? document;
3008
+
3009
+ lastOpenDocumentId = updatedDocument.id;
3010
+ upsertDocumentSummary(updatedDocument);
3011
+
3012
+ return {
3013
+ documentId: updatedDocument.id,
3014
+ inserted: true,
3015
+ saved: true,
3016
+ };
3017
+ }
3018
+
2953
3019
  function getDocumentInsertQueryTitle(query) {
2954
3020
  return query?.displayTitle || query?.title || query?.previewSql || query?.rawSql || 'Saved query';
2955
3021
  }
@@ -0,0 +1,28 @@
1
+ const EMAIL_PATTERN = /^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/;
2
+
3
+ export function detectEmailValue(value) {
4
+ if (typeof value !== "string") {
5
+ return null;
6
+ }
7
+
8
+ const text = value.trim();
9
+
10
+ if (!text || text.length > 320 || !EMAIL_PATTERN.test(text)) {
11
+ return null;
12
+ }
13
+
14
+ const atIndex = text.lastIndexOf("@");
15
+ const localPart = text.slice(0, atIndex);
16
+ const domain = text.slice(atIndex + 1);
17
+
18
+ if (!localPart || !domain || domain.startsWith(".") || domain.endsWith(".")) {
19
+ return null;
20
+ }
21
+
22
+ return {
23
+ type: "email",
24
+ value: text,
25
+ localPart,
26
+ domain,
27
+ };
28
+ }
@@ -83,8 +83,24 @@ function renderFallbackMarkdown(markdown = '') {
83
83
  return html.join('\n');
84
84
  }
85
85
 
86
+ function escapeMarkdownHtmlOutsideFences(markdown = '') {
87
+ const lines = String(markdown ?? '').split('\n');
88
+ let inFence = false;
89
+
90
+ return lines
91
+ .map(line => {
92
+ if (FENCED_CODE_PATTERN.test(line)) {
93
+ inFence = !inFence;
94
+ return line;
95
+ }
96
+
97
+ return inFence ? line : escapeHtml(line);
98
+ })
99
+ .join('\n');
100
+ }
101
+
86
102
  function renderMarkdownBlock(markdown = '') {
87
- const source = escapeHtml(markdown);
103
+ const source = escapeMarkdownHtmlOutsideFences(markdown);
88
104
  const markedRuntime = globalThis.marked;
89
105
  const parser = typeof markedRuntime?.parse === 'function' ? markedRuntime.parse.bind(markedRuntime) : null;
90
106
 
@@ -28,6 +28,29 @@ function isBackupBusy(backup, state) {
28
28
  return Boolean(state.backups.operationLoading) || ['creating', 'verifying', 'restoring'].includes(backup.status);
29
29
  }
30
30
 
31
+ function renderBackupMetadataItem(label, value, extraClass = '') {
32
+ return `
33
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2 ${extraClass}">
34
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">${escapeHtml(label)}</div>
35
+ <div class="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">${escapeHtml(value)}</div>
36
+ </div>
37
+ `;
38
+ }
39
+
40
+ function renderBackupMetadata(backup) {
41
+ return `
42
+ <div class="grid min-w-[24rem] grid-cols-2 gap-2">
43
+ ${renderBackupMetadataItem('Size', formatBytes(backup.sizeBytes))}
44
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
45
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Status</div>
46
+ <div class="mt-1">${renderBackupStatus(backup)}</div>
47
+ </div>
48
+ ${renderBackupMetadataItem('SQLite Hub', backup.sqliteHubVersion ? `v${backup.sqliteHubVersion}` : 'n/a')}
49
+ ${renderBackupMetadataItem('SQLite', backup.sqliteVersion ? `v${backup.sqliteVersion}` : 'n/a')}
50
+ </div>
51
+ `;
52
+ }
53
+
31
54
  function renderBackupRows(state) {
32
55
  return state.backups.items
33
56
  .map(backup => {
@@ -39,27 +62,34 @@ function renderBackupRows(state) {
39
62
 
40
63
  return `
41
64
  <tr class="border-b border-outline-variant/10 align-top">
42
- <td class="px-4 py-4 text-xs font-mono text-on-surface/80">${escapeHtml(formatCompactDateTime(backup.createdAt))}</td>
43
- <td class="px-4 py-4">
44
- <div class="font-headline text-sm font-black uppercase text-on-surface">${escapeHtml(backup.name)}</div>
45
- <div class="mt-1 font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/45" title="${escapeHtml(backup.path)}">
46
- ${escapeHtml(truncateMiddle(backup.fileName || backup.path, 44))}
65
+ <td class="min-w-[20rem] px-4 py-5">
66
+ <div class="space-y-2">
67
+ <div class="font-headline text-sm font-black uppercase text-on-surface">${escapeHtml(backup.name)}</div>
68
+ <div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/45" title="${escapeHtml(backup.path)}">
69
+ ${escapeHtml(truncateMiddle(backup.fileName || backup.path, 48))}
70
+ </div>
71
+ <div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/55">
72
+ Created // ${escapeHtml(formatCompactDateTime(backup.createdAt))}
73
+ </div>
47
74
  </div>
48
75
  </td>
49
- <td class="px-4 py-4 text-right text-xs font-mono text-on-surface/70">${escapeHtml(formatBytes(backup.sizeBytes))}</td>
50
- <td class="px-4 py-4">${renderBackupStatus(backup)}</td>
51
- <td class="max-w-[18rem] px-4 py-4 text-xs leading-6 text-on-surface-variant/70">
52
- ${backup.notes ? escapeHtml(backup.notes) : '<span class="text-on-surface-variant/35">-</span>'}
53
- ${backup.errorMessage ? `<div class="mt-2 text-error">${escapeHtml(backup.errorMessage)}</div>` : ''}
54
- <div class="mt-3">
76
+ <td class="px-4 py-5">
77
+ ${renderBackupMetadata(backup)}
78
+ </td>
79
+ <td class="min-w-[18rem] max-w-[24rem] px-4 py-5">
80
+ <div class="flex h-full flex-col items-start gap-3">
81
+ <div class="text-xs leading-6 text-on-surface-variant/70">
82
+ ${backup.notes ? escapeHtml(backup.notes) : '<span class="text-on-surface-variant/35">No notes</span>'}
83
+ ${backup.errorMessage ? `<div class="mt-2 text-error">${escapeHtml(backup.errorMessage)}</div>` : ''}
84
+ </div>
55
85
  <button class="standard-button" data-action="open-edit-backup-notes-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canEditNotes ? '' : 'disabled'}>
56
86
  <span class="material-symbols-outlined text-sm">edit_note</span>
57
87
  Edit Notes
58
88
  </button>
59
89
  </div>
60
90
  </td>
61
- <td class="px-4 py-4">
62
- <div class="flex flex-wrap items-center justify-end gap-2">
91
+ <td class="px-4 py-5">
92
+ <div class="flex min-w-[10rem] flex-col items-stretch gap-2">
63
93
  <button class="standard-button" data-action="open-restore-backup-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canRestore ? '' : 'disabled'}>
64
94
  <span class="material-symbols-outlined text-sm">restore</span>
65
95
  Restore
@@ -128,10 +158,8 @@ function renderBackupsBody(state) {
128
158
  <table class="min-w-full border-collapse text-left">
129
159
  <thead class="border-b border-outline-variant/10 bg-surface-container">
130
160
  <tr class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
131
- <th class="px-4 py-3 font-normal">Created</th>
132
- <th class="px-4 py-3 font-normal">Name</th>
133
- <th class="px-4 py-3 text-right font-normal">Size</th>
134
- <th class="px-4 py-3 font-normal">Status</th>
161
+ <th class="px-4 py-3 font-normal">Backup</th>
162
+ <th class="px-4 py-3 font-normal">Metadata</th>
135
163
  <th class="px-4 py-3 font-normal">Note</th>
136
164
  <th class="px-4 py-3 text-right font-normal">Actions</th>
137
165
  </tr>