@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- 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}>×</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>
|