@svgrid/enterprise 1.2.0 → 2.0.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.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,553 @@
1
+ <script lang="ts" generics="TFeatures extends TableFeatures, TData extends RowData">
2
+ /**
3
+ * SvExportMenu - a drop-in export button + menu for a licensed grid. Wraps
4
+ * `api.exportData` / `api.copyExport` / `api.print` so consumers don't
5
+ * hand-roll a toolbar in every app:
6
+ * - a sectioned format picker with per-format icons + descriptions,
7
+ * - a row-scope toggle (current view / selected / all rows),
8
+ * - a per-column include picker,
9
+ * - copy-to-clipboard + optional print actions,
10
+ * - a progress bar + Cancel for large exports (via AbortController),
11
+ * - inline error surface when a peer dep is missing or the grid is empty.
12
+ *
13
+ * Minimal, unopinionated styling via a few overridable classes / CSS vars.
14
+ */
15
+ import type { RowData, TableFeatures } from '@svgrid/grid'
16
+ import type { ConditionalFormat } from '@svgrid/grid/format'
17
+ import type { EnterpriseGridApi } from './install'
18
+ import type {
19
+ ClipboardFormat,
20
+ ExportColumn,
21
+ ExportFormat,
22
+ ExportProgress,
23
+ ExportRowSource,
24
+ } from './export'
25
+
26
+ type Props = {
27
+ api: EnterpriseGridApi<TFeatures, TData> | null | undefined
28
+ /** Base filename (no extension). Default 'grid'. */
29
+ filename?: string
30
+ /** Which formats to offer, in order. Default xlsx/xls/pdf/csv/tsv/html. */
31
+ formats?: ReadonlyArray<ExportFormat>
32
+ /** Show the "current view / selected / all" scope toggle. Default true. */
33
+ allowScope?: boolean
34
+ /** Show the per-column include checkboxes. Default true. */
35
+ allowColumns?: boolean
36
+ /** Show the "Copy to clipboard" actions. Default true. */
37
+ allowCopy?: boolean
38
+ /** Show a "Print / Save as PDF" action. Default false. */
39
+ allowPrint?: boolean
40
+ /** Initial scope. Default 'displayed'. */
41
+ scope?: Extract<ExportRowSource<TData>, string>
42
+ /** Button label. Default 'Export'. */
43
+ label?: string
44
+ /** Conditional formatting rules to carry into styled exports (pdf/xlsx/html). */
45
+ conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>
46
+ }
47
+
48
+ let {
49
+ api,
50
+ filename = 'grid',
51
+ formats = ['xlsx', 'xls', 'pdf', 'csv', 'tsv', 'html'],
52
+ allowScope = true,
53
+ allowColumns = true,
54
+ allowCopy = true,
55
+ allowPrint = false,
56
+ scope = 'displayed',
57
+ label = 'Export',
58
+ conditionalFormats,
59
+ }: Props = $props()
60
+
61
+ type IconKind = 'sheet' | 'doc' | 'text' | 'code'
62
+ const FORMAT_META: Record<ExportFormat, { label: string; desc: string; icon: IconKind }> = {
63
+ xlsx: { label: 'Excel', desc: '.xlsx, formatted', icon: 'sheet' },
64
+ xls: { label: 'Excel 97-2003', desc: 'Legacy .xls', icon: 'sheet' },
65
+ pdf: { label: 'PDF', desc: 'Paginated document', icon: 'doc' },
66
+ csv: { label: 'CSV', desc: 'Comma-separated', icon: 'text' },
67
+ tsv: { label: 'TSV', desc: 'Tab-separated', icon: 'text' },
68
+ html: { label: 'HTML', desc: 'Styled table', icon: 'code' },
69
+ json: { label: 'JSON', desc: 'Data array', icon: 'code' },
70
+ xml: { label: 'XML', desc: 'Rows + fields', icon: 'code' },
71
+ md: { label: 'Markdown', desc: 'GFM table', icon: 'text' },
72
+ }
73
+ const SCOPES: Array<{ value: Extract<ExportRowSource<TData>, string>; label: string }> = [
74
+ { value: 'displayed', label: 'Current view' },
75
+ { value: 'selected', label: 'Selected rows' },
76
+ { value: 'all', label: 'All rows' },
77
+ ]
78
+
79
+ let open = $state(false)
80
+ let currentScope = $state<Extract<ExportRowSource<TData>, string>>(scope)
81
+ let busy = $state(false)
82
+ let progress = $state(0)
83
+ let error = $state<string | null>(null)
84
+ let copied = $state<string | null>(null)
85
+ let controller: AbortController | null = null
86
+ let rootEl: HTMLDivElement | undefined = $state()
87
+ let menuEl: HTMLDivElement | undefined = $state()
88
+ let triggerEl: HTMLButtonElement | undefined = $state()
89
+
90
+ // --- Keyboard accessibility -------------------------------------------
91
+ /** Focusable controls inside the menu, in DOM order. */
92
+ function menuItems(): HTMLElement[] {
93
+ if (!menuEl) return []
94
+ return Array.from(
95
+ menuEl.querySelectorAll<HTMLElement>(
96
+ 'button:not([disabled]), input:not([disabled]), [tabindex="0"]',
97
+ ),
98
+ )
99
+ }
100
+
101
+ function focusFirst() {
102
+ // Wait a tick so the menu is rendered, then focus its first control.
103
+ requestAnimationFrame(() => menuItems()[0]?.focus())
104
+ }
105
+
106
+ function closeMenu(returnFocus = true) {
107
+ open = false
108
+ if (returnFocus) triggerEl?.focus()
109
+ }
110
+
111
+ function onMenuKeydown(e: KeyboardEvent) {
112
+ const items = menuItems()
113
+ if (items.length === 0) return
114
+ const idx = items.indexOf(document.activeElement as HTMLElement)
115
+ if (e.key === 'Escape') {
116
+ e.preventDefault()
117
+ closeMenu()
118
+ } else if (e.key === 'ArrowDown') {
119
+ e.preventDefault()
120
+ items[(idx + 1 + items.length) % items.length]?.focus()
121
+ } else if (e.key === 'ArrowUp') {
122
+ e.preventDefault()
123
+ items[(idx - 1 + items.length) % items.length]?.focus()
124
+ } else if (e.key === 'Home') {
125
+ e.preventDefault()
126
+ items[0]?.focus()
127
+ } else if (e.key === 'End') {
128
+ e.preventDefault()
129
+ items[items.length - 1]?.focus()
130
+ } else if (e.key === 'Tab') {
131
+ // Trap focus within the menu (wrap at the ends).
132
+ if (e.shiftKey && (idx <= 0)) {
133
+ e.preventDefault()
134
+ items[items.length - 1]?.focus()
135
+ } else if (!e.shiftKey && idx === items.length - 1) {
136
+ e.preventDefault()
137
+ items[0]?.focus()
138
+ }
139
+ }
140
+ }
141
+
142
+ let picks = $state<Array<{ field: string; header: string; checked: boolean }>>([])
143
+ let showColumns = $state(false)
144
+
145
+ function refreshColumns() {
146
+ if (!api) return
147
+ picks = api
148
+ .getColumns()
149
+ .filter((c) => c.visible && c.field)
150
+ .map((c) => ({ field: c.field!, header: c.header, checked: true }))
151
+ }
152
+
153
+ function toggleOpen() {
154
+ open = !open
155
+ copied = null
156
+ error = null
157
+ if (open) {
158
+ refreshColumns()
159
+ focusFirst()
160
+ }
161
+ }
162
+
163
+ function selectedColumns(): ExportColumn<TData>[] | undefined {
164
+ if (!allowColumns || picks.length === 0) return undefined
165
+ const chosen = picks.filter((p) => p.checked)
166
+ if (chosen.length === picks.length) return undefined
167
+ return chosen.map((p) => ({ field: p.field, header: p.header }))
168
+ }
169
+
170
+ function onDocClick(e: MouseEvent) {
171
+ if (rootEl && !rootEl.contains(e.target as Node)) open = false
172
+ }
173
+
174
+ async function withBusy(fn: () => Promise<unknown>, closeOnDone = true) {
175
+ if (!api || busy) return
176
+ error = null
177
+ copied = null
178
+ busy = true
179
+ progress = 0
180
+ controller = new AbortController()
181
+ try {
182
+ await fn()
183
+ if (closeOnDone) closeMenu()
184
+ } catch (err) {
185
+ if ((err as { name?: string })?.name === 'AbortError') error = 'Export cancelled.'
186
+ else error = err instanceof Error ? err.message : String(err)
187
+ } finally {
188
+ busy = false
189
+ controller = null
190
+ }
191
+ }
192
+
193
+ async function run(format: ExportFormat) {
194
+ await withBusy(() =>
195
+ api!.exportData({
196
+ format,
197
+ filename,
198
+ rows: currentScope,
199
+ columns: selectedColumns(),
200
+ conditionalFormats,
201
+ signal: controller!.signal,
202
+ onProgress: (p: ExportProgress) => {
203
+ progress = p.ratio
204
+ },
205
+ }),
206
+ )
207
+ }
208
+
209
+ async function copy(format: ClipboardFormat) {
210
+ await withBusy(
211
+ () => api!.copyExport({ format, rows: currentScope, columns: selectedColumns() }),
212
+ false,
213
+ )
214
+ if (!error) copied = format === 'tsv' ? 'for Excel' : format
215
+ }
216
+
217
+ async function print() {
218
+ await withBusy(() =>
219
+ api!.print({ title: filename, rows: currentScope, columns: selectedColumns() }),
220
+ )
221
+ }
222
+
223
+ function cancel() {
224
+ controller?.abort()
225
+ }
226
+ </script>
227
+
228
+ <svelte:document onclick={onDocClick} />
229
+
230
+ {#snippet formatIcon(kind: IconKind)}
231
+ <svg class="sv-export-icon" width="16" height="16" viewBox="0 0 24 24" fill="none"
232
+ stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
233
+ {#if kind === 'sheet'}
234
+ <rect x="3" y="3" width="18" height="18" rx="1.5" /><path d="M3 9h18M3 15h18M9 3v18M15 3v18" />
235
+ {:else if kind === 'doc'}
236
+ <path d="M6 2.5h8l4 4v15H6z" /><path d="M14 2.5v4h4" />
237
+ {:else if kind === 'text'}
238
+ <path d="M5 6h14M5 12h14M5 18h9" />
239
+ {:else}
240
+ <path d="M8 7l-4 5 4 5M16 7l4 5-4 5" />
241
+ {/if}
242
+ </svg>
243
+ {/snippet}
244
+
245
+ <div class="sv-export" bind:this={rootEl}>
246
+ <button
247
+ type="button"
248
+ class="sv-export-btn"
249
+ aria-haspopup="menu"
250
+ aria-expanded={open}
251
+ disabled={!api}
252
+ bind:this={triggerEl}
253
+ onclick={toggleOpen}
254
+ >
255
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
256
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
257
+ <path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
258
+ </svg>
259
+ {label}
260
+ <span class="sv-export-caret" aria-hidden="true">▾</span>
261
+ </button>
262
+
263
+ {#if open}
264
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
265
+ <div
266
+ class="sv-export-menu"
267
+ role="menu"
268
+ aria-label="Export options"
269
+ bind:this={menuEl}
270
+ onkeydown={onMenuKeydown}
271
+ >
272
+ {#if allowScope}
273
+ <div class="sv-export-section">
274
+ <span class="sv-export-section-label">Rows</span>
275
+ <div class="sv-export-scope">
276
+ {#each SCOPES as s (s.value)}
277
+ <label class="sv-export-opt">
278
+ <input
279
+ type="radio"
280
+ name="sv-export-scope"
281
+ value={s.value}
282
+ checked={currentScope === s.value}
283
+ onchange={() => (currentScope = s.value)}
284
+ />
285
+ {s.label}
286
+ </label>
287
+ {/each}
288
+ </div>
289
+ </div>
290
+ {/if}
291
+
292
+ {#if allowColumns && picks.length > 0}
293
+ <div class="sv-export-section">
294
+ <button type="button" class="sv-export-cols-toggle" onclick={() => (showColumns = !showColumns)}>
295
+ Columns <span class="sv-export-count">{picks.filter((p) => p.checked).length}/{picks.length}</span>
296
+ <span aria-hidden="true">{showColumns ? '▴' : '▾'}</span>
297
+ </button>
298
+ {#if showColumns}
299
+ <div class="sv-export-cols-list">
300
+ {#each picks as p (p.field)}
301
+ <label class="sv-export-opt">
302
+ <input type="checkbox" bind:checked={p.checked} />
303
+ {p.header}
304
+ </label>
305
+ {/each}
306
+ </div>
307
+ {/if}
308
+ </div>
309
+ {/if}
310
+
311
+ <div class="sv-export-section">
312
+ <span class="sv-export-section-label">Download</span>
313
+ {#each formats as f (f)}
314
+ <button type="button" class="sv-export-item" role="menuitem" disabled={busy} onclick={() => run(f)}>
315
+ {@render formatIcon(FORMAT_META[f].icon)}
316
+ <span class="sv-export-item-text">
317
+ <span class="sv-export-item-label">{FORMAT_META[f].label}</span>
318
+ <span class="sv-export-item-desc">{FORMAT_META[f].desc}</span>
319
+ </span>
320
+ </button>
321
+ {/each}
322
+ </div>
323
+
324
+ {#if allowCopy || allowPrint}
325
+ <div class="sv-export-actions">
326
+ {#if allowCopy}
327
+ <button type="button" class="sv-export-chip" disabled={busy} onclick={() => copy('tsv')}>Copy for Excel</button>
328
+ <button type="button" class="sv-export-chip" disabled={busy} onclick={() => copy('md')}>Copy Markdown</button>
329
+ {/if}
330
+ {#if allowPrint}
331
+ <button type="button" class="sv-export-chip" disabled={busy} onclick={print}>Print…</button>
332
+ {/if}
333
+ {#if copied}<span class="sv-export-copied">Copied {copied} ✓</span>{/if}
334
+ </div>
335
+ {/if}
336
+
337
+ {#if busy}
338
+ <div class="sv-export-progress">
339
+ <div class="sv-export-track"><div class="sv-export-bar" style:width={`${Math.round(progress * 100)}%`}></div></div>
340
+ <button type="button" class="sv-export-cancel" onclick={cancel}>Cancel</button>
341
+ </div>
342
+ {/if}
343
+
344
+ {#if error}
345
+ <p class="sv-export-error" role="alert">{error}</p>
346
+ {/if}
347
+ </div>
348
+ {/if}
349
+ </div>
350
+
351
+ <style>
352
+ .sv-export {
353
+ position: relative;
354
+ display: inline-block;
355
+ }
356
+ .sv-export-btn {
357
+ display: inline-flex;
358
+ align-items: center;
359
+ gap: 6px;
360
+ padding: 6px 12px;
361
+ font: inherit;
362
+ font-size: 13px;
363
+ font-weight: 500;
364
+ border: 1px solid var(--sg-border, #d0d7de);
365
+ border-radius: 8px;
366
+ background: var(--sg-header-bg, #f6f8fa);
367
+ color: var(--sg-fg, #1f2328);
368
+ cursor: pointer;
369
+ }
370
+ .sv-export-btn:disabled {
371
+ opacity: 0.5;
372
+ cursor: not-allowed;
373
+ }
374
+ .sv-export-caret {
375
+ font-size: 10px;
376
+ opacity: 0.7;
377
+ }
378
+ .sv-export-menu {
379
+ position: absolute;
380
+ z-index: 50;
381
+ top: calc(100% + 6px);
382
+ left: 0;
383
+ min-width: 232px;
384
+ padding: 6px;
385
+ border: 1px solid var(--sg-border, #d0d7de);
386
+ border-radius: 12px;
387
+ background: var(--sg-bg, #fff);
388
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14);
389
+ }
390
+ .sv-export-section {
391
+ padding: 6px 6px 8px;
392
+ border-bottom: 1px solid var(--sg-border, #eaecef);
393
+ }
394
+ .sv-export-section:last-of-type {
395
+ border-bottom: none;
396
+ }
397
+ .sv-export-section-label {
398
+ display: block;
399
+ padding: 2px 6px 6px;
400
+ font-size: 10px;
401
+ font-weight: 700;
402
+ text-transform: uppercase;
403
+ letter-spacing: 0.07em;
404
+ color: var(--sg-muted, #8b949e);
405
+ }
406
+ .sv-export-scope {
407
+ display: flex;
408
+ flex-direction: column;
409
+ gap: 1px;
410
+ }
411
+ .sv-export-opt {
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 7px;
415
+ padding: 3px 6px;
416
+ font-size: 12.5px;
417
+ color: var(--sg-fg, #1f2328);
418
+ cursor: pointer;
419
+ border-radius: 6px;
420
+ }
421
+ .sv-export-opt:hover {
422
+ background: var(--sg-header-bg, #f6f8fa);
423
+ }
424
+ .sv-export-item {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 10px;
428
+ width: 100%;
429
+ text-align: left;
430
+ padding: 6px 8px;
431
+ font: inherit;
432
+ border: none;
433
+ border-radius: 8px;
434
+ background: transparent;
435
+ color: var(--sg-fg, #1f2328);
436
+ cursor: pointer;
437
+ }
438
+ .sv-export-item:hover:not(:disabled) {
439
+ background: var(--sg-header-bg, #f6f8fa);
440
+ }
441
+ .sv-export-item:disabled {
442
+ opacity: 0.5;
443
+ cursor: default;
444
+ }
445
+ .sv-export-icon {
446
+ flex-shrink: 0;
447
+ color: var(--site-accent-2, #6366f1);
448
+ }
449
+ .sv-export-item-text {
450
+ display: flex;
451
+ flex-direction: column;
452
+ line-height: 1.25;
453
+ }
454
+ .sv-export-item-label {
455
+ font-size: 13px;
456
+ font-weight: 500;
457
+ }
458
+ .sv-export-item-desc {
459
+ font-size: 11px;
460
+ color: var(--sg-muted, #8b949e);
461
+ }
462
+ .sv-export-cols-toggle {
463
+ display: flex;
464
+ align-items: center;
465
+ gap: 6px;
466
+ width: 100%;
467
+ font: inherit;
468
+ font-size: 11px;
469
+ font-weight: 700;
470
+ text-transform: uppercase;
471
+ letter-spacing: 0.07em;
472
+ color: var(--sg-muted, #8b949e);
473
+ background: transparent;
474
+ border: none;
475
+ padding: 2px 6px;
476
+ cursor: pointer;
477
+ }
478
+ .sv-export-count {
479
+ font-weight: 600;
480
+ text-transform: none;
481
+ letter-spacing: 0;
482
+ color: var(--sg-fg, #1f2328);
483
+ }
484
+ .sv-export-cols-toggle > span:last-child {
485
+ margin-left: auto;
486
+ }
487
+ .sv-export-cols-list {
488
+ max-height: 168px;
489
+ overflow-y: auto;
490
+ margin-top: 4px;
491
+ }
492
+ .sv-export-actions {
493
+ display: flex;
494
+ align-items: center;
495
+ flex-wrap: wrap;
496
+ gap: 6px;
497
+ padding: 8px 6px 4px;
498
+ }
499
+ .sv-export-chip {
500
+ font: inherit;
501
+ font-size: 12px;
502
+ padding: 4px 10px;
503
+ border-radius: 999px;
504
+ border: 1px solid var(--sg-border, #d0d7de);
505
+ background: var(--sg-header-bg, #f6f8fa);
506
+ color: var(--sg-fg, #1f2328);
507
+ cursor: pointer;
508
+ }
509
+ .sv-export-chip:hover:not(:disabled) {
510
+ border-color: var(--site-accent, #3b82f6);
511
+ }
512
+ .sv-export-chip:disabled {
513
+ opacity: 0.5;
514
+ cursor: default;
515
+ }
516
+ .sv-export-copied {
517
+ font-size: 11px;
518
+ color: #16a34a;
519
+ }
520
+ .sv-export-progress {
521
+ display: flex;
522
+ align-items: center;
523
+ gap: 8px;
524
+ padding: 8px 6px 4px;
525
+ }
526
+ .sv-export-track {
527
+ flex: 1;
528
+ min-width: 0;
529
+ height: 6px;
530
+ border-radius: 999px;
531
+ background: var(--sg-border, #eaecef);
532
+ overflow: hidden;
533
+ }
534
+ .sv-export-bar {
535
+ height: 100%;
536
+ border-radius: 999px;
537
+ background: var(--site-accent, #3b82f6);
538
+ transition: width 120ms ease;
539
+ }
540
+ .sv-export-cancel {
541
+ font: inherit;
542
+ font-size: 12px;
543
+ border: none;
544
+ background: transparent;
545
+ color: var(--site-accent-2, #6366f1);
546
+ cursor: pointer;
547
+ }
548
+ .sv-export-error {
549
+ margin: 6px 6px 2px;
550
+ font-size: 12px;
551
+ color: #dc2626;
552
+ }
553
+ </style>
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvFileInput - a file / image upload control for the edit form. Picks a file,
4
+ * shows an image preview (when `image`), and stores a URL. With an `upload`
5
+ * handler it pushes to your storage and stores the returned URL; without one it
6
+ * falls back to an inline data URL (great for demos / small assets). Used by
7
+ * SvGridEditPanel for fields with an `upload` config.
8
+ */
9
+ type Props = {
10
+ value: unknown
11
+ onChange: (url: string) => void
12
+ id?: string
13
+ accept?: string
14
+ image?: boolean
15
+ disabled?: boolean
16
+ /** Push the file to storage and resolve with the URL to store. */
17
+ upload?: (file: File) => Promise<string>
18
+ }
19
+
20
+ let { value, onChange, id, accept, image = false, disabled = false, upload }: Props = $props()
21
+
22
+ let loading = $state(false)
23
+ let error = $state<string | null>(null)
24
+ let name = $state('')
25
+ let inputEl: HTMLInputElement | undefined = $state()
26
+
27
+ const url = $derived(typeof value === 'string' ? value : '')
28
+
29
+ function readAsDataUrl(file: File): Promise<string> {
30
+ return new Promise((resolve, reject) => {
31
+ const reader = new FileReader()
32
+ reader.onload = () => resolve(String(reader.result))
33
+ reader.onerror = () => reject(new Error('Could not read the file'))
34
+ reader.readAsDataURL(file)
35
+ })
36
+ }
37
+
38
+ async function onPick(e: Event) {
39
+ const file = (e.currentTarget as HTMLInputElement).files?.[0]
40
+ if (!file) return
41
+ name = file.name
42
+ error = null
43
+ loading = true
44
+ try {
45
+ onChange(upload ? await upload(file) : await readAsDataUrl(file))
46
+ } catch (err) {
47
+ error = err instanceof Error ? err.message : 'Upload failed'
48
+ } finally {
49
+ loading = false
50
+ }
51
+ }
52
+
53
+ function clear() {
54
+ onChange('')
55
+ name = ''
56
+ if (inputEl) inputEl.value = ''
57
+ }
58
+ </script>
59
+
60
+ <div class="sv-file">
61
+ {#if image && url}
62
+ <img class="sv-file__preview" src={url} alt="preview" />
63
+ {/if}
64
+
65
+ <div class="sv-file__row">
66
+ <button
67
+ type="button"
68
+ class="sv-file__btn"
69
+ {disabled}
70
+ onclick={() => inputEl?.click()}
71
+ >{loading ? 'Uploading…' : url ? 'Replace' : 'Choose file'}</button>
72
+
73
+ {#if url && !image}
74
+ <span class="sv-file__name" title={name || url}>{name || 'Uploaded file'}</span>
75
+ {/if}
76
+ {#if url && !disabled}
77
+ <button type="button" class="sv-file__clear" aria-label="Remove" title="Remove" onclick={clear}>&times;</button>
78
+ {/if}
79
+
80
+ <input
81
+ {id}
82
+ bind:this={inputEl}
83
+ class="sv-file__input"
84
+ type="file"
85
+ {accept}
86
+ {disabled}
87
+ onchange={onPick}
88
+ />
89
+ </div>
90
+
91
+ {#if error}<p class="sv-file__err">{error}</p>{/if}
92
+ </div>
93
+
94
+ <style>
95
+ .sv-file { display: flex; flex-direction: column; gap: 8px; }
96
+ .sv-file__row { display: flex; align-items: center; gap: 8px; }
97
+ .sv-file__input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
98
+ .sv-file__preview {
99
+ width: 64px; height: 64px; object-fit: cover; border-radius: 8px;
100
+ border: 1px solid var(--sg-border, #ddd); background: var(--sg-header-bg, #f6f6f6);
101
+ }
102
+ .sv-file__btn {
103
+ padding: 7px 12px; border: 1px solid var(--sg-border, #ccc); border-radius: 6px;
104
+ background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; font: inherit;
105
+ }
106
+ .sv-file__btn:disabled { opacity: 0.6; cursor: default; }
107
+ .sv-file__name { font-size: 12px; color: var(--sg-muted, #666); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
108
+ .sv-file__clear {
109
+ border: none; background: none; cursor: pointer; font-size: 16px; line-height: 1;
110
+ color: var(--sg-muted, #888); padding: 2px 4px;
111
+ }
112
+ .sv-file__err { margin: 0; font-size: 12px; color: #dc2626; }
113
+ </style>