sqlite-hub 0.9.3 → 0.9.6

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 (48) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +2 -158
  5. package/frontend/js/app.js +41 -22
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +145 -195
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +81 -99
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +28 -31
  15. package/frontend/js/components/structureGraph.js +10 -9
  16. package/frontend/js/components/tableDesignerEditor.js +91 -116
  17. package/frontend/js/store.js +39 -3
  18. package/frontend/js/utils/dom.js +28 -0
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +23 -43
  21. package/frontend/js/views/data.js +116 -132
  22. package/frontend/js/views/mediaTagging.js +131 -164
  23. package/frontend/js/views/structure.js +52 -48
  24. package/frontend/styles/tailwind.css +80 -0
  25. package/frontend/styles/tailwind.generated.css +2 -0
  26. package/frontend/styles/tokens.css +3 -3
  27. package/package.json +19 -5
  28. package/server/routes/mediaTagging.js +2 -10
  29. package/server/routes/sql.js +35 -10
  30. package/server/server.js +24 -0
  31. package/server/services/sqlite/dataBrowserService.js +25 -5
  32. package/server/services/sqlite/exportService.js +4 -2
  33. package/server/services/sqlite/introspection.js +2 -2
  34. package/server/services/sqlite/mediaTaggingService.js +166 -53
  35. package/server/services/sqlite/structureService.js +2 -2
  36. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  37. package/server/services/storage/appStateStore.js +227 -87
  38. package/server/utils/appPaths.js +55 -19
  39. package/server/utils/fileValidation.js +94 -8
  40. package/tailwind.config.cjs +73 -0
  41. package/tests/security-paths.test.js +84 -0
  42. package/tests/sql-identifier-safety.test.js +66 -0
  43. package/.npmingnore +0 -4
  44. package/changelog.md +0 -84
  45. package/docs/DESIGN_GUIDELINES.md +0 -36
  46. package/scripts/publish_brew.sh +0 -466
  47. package/scripts/publish_npm.sh +0 -241
  48. package/shortkeys.md +0 -5
@@ -241,38 +241,34 @@ function renderTableSearchBar(table, state, activeColumn, filteredRowCount) {
241
241
  if (!table || !columns.length) {
242
242
  return '';
243
243
  }
244
-
245
- return `
246
- <div class="flex flex-wrap items-center gap-3 border-b border-outline-variant/10 bg-surface-container-low px-6 py-4">
247
- <label class="control-shell flex min-w-[18rem] flex-1 items-center gap-3 border border-outline-variant/20 bg-surface-container-lowest px-3">
248
- <span class="material-symbols-outlined text-base text-on-surface-variant/55">search</span>
249
- <input
250
- class="control-input control-input--ghost min-w-0 flex-1 text-sm text-on-surface outline-none placeholder:text-on-surface-variant/40"
251
- data-bind="data-search-query"
252
- placeholder="Filter current page..."
253
- type="search"
254
- value="${escapeHtml(state.dataBrowser.searchQuery ?? '')}"
255
- />
256
- </label>
257
- <select
258
- class="control-select min-w-[14rem] border border-outline-variant/20 bg-surface-container-lowest font-mono text-xs tracking-[0.04em] text-on-surface outline-none"
259
- data-bind="data-search-column"
260
- >
261
- ${columns
262
- .map(
263
- columnName => `
264
- <option value="${escapeHtml(columnName)}" ${columnName === activeColumn ? 'selected' : ''}>
265
- ${escapeHtml(columnName)}
266
- </option>
267
- `,
268
- )
269
- .join('')}
270
- </select>
271
- <div class="text-[10px] font-mono tracking-[0.14em] text-on-surface-variant/55">
272
- ${escapeHtml(formatNumber(filteredRowCount))} match${filteredRowCount === 1 ? '' : 'es'} on this page
273
- </div>
274
- </div>
275
- `;
244
+ const columnOptions = columns
245
+ .map(columnName => [
246
+ '<option value="',
247
+ escapeHtml(columnName),
248
+ '" ',
249
+ columnName === activeColumn ? 'selected' : '',
250
+ '>',
251
+ escapeHtml(columnName),
252
+ '</option>',
253
+ ].join(''))
254
+ .join('');
255
+
256
+ return [
257
+ '<div class="flex flex-wrap items-center gap-3 border-b border-outline-variant/10 bg-surface-container-low px-6 py-4">',
258
+ '<label class="control-shell flex min-w-[18rem] flex-1 items-center gap-3 border border-outline-variant/20 bg-surface-container-lowest px-3">',
259
+ '<span class="material-symbols-outlined text-base text-on-surface-variant/55">search</span>',
260
+ '<input class="control-input control-input--ghost min-w-0 flex-1 text-sm text-on-surface outline-none placeholder:text-on-surface-variant/40" data-bind="data-search-query" placeholder="Filter current page..." type="search" value="',
261
+ escapeHtml(state.dataBrowser.searchQuery ?? ''),
262
+ '" /></label>',
263
+ '<select class="control-select min-w-[14rem] border border-outline-variant/20 bg-surface-container-lowest font-mono text-xs tracking-[0.04em] text-on-surface outline-none" data-bind="data-search-column">',
264
+ columnOptions,
265
+ '</select>',
266
+ '<div class="text-[10px] font-mono tracking-[0.14em] text-on-surface-variant/55">',
267
+ escapeHtml(formatNumber(filteredRowCount)),
268
+ ' match',
269
+ filteredRowCount === 1 ? '' : 'es',
270
+ ' on this page</div></div>',
271
+ ].join('');
276
272
  }
277
273
 
278
274
  function renderTableSurface(state) {
@@ -331,107 +327,95 @@ function renderTableSurface(state) {
331
327
  const pageSizes = [25, 50, 100];
332
328
  const filteredRowCount = filteredRows.length;
333
329
  const hasActiveSearch = Boolean(searchQuery);
330
+ const gridMarkup = renderDataGrid({
331
+ columns,
332
+ rows: filteredRows.map(({ row }) => row),
333
+ tableClass: 'min-w-full border-collapse text-left font-mono text-xs',
334
+ theadClass: 'sticky top-0 z-10 bg-surface-container-highest',
335
+ tbodyClass: 'divide-y divide-outline-variant/5',
336
+ getRowClass: (_, filteredIndex) => {
337
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
338
+
339
+ return [
340
+ 'data-browser-row',
341
+ filteredIndex % 2 === 0 ? 'data-browser-row--even' : 'data-browser-row--odd',
342
+ state.dataBrowser.selectedRowIndex === rowIndex ? 'is-selected' : '',
343
+ 'cursor-pointer transition-colors',
344
+ ].filter(Boolean).join(' ');
345
+ },
346
+ getRowAttrs: (_, filteredIndex) => {
347
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
334
348
 
335
- return `
336
- <div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">
337
- ${renderTableSearchBar(table, state, activeColumn, filteredRowCount)}
338
- <div class="custom-scrollbar flex-1 overflow-auto">
339
- ${renderDataGrid({
340
- columns,
341
- rows: filteredRows.map(({ row }) => row),
342
- tableClass: 'min-w-full border-collapse text-left font-mono text-xs',
343
- theadClass: 'sticky top-0 z-10 bg-surface-container-highest',
344
- tbodyClass: 'divide-y divide-outline-variant/5',
345
- getRowClass: (_, filteredIndex) => {
346
- const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
347
-
348
- return `data-browser-row ${
349
- filteredIndex % 2 === 0 ? 'data-browser-row--even' : 'data-browser-row--odd'
350
- } ${state.dataBrowser.selectedRowIndex === rowIndex ? 'is-selected' : ''} cursor-pointer transition-colors`;
351
- },
352
- getRowAttrs: (_, filteredIndex) => {
353
- const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
354
-
355
- return `data-action="select-data-row" data-row-index="${rowIndex}"`;
356
- },
357
- })}
358
- ${
359
- !table.rows?.length
360
- ? `
361
- <div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">
362
- <p class="font-mono text-[10px] uppercase tracking-[0.22em] text-on-surface-variant/40">
363
- TABLE_IS_EMPTY
364
- </p>
365
- </div>
366
- `
367
- : !filteredRowCount
368
- ? `
369
- <div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">
370
- <p class="font-mono text-[10px] tracking-[0.18em] text-on-surface-variant/40">
371
- ${hasActiveSearch ? 'No matching rows on this page.' : 'No rows available.'}
372
- </p>
373
- </div>
374
- `
375
- : ''
376
- }
377
- </div>
378
- <footer class="flex flex-wrap items-center justify-between gap-4 border-t border-outline-variant/10 bg-surface-container px-6 py-4">
379
- <div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
380
- showing ${escapeHtml(formatNumber(fromRow))}-${escapeHtml(formatNumber(toRow))} of ${escapeHtml(
381
- formatNumber(totalRows),
382
- )} rows${hasActiveSearch ? ` // ${escapeHtml(formatNumber(filteredRowCount))} visible on this page` : ''}
383
- </div>
384
- <div class="flex flex-wrap items-center gap-4">
385
- <div class="flex items-center gap-2">
386
- <span class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
387
- rows
388
- </span>
389
- <div class="flex items-center gap-2">
390
- ${pageSizes
391
- .map(
392
- pageSize => `
393
- <button
394
- class="standard-button ${
395
- pageSize === (table.limit ?? state.dataBrowser.pageSize) ? 'is-active' : ''
396
- }"
397
- data-action="set-data-page-size"
398
- data-page-size="${pageSize}"
399
- type="button"
400
- >
401
- ${pageSize}
402
- </button>
403
- `,
404
- )
405
- .join('')}
406
- </div>
407
- </div>
408
- <div class="flex items-center gap-2">
409
- <button
410
- class="standard-button"
411
- data-action="set-data-page"
412
- data-page="${page - 1}"
413
- type="button"
414
- ${page <= 1 ? 'disabled' : ''}
415
- >
416
- Prev
417
- </button>
418
- <div class="min-w-[7rem] text-center text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
419
- page ${escapeHtml(formatNumber(page))} / ${escapeHtml(formatNumber(pageCount))}
420
- </div>
421
- <button
422
- class="standard-button"
423
- data-action="set-data-page"
424
- data-page="${page + 1}"
425
- type="button"
426
- ${page >= pageCount ? 'disabled' : ''}
427
- >
428
- Next
429
- </button>
430
- </div>
431
- </div>
432
- </footer>
433
- </div>
434
- `;
349
+ return ['data-action="select-data-row" data-row-index="', rowIndex, '"'].join('');
350
+ },
351
+ });
352
+ const emptyMarkup = !table.rows?.length
353
+ ? '<div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10"><p class="font-mono text-[10px] uppercase tracking-[0.22em] text-on-surface-variant/40">TABLE_IS_EMPTY</p></div>'
354
+ : !filteredRowCount
355
+ ? [
356
+ '<div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">',
357
+ '<p class="font-mono text-[10px] tracking-[0.18em] text-on-surface-variant/40">',
358
+ hasActiveSearch ? 'No matching rows on this page.' : 'No rows available.',
359
+ '</p></div>',
360
+ ].join('')
361
+ : '';
362
+ const visibleRowsText = hasActiveSearch
363
+ ? [' // ', escapeHtml(formatNumber(filteredRowCount)), ' visible on this page'].join('')
364
+ : '';
365
+ const pageSizeButtons = pageSizes
366
+ .map(pageSize =>
367
+ [
368
+ '<button class="standard-button ',
369
+ pageSize === (table.limit ?? state.dataBrowser.pageSize) ? 'is-active' : '',
370
+ '" data-action="set-data-page-size" data-page-size="',
371
+ pageSize,
372
+ '" type="button">',
373
+ pageSize,
374
+ '</button>',
375
+ ].join(''),
376
+ )
377
+ .join('');
378
+
379
+ return [
380
+ '<div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">',
381
+ renderTableSearchBar(table, state, activeColumn, filteredRowCount),
382
+ '<div class="custom-scrollbar flex-1 overflow-auto">',
383
+ gridMarkup,
384
+ emptyMarkup,
385
+ '</div>',
386
+ '<footer class="flex flex-wrap items-center justify-between gap-4 border-t border-outline-variant/10 bg-surface-container px-6 py-4">',
387
+ '<div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">showing ',
388
+ escapeHtml(formatNumber(fromRow)),
389
+ '-',
390
+ escapeHtml(formatNumber(toRow)),
391
+ ' of ',
392
+ escapeHtml(formatNumber(totalRows)),
393
+ ' rows',
394
+ visibleRowsText,
395
+ '</div>',
396
+ '<div class="flex flex-wrap items-center gap-4"><div class="flex items-center gap-2">',
397
+ '<span class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">rows</span>',
398
+ '<div class="flex items-center gap-2">',
399
+ pageSizeButtons,
400
+ '</div></div>',
401
+ '<div class="flex items-center gap-2">',
402
+ '<button class="standard-button" data-action="set-data-page" data-page="',
403
+ page - 1,
404
+ '" type="button" ',
405
+ page <= 1 ? 'disabled' : '',
406
+ '>Prev</button>',
407
+ '<div class="min-w-[7rem] text-center text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">page ',
408
+ escapeHtml(formatNumber(page)),
409
+ ' / ',
410
+ escapeHtml(formatNumber(pageCount)),
411
+ '</div>',
412
+ '<button class="standard-button" data-action="set-data-page" data-page="',
413
+ page + 1,
414
+ '" type="button" ',
415
+ page >= pageCount ? 'disabled' : '',
416
+ '>Next</button>',
417
+ '</div></div></footer></div>',
418
+ ].join('');
435
419
  }
436
420
 
437
421
  export function renderDataRowEditorPanel(state) {
@@ -180,52 +180,39 @@ function renderParentTagFields({
180
180
 
181
181
  const isCreatingParentTag = Boolean(parentToggleColumn ? tagFormValues[parentToggleColumn.name] : false);
182
182
  const selectedParentTagId = parentSelectColumn ? String(tagFormValues[parentSelectColumn.name] ?? '') : '';
183
-
184
- return `
185
- ${
186
- parentSelectColumn
187
- ? `
188
- <label class="media-tagging-field">
189
- <span class="media-tagging-field__label">Parent Tag</span>
190
- <select
191
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
192
- data-bind="media-tagging-tag-form-field"
193
- data-field="${escapeHtml(parentSelectColumn.name)}"
194
- ${isCreatingParentTag ? 'disabled' : ''}
195
- >
196
- ${renderOptionList(
197
- parentTagOptions.map(tag => ({
198
- value: String(tag.identityValue ?? ''),
199
- label: tag.label,
200
- })),
201
- selectedParentTagId,
202
- parentTagOptions.length ? 'Select a parent tag' : 'No parent tags available',
203
- )}
204
- </select>
205
- </label>
206
- `
207
- : ''
208
- }
209
- ${
210
- parentToggleColumn
211
- ? `
212
- <div class="media-tagging-field">
213
- <label class="standard-checkbox table-designer-check table-designer-checkbox-override">
214
- <input
215
- data-bind="media-tagging-tag-form-field"
216
- data-field="${escapeHtml(parentToggleColumn.name)}"
217
- type="checkbox"
218
- ${isCreatingParentTag ? 'checked' : ''}
219
- />
220
- <span class="media-tagging-field__meta">
221
- <span class="media-tagging-field__label">Create Parent Tag</span>
222
- </span>
223
- </label>
224
- </div>
225
- `
226
- : ''
227
- }
228
- `;
183
+ const parentSelectMarkup = parentSelectColumn
184
+ ? [
185
+ '<label class="media-tagging-field"><span class="media-tagging-field__label">Parent Tag</span>',
186
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-tag-form-field" data-field="',
187
+ escapeHtml(parentSelectColumn.name),
188
+ '" ',
189
+ isCreatingParentTag ? 'disabled' : '',
190
+ '>',
191
+ renderOptionList(
192
+ parentTagOptions.map(tag => ({
193
+ value: String(tag.identityValue ?? ''),
194
+ label: tag.label,
195
+ })),
196
+ selectedParentTagId,
197
+ parentTagOptions.length ? 'Select a parent tag' : 'No parent tags available',
198
+ ),
199
+ '</select></label>',
200
+ ].join('')
201
+ : '';
202
+ const parentToggleMarkup = parentToggleColumn
203
+ ? [
204
+ '<div class="media-tagging-field"><label class="standard-checkbox table-designer-check table-designer-checkbox-override">',
205
+ '<input data-bind="media-tagging-tag-form-field" data-field="',
206
+ escapeHtml(parentToggleColumn.name),
207
+ '" type="checkbox" ',
208
+ isCreatingParentTag ? 'checked' : '',
209
+ '/>',
210
+ '<span class="media-tagging-field__meta"><span class="media-tagging-field__label">Create Parent Tag</span></span>',
211
+ '</label></div>',
212
+ ].join('')
213
+ : '';
214
+
215
+ return [parentSelectMarkup, parentToggleMarkup].join('');
229
216
  }
230
217
 
231
218
  function renderTagList(tags = [], selectedTagKeys = []) {
@@ -285,42 +272,40 @@ function renderCreatedTagList(tags = [], { canRemove = false, removingTagKey = n
285
272
  return `
286
273
  <div class="media-tagging-created-tags custom-scrollbar">
287
274
  ${tags
288
- .map(
289
- tag => `
290
- <article class="media-tagging-created-tag">
291
- <div class="media-tagging-created-tag__content">
292
- <div class="media-tagging-created-tag__label">${escapeHtml(tag.label)}</div>
293
- ${
294
- tag.isParentTag || tag.parentTagLabel
295
- ? `
296
- <div class="media-tagging-created-tag__meta">
297
- ${tag.isParentTag ? '<span class="media-tagging-created-tag__badge">Parent</span>' : ''}
298
- ${
299
- tag.parentTagLabel
300
- ? `<span class="media-tagging-created-tag__badge">${escapeHtml(tag.parentTagLabel)}</span>`
301
- : ''
302
- }
303
- </div>
304
- `
305
- : ''
306
- }
307
- </div>
308
- <button
309
- aria-label="Remove tag"
310
- class="delete-button media-tagging-created-tag__remove"
311
- data-action="remove-media-tag"
312
- data-tag-key="${escapeHtml(tag.key)}"
313
- type="button"
314
- title="Remove tag"
315
- ${canRemove && removingTagKey !== tag.key ? '' : 'disabled'}
316
- >
317
- <span class="material-symbols-outlined text-[18px]">
318
- ${removingTagKey === tag.key ? 'hourglass_top' : 'delete'}
319
- </span>
320
- </button>
321
- </article>
322
- `,
323
- )
275
+ .map(tag => {
276
+ const parentMetaMarkup =
277
+ tag.isParentTag || tag.parentTagLabel
278
+ ? [
279
+ '<div class="media-tagging-created-tag__meta">',
280
+ tag.isParentTag ? '<span class="media-tagging-created-tag__badge">Parent</span>' : '',
281
+ tag.parentTagLabel
282
+ ? [
283
+ '<span class="media-tagging-created-tag__badge">',
284
+ escapeHtml(tag.parentTagLabel),
285
+ '</span>',
286
+ ].join('')
287
+ : '',
288
+ '</div>',
289
+ ].join('')
290
+ : '';
291
+
292
+ return [
293
+ '<article class="media-tagging-created-tag">',
294
+ '<div class="media-tagging-created-tag__content">',
295
+ '<div class="media-tagging-created-tag__label">',
296
+ escapeHtml(tag.label),
297
+ '</div>',
298
+ parentMetaMarkup,
299
+ '</div>',
300
+ '<button aria-label="Remove tag" class="delete-button media-tagging-created-tag__remove" data-action="remove-media-tag" data-tag-key="',
301
+ escapeHtml(tag.key),
302
+ '" type="button" title="Remove tag" ',
303
+ canRemove && removingTagKey !== tag.key ? '' : 'disabled',
304
+ '><span class="material-symbols-outlined text-[18px]">',
305
+ removingTagKey === tag.key ? 'hourglass_top' : 'delete',
306
+ '</span></button></article>',
307
+ ].join('');
308
+ })
324
309
  .join('')}
325
310
  </div>
326
311
  `;
@@ -633,89 +618,71 @@ function renderTaggingSection(state) {
633
618
  const mediaTableColumns = state.mediaTagging.mediaTableColumns ?? [];
634
619
  const pathCandidates = state.mediaTagging.pathCandidates ?? [];
635
620
  const booleanCandidates = state.mediaTagging.booleanCandidates ?? [];
621
+ const tableOptions = renderOptionList(
622
+ tables
623
+ .map(table => ({ value: table.name, label: table.name }))
624
+ .sort((a, b) => a.label.localeCompare(b.label)),
625
+ draft.mediaTable,
626
+ 'Select a media table',
627
+ );
628
+ const pathOptions = renderOptionList(
629
+ mediaTableColumns
630
+ .map(column => ({
631
+ value: column.name,
632
+ label: pathCandidates.includes(column.name)
633
+ ? [column.name, ' // suggested'].join('')
634
+ : column.name,
635
+ }))
636
+ .sort((a, b) => a.label.localeCompare(b.label)),
637
+ draft.pathColumn,
638
+ 'Select the media path column',
639
+ );
640
+ const taggedOptions = renderOptionList(
641
+ mediaTableColumns
642
+ .map(column => ({
643
+ value: column.name,
644
+ label: booleanCandidates.includes(column.name)
645
+ ? [column.name, ' // suggested'].join('')
646
+ : column.name,
647
+ }))
648
+ .sort((a, b) => a.label.localeCompare(b.label)),
649
+ draft.taggedColumn,
650
+ mediaTableColumns.length ? 'Select the tagged flag column' : 'No columns available',
651
+ );
636
652
 
637
- return `
638
- <section class="media-tagging-card media-tagging-card--tagging shell-section">
639
- <div class="media-tagging-card__header">
640
- <div>
641
- <div class="media-tagging-card__eyebrow">2. Tagging</div>
642
- <h2 class="media-tagging-card__title">Media Source</h2>
643
- </div>
644
- </div>
645
- <div class="media-tagging-card__body">
646
- <div class="media-tagging-form-grid">
647
- <label class="media-tagging-field">
648
- <span class="media-tagging-field__label">Media Table</span>
649
- <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="mediaTable">
650
- ${renderOptionList(
651
- tables
652
- .map(table => ({ value: table.name, label: table.name }))
653
- .sort((a, b) => a.label.localeCompare(b.label)),
654
- draft.mediaTable,
655
- 'Select a media table',
656
- )}
657
- </select>
658
- </label>
659
- <div></div>
660
- <label class="media-tagging-field">
661
- <span class="media-tagging-field__label">Path Column</span>
662
- <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="pathColumn">
663
- ${renderOptionList(
664
- mediaTableColumns
665
- .map(column => ({
666
- value: column.name,
667
- label: pathCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
668
- }))
669
- .sort((a, b) => a.label.localeCompare(b.label)),
670
- draft.pathColumn,
671
- 'Select the media path column',
672
- )}
673
- </select>
674
- </label>
675
- <label class="media-tagging-field">
676
- <span class="media-tagging-field__label">Tagged Boolean Column</span>
677
- <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="taggedColumn">
678
- ${renderOptionList(
679
- mediaTableColumns
680
- .map(column => ({
681
- value: column.name,
682
- label: booleanCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
683
- }))
684
- .sort((a, b) => a.label.localeCompare(b.label)),
685
- draft.taggedColumn,
686
- mediaTableColumns.length ? 'Select the tagged flag column' : 'No columns available',
687
- )}
688
- </select>
689
- </label>
690
- </div>
691
-
692
- <div class="media-tagging-query-grid">
693
- ${renderSqlTextarea({
694
- label: 'Untagged Query',
695
- value: draft.untaggedQuery,
696
- dataBind: 'media-tagging-field',
697
- dataField: 'untaggedQuery',
698
- })}
699
- ${renderSqlTextarea({
700
- label: 'Tagged Query',
701
- value: draft.taggedQuery,
702
- dataBind: 'media-tagging-field',
703
- dataField: 'taggedQuery',
704
- })}
705
- </div>
706
-
707
- <div class="flex flex-wrap items-center gap-3">
708
- <button
709
- class="standard-button"
710
- data-action="reset-media-tagging-queries"
711
- type="button"
712
- >
713
- Reset Queries to Defaults
714
- </button>
715
- </div>
716
- </div>
717
- </section>
718
- `;
653
+ return [
654
+ '<section class="media-tagging-card media-tagging-card--tagging shell-section">',
655
+ '<div class="media-tagging-card__header"><div><div class="media-tagging-card__eyebrow">2. Tagging</div><h2 class="media-tagging-card__title">Media Source</h2></div></div>',
656
+ '<div class="media-tagging-card__body"><div class="media-tagging-form-grid">',
657
+ '<label class="media-tagging-field"><span class="media-tagging-field__label">Media Table</span>',
658
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="mediaTable">',
659
+ tableOptions,
660
+ '</select></label><div></div>',
661
+ '<label class="media-tagging-field"><span class="media-tagging-field__label">Path Column</span>',
662
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="pathColumn">',
663
+ pathOptions,
664
+ '</select></label>',
665
+ '<label class="media-tagging-field"><span class="media-tagging-field__label">Tagged Boolean Column</span>',
666
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="taggedColumn">',
667
+ taggedOptions,
668
+ '</select></label></div>',
669
+ '<div class="media-tagging-query-grid">',
670
+ renderSqlTextarea({
671
+ label: 'Untagged Query',
672
+ value: draft.untaggedQuery,
673
+ dataBind: 'media-tagging-field',
674
+ dataField: 'untaggedQuery',
675
+ }),
676
+ renderSqlTextarea({
677
+ label: 'Tagged Query',
678
+ value: draft.taggedQuery,
679
+ dataBind: 'media-tagging-field',
680
+ dataField: 'taggedQuery',
681
+ }),
682
+ '</div>',
683
+ '<div class="flex flex-wrap items-center gap-3"><button class="standard-button" data-action="reset-media-tagging-queries" type="button">Reset Queries to Defaults</button></div>',
684
+ '</div></section>',
685
+ ].join('');
719
686
  }
720
687
 
721
688
  function renderMappingSection(state) {