@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,920 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emitStudioApp - turn a set of `EntitySchema`s into the source files of a
|
|
3
|
+
* runnable **create-studio** app (the EntityScreen-style template). This is what
|
|
4
|
+
* `npm create @svgrid/studio --from <schema>` writes instead of the seeded
|
|
5
|
+
* Customers/Orders: a `schemas.ts` + `data.ts` + one route per entity + the nav
|
|
6
|
+
* shell, all driven by the user's real Drizzle / Prisma schema.
|
|
7
|
+
*
|
|
8
|
+
* Pure string building (like `scaffold` / `scaffoldApp`), and node-safe - it
|
|
9
|
+
* lives in the `./studio` subtree so `create-studio` gets it from the Node
|
|
10
|
+
* build. It targets the template's conventions: `$lib/EntityScreen.svelte`,
|
|
11
|
+
* `$lib/schemas`, `$lib/data`, and the `st-*` / `.home` CSS classes.
|
|
12
|
+
*/
|
|
13
|
+
import { resolveIdField, titleCase, type EntityField, type EntityFieldType, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
|
|
14
|
+
import type { GeneratedFile } from './scaffold.js'
|
|
15
|
+
import type { EntityDataSource, RestSource, ShellConfig, SqlDialectKind } from './project.js'
|
|
16
|
+
import { generateValue } from './sample-data.js'
|
|
17
|
+
|
|
18
|
+
const pascal = (name: string): string =>
|
|
19
|
+
titleCase(name).replace(/\s+/g, '')
|
|
20
|
+
|
|
21
|
+
const camel = (name: string): string => {
|
|
22
|
+
const p = pascal(name)
|
|
23
|
+
return p.charAt(0).toLowerCase() + p.slice(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Entity field type -> a TS property type for the generated row type. */
|
|
27
|
+
function tsType(type: EntityFieldType): string {
|
|
28
|
+
if (type === 'number') return 'number'
|
|
29
|
+
if (type === 'boolean') return 'boolean'
|
|
30
|
+
if (type === 'json') return 'unknown'
|
|
31
|
+
return 'string' // text / date / dateString / datetime / enum / relation
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How many sample rows to seed per entity. */
|
|
35
|
+
const SEED_COUNT = 6
|
|
36
|
+
|
|
37
|
+
type Names = {
|
|
38
|
+
type: string // PascalCase row type, e.g. `User`
|
|
39
|
+
schemaVar: string // e.g. `userSchema`
|
|
40
|
+
sourceVar: string // e.g. `userSource`
|
|
41
|
+
label: string // display label
|
|
42
|
+
route: string // route segment (= schema name)
|
|
43
|
+
idPrefix: string // seed id prefix for nextId()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function namesFor(schema: EntitySchema): Names {
|
|
47
|
+
return {
|
|
48
|
+
type: pascal(schema.name),
|
|
49
|
+
schemaVar: `${camel(schema.name)}Schema`,
|
|
50
|
+
sourceVar: `${camel(schema.name)}Source`,
|
|
51
|
+
label: schema.label ?? titleCase(schema.name),
|
|
52
|
+
route: schema.name,
|
|
53
|
+
idPrefix: schema.name.charAt(0).toLowerCase() || 'r',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lookup variable name for a relation field, e.g. `postAuthorIdLookup`. */
|
|
58
|
+
export const lookupVar = (schema: EntitySchema, field: string) => `${camel(schema.name)}${pascal(field)}Lookup`
|
|
59
|
+
|
|
60
|
+
function rowType(schema: EntitySchema, derived: Set<string>): string {
|
|
61
|
+
// Derived columns (relation labels) are filled at runtime, so they're optional.
|
|
62
|
+
const lines = schema.fields.map((f) => ` ${f.field}${derived.has(f.field) ? '?' : ''}: ${tsType(f.type)}`)
|
|
63
|
+
return `export type ${pascal(schema.name)} = {\n${lines.join('\n')}\n}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Base (unwrapped) in-memory source variable, e.g. `userStore`. */
|
|
67
|
+
const storeVar = (schema: EntitySchema) => `${camel(schema.name)}Store`
|
|
68
|
+
|
|
69
|
+
type RelationInfo = { fkField: string; displayField: string; related: EntitySchema }
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Map each relation FK field (whose target resolves) to the denormalized display
|
|
73
|
+
* field name that `withRelationLabels` fills onto every row - e.g. `companyId` ->
|
|
74
|
+
* `company`, else `<fk>Label`. Used by the grid (via `relationInfos`) and by any
|
|
75
|
+
* view that renders a relation as text (board/calendar titles) so it shows the
|
|
76
|
+
* related label instead of the raw FK id. Order-dependent collision handling must
|
|
77
|
+
* match `relationInfos` exactly, so both go through here.
|
|
78
|
+
*/
|
|
79
|
+
export function relationDisplayFields(schema: EntitySchema, resolve: (name: string) => EntitySchema | undefined): Map<string, string> {
|
|
80
|
+
const existing = new Set(schema.fields.map((f) => f.field))
|
|
81
|
+
const out = new Map<string, string>()
|
|
82
|
+
for (const f of schema.fields) {
|
|
83
|
+
if (f.type !== 'relation' || !f.relation) continue
|
|
84
|
+
if (!resolve(f.relation.entity)) continue
|
|
85
|
+
// Show the label under a friendly name: `authorId` -> `author`, else `<fk>Label`.
|
|
86
|
+
let display = /(_id|Id)$/.test(f.field) ? f.field.replace(/(_id|Id)$/, '') : `${f.field}Label`
|
|
87
|
+
if (!display || existing.has(display)) display = `${f.field}Label`
|
|
88
|
+
while (existing.has(display)) display += '_'
|
|
89
|
+
existing.add(display)
|
|
90
|
+
out.set(f.field, display)
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Relation fields of a schema whose target is present in the set. */
|
|
96
|
+
function relationInfos(schema: EntitySchema, byName: Map<string, EntitySchema>): RelationInfo[] {
|
|
97
|
+
const display = relationDisplayFields(schema, (name) => byName.get(name))
|
|
98
|
+
const infos: RelationInfo[] = []
|
|
99
|
+
for (const f of schema.fields) {
|
|
100
|
+
if (f.type !== 'relation' || !f.relation) continue
|
|
101
|
+
const related = byName.get(f.relation.entity)
|
|
102
|
+
if (!related) continue
|
|
103
|
+
infos.push({ fkField: f.field, displayField: display.get(f.field)!, related })
|
|
104
|
+
}
|
|
105
|
+
return infos
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rewrite a schema for a linked grid: hide each FK column from the grid (it
|
|
110
|
+
* stays a lookup in the form) and append a read-only display column that shows
|
|
111
|
+
* the related label (filled by `withRelationLabels` in data.ts).
|
|
112
|
+
*/
|
|
113
|
+
function prepareSchema(schema: EntitySchema, infos: RelationInfo[]): EntitySchema {
|
|
114
|
+
const pkId = resolveIdField(schema)
|
|
115
|
+
const fkFields = new Set(infos.map((i) => i.fkField))
|
|
116
|
+
const fields: EntityField[] = schema.fields.map((f) => {
|
|
117
|
+
let nf = f
|
|
118
|
+
// The in-memory starter uses string ids, so the key is a text field.
|
|
119
|
+
if (f.field === pkId && f.type !== 'text') nf = { ...nf, type: 'text' }
|
|
120
|
+
if (fkFields.has(f.field)) {
|
|
121
|
+
const prev = typeof nf.hidden === 'object' ? nf.hidden : {}
|
|
122
|
+
nf = { ...nf, hidden: { ...prev, grid: true } }
|
|
123
|
+
}
|
|
124
|
+
return nf
|
|
125
|
+
})
|
|
126
|
+
for (const i of infos) {
|
|
127
|
+
fields.push({ field: i.displayField, type: 'text', label: titleCase(i.displayField), readonly: true, hidden: { form: true } })
|
|
128
|
+
}
|
|
129
|
+
return { ...schema, fields }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Prepared = { schema: EntitySchema; infos: RelationInfo[] }
|
|
133
|
+
|
|
134
|
+
/** N sample rows per entity, with valid (string) ids + foreign-key references. */
|
|
135
|
+
function generateSeed(entries: Prepared[]): Map<string, Array<Record<string, unknown>>> {
|
|
136
|
+
const seed = new Map<string, Array<Record<string, unknown>>>()
|
|
137
|
+
for (const { schema, infos } of entries) {
|
|
138
|
+
const pkId = resolveIdField(schema)
|
|
139
|
+
const idPrefix = namesFor(schema).idPrefix
|
|
140
|
+
const relPrefix = new Map(infos.map((i) => [i.fkField, namesFor(i.related).idPrefix]))
|
|
141
|
+
const display = new Set(infos.map((i) => i.displayField))
|
|
142
|
+
const rows: Array<Record<string, unknown>> = []
|
|
143
|
+
for (let i = 0; i < SEED_COUNT; i++) {
|
|
144
|
+
const row: Record<string, unknown> = {}
|
|
145
|
+
for (const f of schema.fields) {
|
|
146
|
+
if (display.has(f.field)) continue // relation label, filled at runtime
|
|
147
|
+
if (f.field === pkId) row[f.field] = `${idPrefix}${i + 1}`
|
|
148
|
+
else if (f.type === 'relation') {
|
|
149
|
+
const rp = relPrefix.get(f.field)
|
|
150
|
+
row[f.field] = rp ? `${rp}${(i % SEED_COUNT) + 1}` : ''
|
|
151
|
+
} else row[f.field] = generateValue(f, i)
|
|
152
|
+
}
|
|
153
|
+
rows.push(row)
|
|
154
|
+
}
|
|
155
|
+
seed.set(schema.name, rows)
|
|
156
|
+
}
|
|
157
|
+
return seed
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Compile a no-code formula into a JS expression: bare field names -> `$.field`
|
|
161
|
+
* (where `$` is the row, typed loosely so arithmetic never trips svelte-check). */
|
|
162
|
+
function compileFormula(formula: string, fieldNames: string[]): string {
|
|
163
|
+
const names = [...fieldNames].sort((a, b) => b.length - a.length).map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
164
|
+
if (!names.length) return formula
|
|
165
|
+
return formula.replace(new RegExp('\\b(' + names.join('|') + ')\\b', 'g'), '$.$1')
|
|
166
|
+
}
|
|
167
|
+
/** The condition a validation rule asserts must hold (fail -> attach its message). */
|
|
168
|
+
function compileValidation(r: ValidationRuleSpec): string {
|
|
169
|
+
const lhs = `$[${JSON.stringify(r.field)}]`
|
|
170
|
+
const num = (v: unknown) => (r.compareTo ? `Number($[${JSON.stringify(r.compareTo)}])` : Number(v))
|
|
171
|
+
const str = (v: unknown) => (r.compareTo ? `String($[${JSON.stringify(r.compareTo)}])` : JSON.stringify(String(v ?? '')))
|
|
172
|
+
switch (r.op) {
|
|
173
|
+
case 'eq': return `String(${lhs}) === ${str(r.value)}`
|
|
174
|
+
case 'ne': return `String(${lhs}) !== ${str(r.value)}`
|
|
175
|
+
case 'lt': return `Number(${lhs}) < ${num(r.value)}`
|
|
176
|
+
case 'lte': return `Number(${lhs}) <= ${num(r.value)}`
|
|
177
|
+
case 'gt': return `Number(${lhs}) > ${num(r.value)}`
|
|
178
|
+
case 'gte': return `Number(${lhs}) >= ${num(r.value)}`
|
|
179
|
+
case 'required': return `${lhs} != null && ${lhs} !== ''`
|
|
180
|
+
case 'maxLen': return `String(${lhs} ?? '').length <= ${Number(r.value)}`
|
|
181
|
+
case 'minLen': return `String(${lhs} ?? '').length >= ${Number(r.value)}`
|
|
182
|
+
default: return 'true'
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function schemasModule(entries: Prepared[]): GeneratedFile {
|
|
187
|
+
const body = entries
|
|
188
|
+
.map(({ schema: s, infos }) => {
|
|
189
|
+
const n = namesFor(s)
|
|
190
|
+
const derived = new Set(infos.map((i) => i.displayField))
|
|
191
|
+
const fieldNames = s.fields.map((f) => f.field)
|
|
192
|
+
// Strip the no-code specs from the emitted DATA literal; compile them below
|
|
193
|
+
// into real `computed` / `hooks.validate` functions (JSON drops functions).
|
|
194
|
+
const clean: EntitySchema = {
|
|
195
|
+
...s,
|
|
196
|
+
fields: s.fields.map((f) => { const { formula: _f, ...rest } = f; return rest }),
|
|
197
|
+
}
|
|
198
|
+
delete (clean as { validations?: unknown }).validations
|
|
199
|
+
let out = `${rowType(s, derived)}\n\nexport const ${n.schemaVar}: EntitySchema<${n.type}> = ${JSON.stringify(clean, null, 2)}`
|
|
200
|
+
|
|
201
|
+
// Computed fields from formulas.
|
|
202
|
+
const aug: string[] = []
|
|
203
|
+
for (const f of s.fields) {
|
|
204
|
+
if (!f.formula || f.computed) continue
|
|
205
|
+
aug.push(`;(${n.schemaVar}.fields.find((f) => f.field === ${JSON.stringify(f.field)})!).computed = (row) => { const $ = row as Record<string, any>; return (${compileFormula(f.formula, fieldNames)}) }`)
|
|
206
|
+
}
|
|
207
|
+
// Cross-field validation rules -> hooks.validate.
|
|
208
|
+
if (s.validations?.length) {
|
|
209
|
+
const checks = s.validations.map((r) => ` if (!(${compileValidation(r)})) errors[${JSON.stringify(r.field)}] = ${JSON.stringify(r.message)}`).join('\n')
|
|
210
|
+
aug.push(`${n.schemaVar}.hooks = {\n ...${n.schemaVar}.hooks,\n validate: (values) => {\n const $ = values as Record<string, any>\n const errors: Record<string, string> = {}\n${checks}\n return Object.keys(errors).length ? errors : null\n },\n}`)
|
|
211
|
+
}
|
|
212
|
+
if (aug.length) out += '\n\n' + aug.join('\n')
|
|
213
|
+
return out
|
|
214
|
+
})
|
|
215
|
+
.join('\n\n')
|
|
216
|
+
return {
|
|
217
|
+
path: 'src/lib/schemas.ts',
|
|
218
|
+
description: 'One EntitySchema per entity, generated from your schema. Add computed fields, hooks, or validators here.',
|
|
219
|
+
contents: `/**
|
|
220
|
+
* Generated from your schema by \`npm create @svgrid/studio --from\`. Each
|
|
221
|
+
* EntitySchema drives the grid columns, the edit form, and validation. Add a
|
|
222
|
+
* field here (and to its type) and it shows up in the grid and the form.
|
|
223
|
+
*/
|
|
224
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
225
|
+
|
|
226
|
+
${body}
|
|
227
|
+
`,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- REST / SQL source-expression helpers ----------------------------------
|
|
232
|
+
|
|
233
|
+
const BACKTICK = String.fromCharCode(96)
|
|
234
|
+
|
|
235
|
+
/** Join a base URL and a path, tolerating leading/trailing slashes + empty base. */
|
|
236
|
+
function joinUrl(base: string, path: string): string {
|
|
237
|
+
const b = (base ?? '').replace(/\/+$/, '')
|
|
238
|
+
const p = (path ?? '').replace(/^\/+/, '')
|
|
239
|
+
if (!b) return p
|
|
240
|
+
if (!p) return b
|
|
241
|
+
return `${b}/${p}`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `body` + `data.items` -> `body?.data?.items` (safe access into a response). */
|
|
245
|
+
function dottedAccess(root: string, dotted: string): string {
|
|
246
|
+
return dotted.split('.').filter(Boolean).reduce((acc, key) => `${acc}?.${key}`, root)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The REST collection URL with static path params substituted (`{id}` -> value). */
|
|
250
|
+
function buildRestUrl(src: RestSource): string {
|
|
251
|
+
let path = src.path
|
|
252
|
+
for (const p of src.params) {
|
|
253
|
+
if (p.location === 'path' && p.value) path = path.split(`{${p.name}}`).join(p.value)
|
|
254
|
+
}
|
|
255
|
+
return joinUrl(src.baseUrl, path)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A `SqlDialect` object literal for the dialect, or null to use adapter defaults. */
|
|
259
|
+
function sqlDialectExpr(dialect?: SqlDialectKind): string | null {
|
|
260
|
+
switch (dialect) {
|
|
261
|
+
case 'postgres':
|
|
262
|
+
case 'supabase': return "{ placeholders: '$', ilike: true }"
|
|
263
|
+
case 'mysql': return `{ quote: '${BACKTICK}', placeholders: '?' }`
|
|
264
|
+
case 'mssql': return "{ placeholders: '@' }"
|
|
265
|
+
case 'turso': return "{ placeholders: '?' }" // libSQL / SQLite positional args
|
|
266
|
+
default: return null // sqlite / undefined -> adapter defaults
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sq = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
271
|
+
|
|
272
|
+
/** The `createRestDataSource(...)` call for one entity's REST binding. */
|
|
273
|
+
function restStoreExpr(src: RestSource, storeName: string, T: string, schemaVar: string): string {
|
|
274
|
+
const opts: string[] = [`url: ${sq(buildRestUrl(src))}`, `schema: ${schemaVar}`]
|
|
275
|
+
if (src.idField) opts.push(`idField: ${sq(src.idField)}`)
|
|
276
|
+
const headers = src.params.filter((p) => p.location === 'header' && p.value !== undefined && p.value !== '')
|
|
277
|
+
if (headers.length) opts.push(`headers: { ${headers.map((h) => `${sq(h.name)}: ${sq(h.value ?? '')}`).join(', ')} }`)
|
|
278
|
+
const query = src.params.filter((p) => p.location === 'query' && p.value !== undefined && p.value !== '')
|
|
279
|
+
if (query.length) opts.push(`query: { ${query.map((q) => `${sq(q.name)}: ${sq(q.value ?? '')}`).join(', ')} }`)
|
|
280
|
+
if (src.rowsPath || src.totalPath) {
|
|
281
|
+
const rowsExpr = src.rowsPath ? dottedAccess('body', src.rowsPath) : 'body'
|
|
282
|
+
const totalExpr = src.totalPath ? dottedAccess('body', src.totalPath) : `(${rowsExpr})?.length`
|
|
283
|
+
opts.push(`parse: (body: any) => ({ rows: ${rowsExpr} ?? [], rowCount: Number(${totalExpr} ?? 0) })`)
|
|
284
|
+
}
|
|
285
|
+
return `const ${storeName} = createRestDataSource<${T}>({ ${opts.join(', ')} })`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type DataModuleNeeds = {
|
|
289
|
+
supabase: boolean
|
|
290
|
+
supabaseUrl?: string
|
|
291
|
+
supabaseKey?: string
|
|
292
|
+
/** SQL-bound entities get a connected `+server.ts` API route each. */
|
|
293
|
+
sqlRoutes: Array<{ schema: EntitySchema; table: string; dialect?: SqlDialectKind }>
|
|
294
|
+
/** Embedded-Postgres (PGlite) entities: one shared client + a table each. */
|
|
295
|
+
pglite?: boolean
|
|
296
|
+
pgliteTables?: Array<{ schema: EntitySchema; table: string; seed?: Record<string, unknown>[] }>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Per-dialect server driver wiring for a connected `+server.ts` (reads `$env DATABASE_URL`). */
|
|
300
|
+
const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', { dep: string; imports: string; setup: string; exec: string }> = {
|
|
301
|
+
postgres: { dep: 'pg', imports: `import pg from 'pg'`, setup: `const pool = new pg.Pool({ connectionString: env.DATABASE_URL })`, exec: `const result = await pool.query(text, params)\n return result.rows` },
|
|
302
|
+
mysql: { dep: 'mysql2', imports: `import mysql from 'mysql2/promise'`, setup: `const pool = mysql.createPool(env.DATABASE_URL ?? '')`, exec: `const [rows] = await pool.query(text, params)\n return rows as Record<string, unknown>[]` },
|
|
303
|
+
mssql: { dep: 'mssql', imports: `import mssql from 'mssql'`, setup: `const poolPromise = mssql.connect(env.DATABASE_URL ?? '')`, exec: `const pool = await poolPromise\n const request = pool.request()\n params.forEach((p, i) => request.input('p' + (i + 1), p))\n const result = await request.query(text)\n return result.recordset as Record<string, unknown>[]` },
|
|
304
|
+
sqlite: { dep: 'better-sqlite3', imports: `import Database from 'better-sqlite3'`, setup: `const db = new Database(env.DATABASE_URL ?? 'data.db')`, exec: `return db.prepare(text).all(...params) as Record<string, unknown>[]` },
|
|
305
|
+
// Turso / libSQL: hosted SQLite over HTTP. DATABASE_URL is the libsql:// URL,
|
|
306
|
+
// DATABASE_AUTH_TOKEN the database token. Runs on serverless + edge.
|
|
307
|
+
turso: { dep: '@libsql/client', imports: `import { createClient } from '@libsql/client'`, setup: `const client = createClient({ url: env.DATABASE_URL ?? '', authToken: env.DATABASE_AUTH_TOKEN })`, exec: `const rs = await client.execute({ sql: text, args: params })\n return rs.rows as unknown as Record<string, unknown>[]` },
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A fully-connected SvelteKit API route for a SQL-bound entity. When RBAC is on,
|
|
311
|
+
* the route imports the shared access policy and rejects unauthorized writes -
|
|
312
|
+
* server-enforced, so a tampered client can't bypass it. When audit is on, every
|
|
313
|
+
* successful write is recorded. */
|
|
314
|
+
function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean } = {}): GeneratedFile {
|
|
315
|
+
const n = namesFor(schema)
|
|
316
|
+
const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
|
|
317
|
+
const driver = SQL_DRIVERS[key]
|
|
318
|
+
const dialectLiteral = sqlDialectExpr(dialect)
|
|
319
|
+
const accessImport = feat.access ? `\nimport { authorizeAction, getServerRole } from '$lib/access'` : ''
|
|
320
|
+
const auditImport = feat.audit ? `\nimport { recordAudit } from '$lib/audit'` : ''
|
|
321
|
+
// Every connected route validates writes against the schema server-side, and
|
|
322
|
+
// (when enabled) authorizes them by role + records an audit entry.
|
|
323
|
+
const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
|
|
324
|
+
if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals).\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action)`)
|
|
325
|
+
if (feat.audit) opts.push(`// Record every successful write to the audit trail.\n audit: (e) => recordAudit({ entity: ${JSON.stringify(schema.name)}, action: e.action, recordId: e.id, values: e.values as Record<string, unknown> | undefined, actor: String(e.event.locals?.role ?? e.event.locals?.user ?? 'system') })`)
|
|
326
|
+
const handlers = `export const { POST } = createKitHandlers({\n ${opts.join(',\n ')},\n})`
|
|
327
|
+
return {
|
|
328
|
+
path: `src/routes/api/${n.route}/+server.ts`,
|
|
329
|
+
description: `Connected API route for ${n.label} (SQL via DATABASE_URL). Runs server-side.`,
|
|
330
|
+
contents: `${driver.imports}
|
|
331
|
+
import { env } from '$env/dynamic/private'
|
|
332
|
+
import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'
|
|
333
|
+
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}
|
|
334
|
+
|
|
335
|
+
${driver.setup}
|
|
336
|
+
|
|
337
|
+
const source = createSqlDataSource<${n.type}>({
|
|
338
|
+
schema: ${n.schemaVar},
|
|
339
|
+
table: ${JSON.stringify(table)},${dialectLiteral ? `\n dialect: ${dialectLiteral},` : ''}
|
|
340
|
+
execute: async (text, params) => {
|
|
341
|
+
${driver.exec}
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
${handlers}
|
|
346
|
+
`,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Map an EntityField type to a Postgres column type for PGlite DDL. */
|
|
351
|
+
const PG_COL_TYPE: Record<string, string> = { number: 'double precision', boolean: 'boolean', date: 'date', datetime: 'timestamptz', json: 'jsonb' }
|
|
352
|
+
/** The shared PGlite client + table DDL + one-time seed, injected into data.ts. */
|
|
353
|
+
function pgliteBootstrap(tables: Array<{ schema: EntitySchema; table: string; seed?: Record<string, unknown>[] }>, seed: Map<string, Array<Record<string, unknown>>>): string {
|
|
354
|
+
const ddls: string[] = []
|
|
355
|
+
const seeds: string[] = []
|
|
356
|
+
for (const { schema, table, seed: curated } of tables) {
|
|
357
|
+
const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
358
|
+
const cols = schema.fields.map((f) => `"${f.field}" ${PG_COL_TYPE[f.type] ?? 'text'}${f.primaryKey || f.field === pk ? ' primary key' : ''}`).join(', ')
|
|
359
|
+
ddls.push(`CREATE TABLE IF NOT EXISTS "${table}" (${cols});`)
|
|
360
|
+
// Migrate schema additions: fields added after the DB was first created still
|
|
361
|
+
// appear (CREATE TABLE IF NOT EXISTS is a no-op once the table exists).
|
|
362
|
+
for (const f of schema.fields) {
|
|
363
|
+
if (f.primaryKey || f.field === pk) continue
|
|
364
|
+
ddls.push(`ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "${f.field}" ${PG_COL_TYPE[f.type] ?? 'text'};`)
|
|
365
|
+
}
|
|
366
|
+
// Prefer rows imported/curated on the source (e.g. a CSV import), else the generator.
|
|
367
|
+
const rows = curated ?? seed.get(schema.name) ?? []
|
|
368
|
+
seeds.push(` await _pgSeed(${JSON.stringify(table)}, ${JSON.stringify(rows)}, ${JSON.stringify(schema.fields.map((f) => f.field))})`)
|
|
369
|
+
}
|
|
370
|
+
return `// Embedded Postgres (PGlite), persisted in the browser (IndexedDB) - a real,
|
|
371
|
+
// persistent database with ZERO backend setup. To go to production, bind these
|
|
372
|
+
// entities to a hosted SQL source (same schema) instead.
|
|
373
|
+
const pg = new PGlite('idb://svgrid-studio')
|
|
374
|
+
const pgReady = (async () => {
|
|
375
|
+
await pg.exec(\`
|
|
376
|
+
${ddls.join('\n ')}
|
|
377
|
+
\`)
|
|
378
|
+
${seeds.join('\n')}
|
|
379
|
+
})()
|
|
380
|
+
async function _pgSeed(table: string, rows: Record<string, unknown>[], cols: string[]): Promise<void> {
|
|
381
|
+
if (!rows.length) return
|
|
382
|
+
const existing = await pg.query(\`select count(*)::int as n from "\${table}"\`)
|
|
383
|
+
if (((existing.rows[0] as { n?: number } | undefined)?.n ?? 0) > 0) return // already seeded
|
|
384
|
+
for (const row of rows) {
|
|
385
|
+
await pg.query(
|
|
386
|
+
\`insert into "\${table}" (\${cols.map((c) => '"' + c + '"').join(', ')}) values (\${cols.map((_, i) => '$' + (i + 1)).join(', ')})\`,
|
|
387
|
+
cols.map((c) => { const v = row[c]; return v != null && typeof v === 'object' ? JSON.stringify(v) : (v ?? null) }),
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
`
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function dataModule(
|
|
395
|
+
entries: Prepared[],
|
|
396
|
+
seed: Map<string, Array<Record<string, unknown>>>,
|
|
397
|
+
sources?: Record<string, EntityDataSource>,
|
|
398
|
+
): { file: GeneratedFile; needs: DataModuleNeeds } {
|
|
399
|
+
const schemas = entries.map((e) => e.schema)
|
|
400
|
+
// Each type gets its own `type` keyword - SvelteKit enables verbatimModuleSyntax.
|
|
401
|
+
const typeImports = schemas.map((s) => `type ${pascal(s.name)}`).join(', ')
|
|
402
|
+
const schemaImports = schemas.map((s) => namesFor(s).schemaVar).join(', ')
|
|
403
|
+
const hasRelations = entries.some((e) => e.infos.length > 0)
|
|
404
|
+
const entImports = new Set<string>()
|
|
405
|
+
const needs: DataModuleNeeds = { supabase: false, sqlRoutes: [] }
|
|
406
|
+
|
|
407
|
+
// 1. Base store per entity, branched on its bound data source (default: seeded
|
|
408
|
+
// in-memory, so the app runs with no backend).
|
|
409
|
+
const stores = entries
|
|
410
|
+
.map((e) => {
|
|
411
|
+
const T = pascal(e.schema.name)
|
|
412
|
+
const sv = namesFor(e.schema).schemaVar
|
|
413
|
+
const store = storeVar(e.schema)
|
|
414
|
+
const src = sources?.[e.schema.name] ?? { kind: 'memory' as const }
|
|
415
|
+
switch (src.kind) {
|
|
416
|
+
case 'rest':
|
|
417
|
+
entImports.add('createRestDataSource')
|
|
418
|
+
return restStoreExpr(src, store, T, sv)
|
|
419
|
+
case 'sql':
|
|
420
|
+
// SQL runs server-side: the client talks to a generated /api/<entity> route.
|
|
421
|
+
entImports.add('createKitDataSource')
|
|
422
|
+
needs.sqlRoutes.push({ schema: e.schema, table: src.table, dialect: src.dialect })
|
|
423
|
+
return `const ${store} = createKitDataSource<${T}>({ endpoint: '/api/${namesFor(e.schema).route}' })`
|
|
424
|
+
case 'supabase':
|
|
425
|
+
entImports.add('createSupabaseDataSource')
|
|
426
|
+
needs.supabase = true
|
|
427
|
+
if (src.url && src.key && !needs.supabaseUrl) { needs.supabaseUrl = src.url; needs.supabaseKey = src.key }
|
|
428
|
+
return `const ${store} = createSupabaseDataSource<${T}>({ client: supabaseClient, table: ${sq(src.table)}, schema: ${sv} })`
|
|
429
|
+
case 'pglite': {
|
|
430
|
+
// Embedded Postgres (PGlite) in the browser - real SQL, persisted to
|
|
431
|
+
// IndexedDB, zero backend setup. Shares one `pg` client + `pgReady`.
|
|
432
|
+
entImports.add('createSqlDataSource')
|
|
433
|
+
needs.pglite = true
|
|
434
|
+
needs.pgliteTables ??= []
|
|
435
|
+
needs.pgliteTables.push({ schema: e.schema, table: src.table, seed: src.seed })
|
|
436
|
+
return `const ${store} = createSqlDataSource<${T}>({ schema: ${sv}, table: ${sq(src.table)}, dialect: { placeholders: '$', ilike: true }, execute: async (text, params) => { await pgReady; return (await pg.query(text, params)).rows as Record<string, unknown>[] } })`
|
|
437
|
+
}
|
|
438
|
+
default: {
|
|
439
|
+
entImports.add('createInMemoryDataSource')
|
|
440
|
+
// Prefer curated seed on the source (a sample app), else the realistic generator.
|
|
441
|
+
const curated = src.kind === 'memory' ? src.seed : undefined
|
|
442
|
+
const rows = JSON.stringify(curated ?? seed.get(e.schema.name) ?? [])
|
|
443
|
+
return `const ${store} = createInMemoryDataSource<${T}>(${rows}, ${sv})`
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
.join('\n')
|
|
448
|
+
|
|
449
|
+
// 2. A searchable picker per relation, reading the related store.
|
|
450
|
+
const lookups: string[] = []
|
|
451
|
+
for (const { schema: s, infos } of entries) {
|
|
452
|
+
for (const info of infos) {
|
|
453
|
+
const rn = namesFor(info.related)
|
|
454
|
+
lookups.push(
|
|
455
|
+
`export const ${lookupVar(s, info.fkField)} = createRelationLookup<${rn.type}>({ source: ${storeVar(info.related)}, schema: ${rn.schemaVar}, labelField: '${(s.fields.find((f) => f.field === info.fkField)?.relation?.labelField) ?? 'name'}' })`,
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (hasRelations) { entImports.add('createRelationLookup'); entImports.add('withRelationLabels') }
|
|
460
|
+
|
|
461
|
+
// 3. Exported sources: wrap with withRelationLabels so the grid shows labels.
|
|
462
|
+
const sourceLines = entries
|
|
463
|
+
.map(({ schema: s, infos }) => {
|
|
464
|
+
const n = namesFor(s)
|
|
465
|
+
if (infos.length === 0) return `export const ${n.sourceVar} = ${storeVar(s)}`
|
|
466
|
+
const cfgs = infos.map((i) => `{ fk: '${i.fkField}', lookup: ${lookupVar(s, i.fkField)}, as: '${i.displayField}' }`).join(', ')
|
|
467
|
+
return `export const ${n.sourceVar} = withRelationLabels(${storeVar(s)}, [${cfgs}])`
|
|
468
|
+
})
|
|
469
|
+
.join('\n')
|
|
470
|
+
|
|
471
|
+
const connImportLine = needs.supabase ? `\nimport { supabaseClient } from './connections'` : ''
|
|
472
|
+
const pgliteImportLine = needs.pglite ? `\nimport { PGlite } from '@electric-sql/pglite'` : ''
|
|
473
|
+
const pgliteBoot = needs.pglite ? pgliteBootstrap(needs.pgliteTables!, seed) + '\n' : ''
|
|
474
|
+
|
|
475
|
+
const file: GeneratedFile = {
|
|
476
|
+
path: 'src/lib/data.ts',
|
|
477
|
+
description: 'Data sources per entity (in-memory / REST / SQL / Supabase / PGlite) + a searchable lookup per relation.',
|
|
478
|
+
contents: `/**
|
|
479
|
+
* Data sources. Each entity binds to its own backend - in-memory (seeded), a
|
|
480
|
+
* REST endpoint, a SQL table, Supabase, or embedded Postgres (PGlite). In-memory
|
|
481
|
+
* + PGlite run with no setup; SQL / Supabase read their connection from \`./connections\`.
|
|
482
|
+
*/
|
|
483
|
+
import { ${[...entImports].join(', ')} } from '@svgrid/enterprise'
|
|
484
|
+
import { ${schemaImports}, ${typeImports} } from './schemas'${connImportLine}${pgliteImportLine}
|
|
485
|
+
|
|
486
|
+
${pgliteBoot}${stores}
|
|
487
|
+
${lookups.length ? `\n// Searchable pickers for relation (foreign-key) fields.\n${lookups.join('\n')}\n` : ''}
|
|
488
|
+
// Grids show the related label (not the raw id) via withRelationLabels.
|
|
489
|
+
${sourceLines}
|
|
490
|
+
|
|
491
|
+
// New-row ids continue after the seeded rows.
|
|
492
|
+
let seq = ${SEED_COUNT}
|
|
493
|
+
export const nextId = (prefix: string) => \`\${prefix}\${++seq}\`
|
|
494
|
+
`,
|
|
495
|
+
}
|
|
496
|
+
return { file, needs }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The connection stubs for SQL / Supabase sources - the one manual wiring step. */
|
|
500
|
+
function connectionsModule(needs: DataModuleNeeds): GeneratedFile {
|
|
501
|
+
const realSupabase = !!(needs.supabaseUrl && needs.supabaseKey)
|
|
502
|
+
const valueImport = realSupabase ? `import { createClient } from '@supabase/supabase-js'\n` : ''
|
|
503
|
+
const body = realSupabase
|
|
504
|
+
? `/**
|
|
505
|
+
* Supabase client. The anon key is public (browser-safe) - access is protected
|
|
506
|
+
* by your Row Level Security policies. Requires \`@supabase/supabase-js\`
|
|
507
|
+
* (\`npm i @supabase/supabase-js\`).
|
|
508
|
+
*/
|
|
509
|
+
export const supabaseClient = createClient(${sq(needs.supabaseUrl!)}, ${sq(needs.supabaseKey!)}) as unknown as SupabaseClientLike`
|
|
510
|
+
: `/**
|
|
511
|
+
* TODO: connect Supabase. Add your project URL + anon key in the Studio designer,
|
|
512
|
+
* or replace this null stub with a real client:
|
|
513
|
+
* \`import { createClient } from '@supabase/supabase-js'; export const supabaseClient = createClient(url, anonKey)\`.
|
|
514
|
+
*/
|
|
515
|
+
export const supabaseClient = null as unknown as SupabaseClientLike`
|
|
516
|
+
return {
|
|
517
|
+
path: 'src/lib/connections.ts',
|
|
518
|
+
description: 'Supabase client for Supabase-bound entities.',
|
|
519
|
+
contents: `${valueImport}import type { SupabaseClientLike } from '@svgrid/enterprise'\n\n${body}\n`,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A CRUD screen page (grid + form) for a prepared entity schema. `route` and
|
|
525
|
+
* `title` default to the entity's, but the Studio designer overrides them so a
|
|
526
|
+
* project can have custom routes / titles / multiple screens per entity.
|
|
527
|
+
*/
|
|
528
|
+
export function entityScreenPage(schema: EntitySchema, route?: string, title?: string): GeneratedFile {
|
|
529
|
+
const n = namesFor(schema)
|
|
530
|
+
const r = route ?? n.route
|
|
531
|
+
const t = title ?? n.label
|
|
532
|
+
const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
|
|
533
|
+
const lookupImports = relationFields.map((f) => lookupVar(schema, f.field))
|
|
534
|
+
const dataImports = [n.sourceVar, ...lookupImports, 'nextId'].join(', ')
|
|
535
|
+
const lookupsProp = relationFields.length
|
|
536
|
+
? `\n lookups={{ ${relationFields.map((f) => `${f.field}: ${lookupVar(schema, f.field)}`).join(', ')} }}`
|
|
537
|
+
: ''
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
path: `src/routes/${r}/+page.svelte`,
|
|
541
|
+
description: `CRUD screen for ${t}.`,
|
|
542
|
+
contents: `<script lang="ts">
|
|
543
|
+
import EntityScreen from '$lib/EntityScreen.svelte'
|
|
544
|
+
import { ${n.schemaVar} } from '$lib/schemas'
|
|
545
|
+
import { ${dataImports} } from '$lib/data'
|
|
546
|
+
</script>
|
|
547
|
+
|
|
548
|
+
<h1 class="st__title">${t}</h1>
|
|
549
|
+
<EntityScreen schema={${n.schemaVar}} source={${n.sourceVar}}${lookupsProp} newId={() => nextId('${n.idPrefix}')} />
|
|
550
|
+
`,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export type NavItem = { href: string; label: string; id?: string }
|
|
555
|
+
|
|
556
|
+
export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; dark?: boolean; access?: boolean; i18n?: boolean } = {}): GeneratedFile {
|
|
557
|
+
const links = [{ href: '/', label: 'Home' }, ...nav]
|
|
558
|
+
const shell = opts.shell ?? {}
|
|
559
|
+
const style: 'sidebar' | 'top-nav' = shell.style ?? 'sidebar'
|
|
560
|
+
const brand = (shell.brand ?? '').trim() || opts.title || 'My Studio App'
|
|
561
|
+
const footer = shell.footer === undefined ? 'Built with SvGrid Studio' : shell.footer
|
|
562
|
+
const right = style === 'sidebar' && shell.navPosition === 'right'
|
|
563
|
+
// Emit the full theme token bundle (+ color-scheme for dark themes) so the
|
|
564
|
+
// generated app matches the look-and-feel picked in the designer.
|
|
565
|
+
const vars = { ...(opts.themeVars ?? {}) }
|
|
566
|
+
if (opts.accent) vars['--sg-accent'] = opts.accent
|
|
567
|
+
const varLines = Object.entries(vars).map(([k, v]) => `${k}: ${v};`).join(' ')
|
|
568
|
+
const rootRule = [varLines, opts.dark ? 'color-scheme: dark;' : ''].filter(Boolean).join(' ')
|
|
569
|
+
const themeHead = rootRule ? `\n<svelte:head><style>:root { ${rootRule} }</style></svelte:head>\n` : ''
|
|
570
|
+
|
|
571
|
+
// i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
|
|
572
|
+
const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
|
|
573
|
+
const anchor = `<a class="sv-app__link" class:is-active={$page.url.pathname === item.href} href={item.href}>${navLabel}</a>`
|
|
574
|
+
// When RBAC is on, hide nav links the current role can't open.
|
|
575
|
+
const linkGate = opts.access
|
|
576
|
+
? `{#if !item.id || canScreen($currentRole, item.id)}${anchor}{/if}`
|
|
577
|
+
: anchor
|
|
578
|
+
const localeSwitcher = opts.i18n
|
|
579
|
+
? `\n <select class="sv-app__locale" aria-label="Language" onchange={(e) => currentLocale.set(e.currentTarget.value as typeof $currentLocale)}>
|
|
580
|
+
{#each locales as loc (loc)}<option value={loc} selected={loc === $currentLocale}>{loc}</option>{/each}
|
|
581
|
+
</select>`
|
|
582
|
+
: ''
|
|
583
|
+
// Clicking any nav link closes the mobile drawer.
|
|
584
|
+
const linksMarkup = `<nav class="sv-app__links" onclick={() => (navOpen = false)}>
|
|
585
|
+
{#each nav as item (item.href)}
|
|
586
|
+
${linkGate}
|
|
587
|
+
{/each}
|
|
588
|
+
</nav>${localeSwitcher}`
|
|
589
|
+
const footMarkup = footer ? `\n <span class="sv-app__foot">{footer}</span>` : ''
|
|
590
|
+
const footConst = footer ? `\n const footer = ${JSON.stringify(footer)}` : ''
|
|
591
|
+
// Sidebar-only: a collapsible sidebar that docks on wide screens and becomes an
|
|
592
|
+
// off-canvas drawer when collapsed or on tablet/phone (<= 1024px). The collapse
|
|
593
|
+
// choice persists; tablet/phone start collapsed.
|
|
594
|
+
const sideState = style === 'top-nav' ? '' : `
|
|
595
|
+
let collapsed = $state(false)
|
|
596
|
+
let narrow = $state(false)
|
|
597
|
+
const drawer = $derived(collapsed || narrow)
|
|
598
|
+
function toggleNav() { collapsed = !collapsed; try { localStorage.setItem('svapp:nav', collapsed ? 'c' : 'e') } catch (_) { /* storage blocked */ } }
|
|
599
|
+
$effect(() => {
|
|
600
|
+
// Tablet / phone (<= 1024px) collapse to a drawer by default.
|
|
601
|
+
const mq = window.matchMedia('(max-width: 1024px)')
|
|
602
|
+
const sync = () => (narrow = mq.matches)
|
|
603
|
+
sync()
|
|
604
|
+
try { const s = localStorage.getItem('svapp:nav'); if (s) collapsed = s === 'c' } catch (_) { /* storage blocked */ }
|
|
605
|
+
mq.addEventListener('change', sync)
|
|
606
|
+
return () => mq.removeEventListener('change', sync)
|
|
607
|
+
})`
|
|
608
|
+
|
|
609
|
+
// Brand: a company logo image (data URL / URL) when set, else the app name.
|
|
610
|
+
const logoConst = shell.logo ? `\n const logo = ${JSON.stringify(shell.logo)}` : ''
|
|
611
|
+
const brandInner = shell.logo
|
|
612
|
+
? `<img class="sv-app__logo" src={logo} alt={brand} />`
|
|
613
|
+
: `<span class="sv-app__brandtext">{brand}</span>`
|
|
614
|
+
const brandLink = `<a class="sv-app__brand" href="/">${brandInner}</a>`
|
|
615
|
+
// Collapsed bar (tablet / phone, or a collapsed desktop sidebar): a hamburger +
|
|
616
|
+
// brand. The nav slides in as an off-canvas drawer. On desktop the "dock" button
|
|
617
|
+
// pins the sidebar back open.
|
|
618
|
+
const mobileBar = `<header class="sv-app__mobilebar">
|
|
619
|
+
<button type="button" class="sv-app__burger" aria-label="Toggle navigation" aria-expanded={navOpen} onclick={() => (navOpen = !navOpen)}>
|
|
620
|
+
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
621
|
+
</button>
|
|
622
|
+
${brandLink}
|
|
623
|
+
{#if !narrow}<button type="button" class="sv-app__dock" aria-label="Dock sidebar" title="Dock sidebar" onclick={toggleNav}>
|
|
624
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
|
|
625
|
+
</button>{/if}
|
|
626
|
+
</header>
|
|
627
|
+
{#if navOpen}<button type="button" class="sv-app__backdrop" aria-label="Close navigation" onclick={() => (navOpen = false)}></button>{/if}`
|
|
628
|
+
|
|
629
|
+
// App-chrome toolbar (docked at the top of the content area for both layouts):
|
|
630
|
+
// a functional quick-search over the app's screens + an account cluster. Reads as
|
|
631
|
+
// a real product header. Opt out with `shell.toolbar === false`.
|
|
632
|
+
const toolbarOn = shell.toolbar !== false && nav.length > 0
|
|
633
|
+
const initials = ((brand.match(/\b[A-Za-z0-9]/g) ?? []).slice(0, 2).join('') || 'A').toUpperCase()
|
|
634
|
+
const brandSlug = brand.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'app'
|
|
635
|
+
const resultLabel = opts.i18n ? `{r.id ? $t('nav.' + r.id, r.label) : r.label}` : '{r.label}'
|
|
636
|
+
const toolbarMarkup = toolbarOn
|
|
637
|
+
? `<div class="sv-app__toolbar">
|
|
638
|
+
<div class="sv-app__search">
|
|
639
|
+
<svg class="sv-app__search-ic" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>
|
|
640
|
+
<input class="sv-app__search-in" type="search" placeholder="Search {brand}…" bind:value={q} bind:this={searchEl} aria-label="Search screens" />
|
|
641
|
+
<kbd class="sv-app__kbd" aria-hidden="true">⌘K</kbd>
|
|
642
|
+
{#if results.length}
|
|
643
|
+
<div class="sv-app__results">
|
|
644
|
+
{#each results as r (r.href)}
|
|
645
|
+
<a class="sv-app__result" href={r.href} onclick={() => (q = '')}>${resultLabel}</a>
|
|
646
|
+
{/each}
|
|
647
|
+
</div>
|
|
648
|
+
{/if}
|
|
649
|
+
</div>
|
|
650
|
+
<div class="sv-app__tools">
|
|
651
|
+
<div class="sv-app__pop">
|
|
652
|
+
<button type="button" class="sv-app__tool" aria-label="Notifications" aria-expanded={bellOpen} onclick={() => { bellOpen = !bellOpen; menuOpen = false }}>
|
|
653
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
|
654
|
+
<span class="sv-app__dot"></span>
|
|
655
|
+
</button>
|
|
656
|
+
{#if bellOpen}
|
|
657
|
+
<div class="sv-app__menu sv-app__menu--notif" role="menu">
|
|
658
|
+
<div class="sv-app__menu-head">Notifications</div>
|
|
659
|
+
{#each notifications as n (n.title)}
|
|
660
|
+
<div class="sv-app__notif" role="menuitem"><span class="sv-app__notif-dot" style:background={n.color}></span><div class="sv-app__notif-body"><strong>{n.title}</strong><span>{n.time}</span></div></div>
|
|
661
|
+
{/each}
|
|
662
|
+
<button type="button" class="sv-app__menu-foot" onclick={() => (bellOpen = false)}>Mark all as read</button>
|
|
663
|
+
</div>
|
|
664
|
+
{/if}
|
|
665
|
+
</div>
|
|
666
|
+
<div class="sv-app__pop">
|
|
667
|
+
<button type="button" class="sv-app__avatar" title={brand} aria-label="Account" aria-expanded={menuOpen} onclick={() => { menuOpen = !menuOpen; bellOpen = false }}>{initials}</button>
|
|
668
|
+
{#if menuOpen}
|
|
669
|
+
<div class="sv-app__menu sv-app__menu--acct" role="menu">
|
|
670
|
+
<div class="sv-app__acct-head"><span class="sv-app__avatar sv-app__avatar--lg" aria-hidden="true">{initials}</span><div class="sv-app__acct-id"><strong>{brand}</strong><span>{acctEmail}</span></div></div>
|
|
671
|
+
<a class="sv-app__menu-item" href="/" role="menuitem" onclick={() => (menuOpen = false)}>Dashboard</a>
|
|
672
|
+
<button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Profile</button>
|
|
673
|
+
<button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>
|
|
674
|
+
<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>
|
|
675
|
+
</div>
|
|
676
|
+
{/if}
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
{#if bellOpen || menuOpen}<button type="button" class="sv-app__scrim" tabindex="-1" aria-label="Close menus" onclick={() => { bellOpen = false; menuOpen = false }}></button>{/if}
|
|
680
|
+
</div>\n `
|
|
681
|
+
: ''
|
|
682
|
+
const mainMarkup = `<main class="sv-app__main">
|
|
683
|
+
${toolbarMarkup}<div class="sv-app__content">
|
|
684
|
+
{@render children()}
|
|
685
|
+
</div>
|
|
686
|
+
</main>`
|
|
687
|
+
|
|
688
|
+
const body = style === 'top-nav'
|
|
689
|
+
? `<div class="sv-app sv-app--top">
|
|
690
|
+
<header class="sv-app__bar">
|
|
691
|
+
${brandLink}
|
|
692
|
+
${linksMarkup}
|
|
693
|
+
</header>
|
|
694
|
+
${mainMarkup}${footer ? `\n <footer class="sv-app__footbar">{footer}</footer>` : ''}
|
|
695
|
+
</div>`
|
|
696
|
+
: `<div class="sv-app sv-app--side${right ? ' sv-app--right' : ''}" class:is-drawer={drawer} class:is-navopen={navOpen}>
|
|
697
|
+
${mobileBar}
|
|
698
|
+
<aside class="sv-app__side">
|
|
699
|
+
<div class="sv-app__sidehead">
|
|
700
|
+
${brandLink}
|
|
701
|
+
<button type="button" class="sv-app__collapse" aria-label="Collapse sidebar" title="Collapse sidebar" onclick={toggleNav}>
|
|
702
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
${linksMarkup}${footMarkup}
|
|
706
|
+
</aside>
|
|
707
|
+
${mainMarkup}
|
|
708
|
+
</div>`
|
|
709
|
+
|
|
710
|
+
const styles = style === 'top-nav'
|
|
711
|
+
? ` .sv-app--top { display: flex; flex-direction: column; min-height: 100vh; }
|
|
712
|
+
.sv-app__bar { display: flex; align-items: center; gap: 20px; padding: 12px 22px; border-bottom: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec)); background: var(--sg-header-bg, #f8fafc); }
|
|
713
|
+
.sv-app__links { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
|
|
714
|
+
.sv-app__footbar { padding: 12px 22px; border-top: 1px solid var(--sg-border, #e6e8ec); color: var(--sg-muted, #94a3b8); font-size: 13px; }
|
|
715
|
+
/* Mobile: the bar stacks and its links scroll horizontally. */
|
|
716
|
+
@media (max-width: 640px) {
|
|
717
|
+
.sv-app__bar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; }
|
|
718
|
+
.sv-app__links { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
|
|
719
|
+
.sv-app__link { white-space: nowrap; }
|
|
720
|
+
}`
|
|
721
|
+
: ` .sv-app--side { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
|
722
|
+
.sv-app--side.sv-app--right { grid-template-columns: minmax(0, 1fr) 240px; }
|
|
723
|
+
.sv-app--right .sv-app__side { order: 2; border-right: 0; border-left: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec)); }
|
|
724
|
+
.sv-app__side { display: flex; flex-direction: column; gap: 4px; padding: 20px 14px; border-right: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec)); background: var(--sg-header-bg, #f8fafc); }
|
|
725
|
+
.sv-app__sidehead { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
|
726
|
+
.sv-app__collapse, .sv-app__dock { display: inline-flex; padding: 5px; border: 0; border-radius: 8px; background: transparent; color: var(--sg-muted, #64748b); cursor: pointer; }
|
|
727
|
+
.sv-app__collapse:hover, .sv-app__dock:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, #0f172a); }
|
|
728
|
+
.sv-app__mobilebar { display: none; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec)); background: var(--sg-header-bg, #f8fafc); }
|
|
729
|
+
.sv-app__mobilebar .sv-app__dock { margin-left: auto; }
|
|
730
|
+
.sv-app__burger { display: inline-flex; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
731
|
+
.sv-app__backdrop { position: fixed; inset: 0; z-index: 55; border: 0; background: rgba(15, 23, 42, 0.4); cursor: pointer; }
|
|
732
|
+
.sv-app__links { display: flex; flex-direction: column; gap: 2px; }
|
|
733
|
+
.sv-app__foot { margin-top: auto; padding-top: 14px; color: var(--sg-muted, #94a3b8); font-size: 12px; }
|
|
734
|
+
/* Drawer mode: tablet / phone (<= 1024px) or a user-collapsed desktop sidebar.
|
|
735
|
+
Driven by the .is-drawer class (JS matchMedia + the collapse toggle), so the
|
|
736
|
+
sidebar docks on wide screens and slides in as an off-canvas drawer otherwise. */
|
|
737
|
+
.sv-app--side.is-drawer, .sv-app--side.is-drawer.sv-app--right { display: block; }
|
|
738
|
+
.sv-app--side.is-drawer .sv-app__mobilebar { display: flex; }
|
|
739
|
+
.sv-app--side.is-drawer .sv-app__side {
|
|
740
|
+
position: fixed; top: 0; bottom: 0; left: 0; z-index: 60; width: 264px; max-width: 82vw;
|
|
741
|
+
transform: translateX(-100%); transition: transform 0.24s ease; box-shadow: 0 0 40px rgba(15, 23, 42, 0.28);
|
|
742
|
+
}
|
|
743
|
+
.sv-app--side.is-drawer.sv-app--right .sv-app__side { left: auto; right: 0; transform: translateX(100%); border-left: 0; }
|
|
744
|
+
.sv-app--side.is-drawer.is-navopen .sv-app__side { transform: translateX(0); }
|
|
745
|
+
.sv-app--side.is-drawer .sv-app__sidehead .sv-app__brand { display: none; } /* brand already in the top bar */
|
|
746
|
+
.sv-app--side.is-drawer .sv-app__collapse { display: none; } /* dock lives in the top bar when drawered */`
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
path: 'src/routes/+layout.svelte',
|
|
750
|
+
description: `App shell (${style}): nav linking every screen.`,
|
|
751
|
+
contents: `<script lang="ts">
|
|
752
|
+
import '../app.css'
|
|
753
|
+
import { page } from '$app/stores'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}
|
|
754
|
+
|
|
755
|
+
let { children } = $props()
|
|
756
|
+
const nav = ${JSON.stringify(links)}
|
|
757
|
+
const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
|
|
758
|
+
let navOpen = $state(false)
|
|
759
|
+
// Close the mobile drawer whenever the route changes.
|
|
760
|
+
$effect(() => { void $page.url.pathname; navOpen = false })${sideState}${toolbarOn ? `
|
|
761
|
+
// App-chrome quick-search: filter the screens by label as you type.
|
|
762
|
+
const initials = ${JSON.stringify(initials)}
|
|
763
|
+
const acctEmail = ${JSON.stringify(`admin@${brandSlug}.com`)}
|
|
764
|
+
let q = $state('')
|
|
765
|
+
const results = $derived(q.trim() ? nav.filter((n) => n.label.toLowerCase().includes(q.trim().toLowerCase())).slice(0, 6) : [])
|
|
766
|
+
let bellOpen = $state(false)
|
|
767
|
+
let menuOpen = $state(false)
|
|
768
|
+
let searchEl: HTMLInputElement | null = $state(null)
|
|
769
|
+
const notifications = [
|
|
770
|
+
{ title: 'Welcome to ' + brand, time: 'just now', color: 'var(--sg-accent, #6366f1)' },
|
|
771
|
+
{ title: 'Your workspace is ready', time: '2m ago', color: '#10b981' },
|
|
772
|
+
{ title: 'Sample data loaded', time: '5m ago', color: '#f59e0b' },
|
|
773
|
+
]
|
|
774
|
+
// Cmd/Ctrl+K focuses search; Escape closes the menus.
|
|
775
|
+
$effect(() => {
|
|
776
|
+
function onKey(e: KeyboardEvent) {
|
|
777
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); searchEl?.focus() }
|
|
778
|
+
else if (e.key === 'Escape') { bellOpen = false; menuOpen = false }
|
|
779
|
+
}
|
|
780
|
+
window.addEventListener('keydown', onKey)
|
|
781
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
782
|
+
})
|
|
783
|
+
// Close both menus on route change.
|
|
784
|
+
$effect(() => { void $page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
|
|
785
|
+
</script>
|
|
786
|
+
${themeHead}
|
|
787
|
+
|
|
788
|
+
${body}
|
|
789
|
+
|
|
790
|
+
<style>
|
|
791
|
+
${styles}
|
|
792
|
+
.sv-app__brand { display: inline-flex; align-items: center; font-weight: 700; font-size: 15px; color: var(--sg-fg, #0f172a); text-decoration: none; padding: 4px 8px; }
|
|
793
|
+
.sv-app__logo { max-height: 34px; max-width: 160px; width: auto; object-fit: contain; display: block; }
|
|
794
|
+
.sv-app__link { padding: 7px 10px; border-radius: 8px; color: var(--sg-fg, #334155); text-decoration: none; font-size: 14px; }
|
|
795
|
+
.sv-app__link:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); }
|
|
796
|
+
.sv-app__link.is-active { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 14%, transparent); color: var(--sg-accent, #4f46e5); font-weight: 600; }
|
|
797
|
+
.sv-app__main { display: flex; flex-direction: column; min-width: 0; }
|
|
798
|
+
.sv-app__content { padding: 24px 28px; min-width: 0; }
|
|
799
|
+
/* App-chrome toolbar: a sticky product header with quick-search + account cluster. */
|
|
800
|
+
.sv-app__toolbar { position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 16px; padding: 10px 28px; background: color-mix(in srgb, var(--sg-header-bg, #f8fafc) 86%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 10%, var(--sg-border, #e6e8ec)); }
|
|
801
|
+
.sv-app__search { position: relative; flex: 1; max-width: 440px; display: flex; align-items: center; }
|
|
802
|
+
.sv-app__search-ic { position: absolute; left: 11px; color: var(--sg-muted, #94a3b8); pointer-events: none; }
|
|
803
|
+
.sv-app__search-in { width: 100%; font: inherit; font-size: 13.5px; padding: 7px 46px 7px 34px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
804
|
+
.sv-app__search-in::-webkit-search-cancel-button { -webkit-appearance: none; }
|
|
805
|
+
.sv-app__search-in:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
|
|
806
|
+
.sv-app__results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; display: flex; flex-direction: column; padding: 5px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.25); }
|
|
807
|
+
.sv-app__result { padding: 7px 10px; border-radius: 7px; font-size: 13.5px; color: var(--sg-fg, #334155); text-decoration: none; }
|
|
808
|
+
.sv-app__result:hover { background: color-mix(in srgb, var(--sg-accent, #6366f1) 12%, transparent); color: var(--sg-accent, #6366f1); }
|
|
809
|
+
.sv-app__tools { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
810
|
+
.sv-app__tool { position: relative; display: inline-flex; padding: 7px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); cursor: pointer; }
|
|
811
|
+
.sv-app__tool:hover { color: var(--sg-fg, #0f172a); border-color: color-mix(in srgb, var(--sg-fg, #0f172a) 20%, var(--sg-border, #e6e8ec)); }
|
|
812
|
+
.sv-app__dot { position: absolute; top: 5px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: var(--sg-accent, #6366f1); box-shadow: 0 0 0 2px var(--sg-bg, #fff); }
|
|
813
|
+
.sv-app__avatar { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; font: inherit; font-size: 12px; font-weight: 700; color: #fff; background: var(--sg-accent, #6366f1); letter-spacing: 0.02em; cursor: pointer; }
|
|
814
|
+
.sv-app__kbd { position: absolute; right: 8px; font-size: 10.5px; font-weight: 600; color: var(--sg-muted, #94a3b8); background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 5px; padding: 1px 5px; pointer-events: none; }
|
|
815
|
+
.sv-app__pop { position: relative; }
|
|
816
|
+
.sv-app__scrim { position: fixed; inset: 0; z-index: 44; border: 0; padding: 0; background: transparent; cursor: default; }
|
|
817
|
+
.sv-app__menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 46; min-width: 232px; padding: 6px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 11px; box-shadow: 0 16px 40px -10px rgba(15, 23, 42, 0.3); }
|
|
818
|
+
.sv-app__menu-head { padding: 6px 10px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--sg-muted, #94a3b8); }
|
|
819
|
+
.sv-app__notif { display: flex; gap: 9px; padding: 8px 10px; border-radius: 8px; }
|
|
820
|
+
.sv-app__notif:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, transparent); }
|
|
821
|
+
.sv-app__notif-dot { flex: none; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; }
|
|
822
|
+
.sv-app__notif-body { display: flex; flex-direction: column; min-width: 0; }
|
|
823
|
+
.sv-app__notif-body strong { font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); }
|
|
824
|
+
.sv-app__notif-body span { font-size: 11.5px; color: var(--sg-muted, #94a3b8); }
|
|
825
|
+
.sv-app__menu-foot { width: 100%; margin-top: 4px; padding: 8px; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--sg-accent, #6366f1); background: none; border: none; border-top: 1px solid var(--sg-border, #eef0f3); cursor: pointer; }
|
|
826
|
+
.sv-app__acct-head { display: flex; align-items: center; gap: 10px; padding: 8px 10px 10px; border-bottom: 1px solid var(--sg-border, #eef0f3); margin-bottom: 4px; }
|
|
827
|
+
.sv-app__avatar--lg { width: 38px; height: 38px; font-size: 14px; }
|
|
828
|
+
.sv-app__acct-id { display: flex; flex-direction: column; min-width: 0; }
|
|
829
|
+
.sv-app__acct-id strong { font-size: 13.5px; color: var(--sg-fg, #0f172a); }
|
|
830
|
+
.sv-app__acct-id span { font-size: 12px; color: var(--sg-muted, #94a3b8); overflow: hidden; text-overflow: ellipsis; }
|
|
831
|
+
.sv-app__menu-item { display: block; width: 100%; text-align: left; padding: 8px 10px; font: inherit; font-size: 13px; color: var(--sg-fg, #334155); background: none; border: none; border-radius: 8px; text-decoration: none; cursor: pointer; }
|
|
832
|
+
.sv-app__menu-item:hover { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, transparent); color: var(--sg-accent, #6366f1); }
|
|
833
|
+
.sv-app__menu-item--danger:hover { background: color-mix(in srgb, #ef4444 12%, transparent); color: #ef4444; }
|
|
834
|
+
.sv-app__locale { margin-top: 10px; padding: 5px 8px; font: inherit; font-size: 12.5px; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; }
|
|
835
|
+
/* Mobile bar + drawer chrome (hidden on desktop; media queries above switch it on) */
|
|
836
|
+
.sv-app__mobilebar { display: none; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); background: var(--sg-header-bg, #f8fafc); position: sticky; top: 0; z-index: 50; }
|
|
837
|
+
.sv-app__burger { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
838
|
+
.sv-app__backdrop { position: fixed; inset: 0; z-index: 55; border: 0; padding: 0; background: rgba(15, 23, 42, 0.42); cursor: pointer; }
|
|
839
|
+
@media (max-width: 860px) { .sv-app__content { padding: 18px 16px; } .sv-app__toolbar { padding: 9px 16px; } }
|
|
840
|
+
@media (max-width: 640px) { .sv-app__content { padding: 14px 12px; } .sv-app__toolbar { padding: 8px 12px; gap: 10px; } .sv-app__tool { display: none; } }
|
|
841
|
+
</style>
|
|
842
|
+
`,
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function homeFile(nav: NavItem[]): GeneratedFile {
|
|
847
|
+
// Land on the app's primary screen (the first dashboard) instead of a generic
|
|
848
|
+
// welcome, so opening the app drops you straight into the real product. A themed
|
|
849
|
+
// splash + link covers the pre-hydration moment and the no-JS case.
|
|
850
|
+
const first = nav[0]?.href ?? '/'
|
|
851
|
+
if (nav.length === 0) {
|
|
852
|
+
return { path: 'src/routes/+page.svelte', description: 'Home page.', contents: `<h1 class="st__title">Your data app</h1>\n<p class="st__sub">Add a screen to get started.</p>\n` }
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
path: 'src/routes/+page.svelte',
|
|
856
|
+
description: 'Home: redirects to the primary dashboard.',
|
|
857
|
+
contents: `<script lang="ts">
|
|
858
|
+
import { onMount } from 'svelte'
|
|
859
|
+
import { goto } from '$app/navigation'
|
|
860
|
+
const home = ${JSON.stringify(first)}
|
|
861
|
+
onMount(() => { goto(home, { replaceState: true }) })
|
|
862
|
+
</script>
|
|
863
|
+
|
|
864
|
+
<div class="st-home">
|
|
865
|
+
<div class="st-home__spinner" aria-hidden="true"></div>
|
|
866
|
+
<p>Opening your workspace… <a href={home}>Continue</a></p>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<style>
|
|
870
|
+
.st-home { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; min-height: 52vh; color: var(--sg-muted, #64748b); font-size: 14px; }
|
|
871
|
+
.st-home__spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--sg-border, #e6e8ec); border-top-color: var(--sg-accent, #6366f1); animation: st-home-spin 0.7s linear infinite; }
|
|
872
|
+
.st-home a { color: var(--sg-accent, #6366f1); font-weight: 600; }
|
|
873
|
+
@keyframes st-home-spin { to { transform: rotate(360deg); } }
|
|
874
|
+
</style>
|
|
875
|
+
`,
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Prepare entities for emission: resolve relations, rewrite each schema so the
|
|
881
|
+
* grid shows a related label column instead of the raw FK, and build the seed.
|
|
882
|
+
* Shared by `emitStudioApp` and the Studio designer's `emitStudioProject`.
|
|
883
|
+
*/
|
|
884
|
+
export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[]; seed: Map<string, Array<Record<string, unknown>>> } {
|
|
885
|
+
for (const s of schemas) resolveIdField(s) // fail fast on an unresolvable key
|
|
886
|
+
const byName = new Map(schemas.map((s) => [s.name, s]))
|
|
887
|
+
const entries: Prepared[] = schemas.map((s) => {
|
|
888
|
+
const infos = relationInfos(s, byName)
|
|
889
|
+
return { schema: prepareSchema(s, infos), infos }
|
|
890
|
+
})
|
|
891
|
+
return { entries, seed: generateSeed(entries) }
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
|
|
895
|
+
export function emitEntityModules(
|
|
896
|
+
schemas: EntitySchema[],
|
|
897
|
+
opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean } = {},
|
|
898
|
+
): { files: GeneratedFile[]; prepared: EntitySchema[] } {
|
|
899
|
+
const { entries, seed } = prepareEntities(schemas)
|
|
900
|
+
const { file: data, needs } = dataModule(entries, seed, opts.sources)
|
|
901
|
+
const files: GeneratedFile[] = [schemasModule(entries), data]
|
|
902
|
+
if (needs.supabase) files.push(connectionsModule(needs))
|
|
903
|
+
for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled }))
|
|
904
|
+
return { files, prepared: entries.map((e) => e.schema) }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Emit every source file of a create-studio app for the given entities:
|
|
909
|
+
* `schemas.ts`, `data.ts`, one `+page.svelte` per entity, and the nav shell.
|
|
910
|
+
* The template supplies the rest (EntityScreen, theme, configs).
|
|
911
|
+
*/
|
|
912
|
+
export function emitStudioApp(schemas: EntitySchema[]): GeneratedFile[] {
|
|
913
|
+
if (schemas.length === 0) throw new Error('emitStudioApp: no schemas to emit')
|
|
914
|
+
const { files, prepared } = emitEntityModules(schemas)
|
|
915
|
+
const nav: NavItem[] = prepared.map((s) => {
|
|
916
|
+
const n = namesFor(s)
|
|
917
|
+
return { href: `/${n.route}`, label: n.label }
|
|
918
|
+
})
|
|
919
|
+
return [...files, ...prepared.map((s) => entityScreenPage(s)), layoutFile(nav), homeFile(nav)]
|
|
920
|
+
}
|