@svgrid/create-studio 0.1.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @svgrid/create-studio
2
+
3
+ Scaffold a runnable **SvGrid Studio** data app in one command.
4
+
5
+ ```bash
6
+ npm create @svgrid/studio@latest
7
+ # or into a named directory
8
+ npm create @svgrid/studio@latest my-data-app
9
+ # pnpm / yarn
10
+ pnpm create @svgrid/studio my-data-app
11
+ yarn create @svgrid/studio my-data-app
12
+ ```
13
+
14
+ Then:
15
+
16
+ ```bash
17
+ cd my-data-app
18
+ npm install
19
+ npm run dev
20
+ ```
21
+
22
+ ## From your own schema
23
+
24
+ Point it at an existing **Drizzle** `schema.ts` or **Prisma** `schema.prisma`
25
+ (auto-detected) and it scaffolds a screen for every table/model instead of the
26
+ seeded example - foreign keys become searchable lookups:
27
+
28
+ ```bash
29
+ npm create @svgrid/studio@latest my-app -- --from ./prisma/schema.prisma
30
+ npm create @svgrid/studio@latest my-app -- --from ./src/lib/db/schema.ts
31
+ ```
32
+
33
+ ## From a designer project
34
+
35
+ Export a `studio.config.json` from the [visual app designer](https://www.svgrid.com/docs/enterprise/studio/app-designer)
36
+ and generate the exact screens + blocks you arranged:
37
+
38
+ ```bash
39
+ npm create @svgrid/studio@latest my-app -- --project ./studio.config.json
40
+ ```
41
+
42
+ ## What you get
43
+
44
+ A full [SvelteKit](https://svelte.dev/docs/kit) app - not a snippet - wired end to end:
45
+
46
+ - **Nav shell + modern theme** (plain CSS, light/dark aware, no Tailwind).
47
+ - **Two linked entities** - Customers and Orders - where an order references a
48
+ customer through a **searchable lookup** field.
49
+ - **Grid + modal CRUD** on every screen: sort, filter, global search, paging,
50
+ multi-select delete, and a draggable / resizable / pinnable edit modal - all
51
+ driven by a single `EntitySchema` per entity.
52
+ - **Seeded in-memory data**, so it runs with **no backend to set up**.
53
+
54
+ Everything is schema-driven. Open [`src/lib/schemas.ts`](templates/default/src/lib/schemas.ts),
55
+ add a field, and it appears in both the grid and the form.
56
+
57
+ ## Going to a real database
58
+
59
+ The starter's data sources are the only thing tied to in-memory storage. Swap
60
+ `createInMemoryDataSource` in `src/lib/data.ts` for a real adapter - or generate
61
+ a connected entity from an existing table:
62
+
63
+ ```bash
64
+ npx @svgrid/studio add public.invoices --db postgres --url "$DATABASE_URL"
65
+ ```
66
+
67
+ The grid, form, sorting, filtering, and paging keep working unchanged.
68
+
69
+ ## Requirements
70
+
71
+ - Node.js >= 18
72
+
73
+ ## Links
74
+
75
+ - Docs: https://www.svgrid.com/docs/studio
76
+ - Grid: [`@svgrid/grid`](https://www.npmjs.com/package/@svgrid/grid)
77
+ - Studio / enterprise: [`@svgrid/enterprise`](https://www.npmjs.com/package/@svgrid/enterprise)
package/index.mjs ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ // @svgrid/create-studio - scaffold a runnable SvGrid Studio data app.
3
+ //
4
+ // npm create @svgrid/studio@latest # interactive
5
+ // pnpm create @svgrid/studio my-app # into ./my-app
6
+ // npm create @svgrid/studio@latest my-app -- --force
7
+ //
8
+ // One command -> a full SvelteKit app: nav shell, two linked entities
9
+ // (Customers + Orders with a searchable lookup), grid + modal CRUD, and a
10
+ // modern theme, running on seeded in-memory data with no backend to set up.
11
+ //
12
+ // Zero runtime dependencies - Node built-ins only. Copies the bundled template,
13
+ // renames `_`-prefixed dotfiles, and rewrites the project name.
14
+
15
+ import { cp, mkdir, readdir, rename, rm, stat, readFile, writeFile } from 'node:fs/promises'
16
+ import { existsSync } from 'node:fs'
17
+ import { dirname, join, basename, resolve, isAbsolute } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { createInterface } from 'node:readline/promises'
20
+ import { stdin, stdout } from 'node:process'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const TEMPLATE_DIR = join(__dirname, 'templates', 'default')
24
+
25
+ const RENAME_BACK = new Map([
26
+ ['_gitignore', '.gitignore'],
27
+ ['_npmrc', '.npmrc'],
28
+ ['_env.example', '.env.example'],
29
+ ['_package.json', 'package.json'],
30
+ ])
31
+
32
+ const c = {
33
+ reset: '\x1b[0m',
34
+ bold: '\x1b[1m',
35
+ dim: '\x1b[2m',
36
+ green: '\x1b[32m',
37
+ cyan: '\x1b[36m',
38
+ red: '\x1b[31m',
39
+ yellow: '\x1b[33m',
40
+ }
41
+ const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
42
+
43
+ function parseArgs(argv) {
44
+ const args = { _: [], force: false, help: false, from: null, project: null }
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i]
47
+ if (a === '--help' || a === '-h') args.help = true
48
+ else if (a === '--force' || a === '-f') args.force = true
49
+ else if (a === '--from') args.from = argv[++i]
50
+ else if (a.startsWith('--from=')) args.from = a.slice('--from='.length)
51
+ else if (a === '--project') args.project = argv[++i]
52
+ else if (a.startsWith('--project=')) args.project = a.slice('--project='.length)
53
+ else if (!a.startsWith('-')) args._.push(a)
54
+ }
55
+ return args
56
+ }
57
+
58
+ function printHelp() {
59
+ stdout.write(`
60
+ ${color('bold', '@svgrid/create-studio')} - scaffold a SvGrid Studio data app
61
+
62
+ ${color('bold', 'Usage')}
63
+ npm create @svgrid/studio@latest [dir] -- [--from <schema>] [--force]
64
+
65
+ ${color('bold', 'What you get')}
66
+ A runnable SvelteKit app: nav shell, grid + modal CRUD per entity, and a
67
+ modern theme. Without --from you get seeded Customers + Orders; with --from
68
+ you get a screen for every table/model in your schema.
69
+
70
+ ${color('bold', 'Options')}
71
+ --from <path> Generate the app from a Drizzle (.ts) or Prisma (.prisma)
72
+ schema instead of the seeded example (auto-detected).
73
+ --project <path> Generate the app from a studio.config.json exported by the
74
+ visual designer (screens + blocks, not just entities).
75
+ --force Scaffold into a non-empty directory.
76
+
77
+ ${color('bold', 'Examples')}
78
+ npm create @svgrid/studio@latest
79
+ pnpm create @svgrid/studio my-data-app
80
+ npm create @svgrid/studio@latest my-app -- --from ./prisma/schema.prisma
81
+ npm create @svgrid/studio@latest my-app -- --project ./studio.config.json
82
+ `)
83
+ }
84
+
85
+ /**
86
+ * Generate the app from a studio.config.json (the visual designer's export):
87
+ * parse it, emit each screen's composed blocks, and replace the seeded example.
88
+ */
89
+ async function applyProject(destDir, projectPath) {
90
+ const abs = resolve(process.cwd(), projectPath)
91
+ const json = await readFile(abs, 'utf8').catch(() => null)
92
+ if (json == null) throw new Error(`--project: config not found: ${projectPath}`)
93
+
94
+ let studio
95
+ try {
96
+ studio = await import('@svgrid/enterprise/studio')
97
+ } catch {
98
+ throw new Error('--project needs @svgrid/enterprise; reinstall and try again.')
99
+ }
100
+
101
+ const project = studio.parseProject(json)
102
+ const files = studio.emitStudioProject(project)
103
+
104
+ for (const ex of ['customers', 'orders']) {
105
+ await rm(join(destDir, 'src', 'routes', ex), { recursive: true, force: true })
106
+ }
107
+ for (const f of files) {
108
+ const full = join(destDir, f.path)
109
+ await mkdir(dirname(full), { recursive: true })
110
+ await writeFile(full, f.contents)
111
+ }
112
+ // Add the runtime deps the generated code imports (Supabase client / SQL
113
+ // drivers) so the app installs + runs turnkey.
114
+ const allSource = files.map((f) => f.contents).join('\n')
115
+ const DEPS = [
116
+ ["from '@supabase/supabase-js'", '@supabase/supabase-js', '^2.45.0'],
117
+ ["import pg from 'pg'", 'pg', '^8.11.0'],
118
+ ["from 'mysql2/promise'", 'mysql2', '^3.9.0'],
119
+ ["import mssql from 'mssql'", 'mssql', '^10.0.0'],
120
+ ["import Database from 'better-sqlite3'", 'better-sqlite3', '^11.0.0'],
121
+ ]
122
+ for (const [needle, dep, version] of DEPS) {
123
+ if (allSource.includes(needle)) await addDependency(destDir, dep, version)
124
+ }
125
+ return project.screens.map((s) => s.title)
126
+ }
127
+
128
+ /** Add a runtime dependency to the generated app's package.json (idempotent). */
129
+ async function addDependency(destDir, name, version) {
130
+ const pkgPath = join(destDir, 'package.json')
131
+ if (!existsSync(pkgPath)) return
132
+ try {
133
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
134
+ pkg.dependencies = { ...pkg.dependencies, [name]: version }
135
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
136
+ } catch {
137
+ // leave deps as-is if package.json isn't valid JSON
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Generate the app's entities from a Drizzle/Prisma schema file: parse it with
143
+ * the Studio core, emit EntityScreen-style files, and replace the seeded
144
+ * Customers/Orders. Runs after the base template is copied.
145
+ */
146
+ async function applyFromSchema(destDir, fromPath) {
147
+ const abs = resolve(process.cwd(), fromPath)
148
+ const src = await readFile(abs, 'utf8').catch(() => null)
149
+ if (src == null) throw new Error(`--from: schema file not found: ${fromPath}`)
150
+
151
+ let studio
152
+ try {
153
+ studio = await import('@svgrid/enterprise/studio')
154
+ } catch {
155
+ throw new Error('--from needs @svgrid/enterprise; reinstall, or scaffold without --from and run `npx @svgrid/studio add --all --from ...` in the app.')
156
+ }
157
+
158
+ const isPrisma = /\.prisma$/i.test(fromPath) || /\bmodel\s+\w+\s*\{/.test(src)
159
+ const schemas = isPrisma ? studio.introspectPrismaAll(src) : studio.introspectDrizzleAll(src)
160
+ if (!schemas.length) throw new Error(`--from: no ${isPrisma ? 'models' : 'tables'} found in ${fromPath}`)
161
+
162
+ const files = studio.emitStudioApp(schemas)
163
+
164
+ // Drop the seeded example screens (their schemas/data no longer exist).
165
+ for (const ex of ['customers', 'orders']) {
166
+ await rm(join(destDir, 'src', 'routes', ex), { recursive: true, force: true })
167
+ }
168
+ // Write generated files (overwrites schemas.ts / data.ts / +layout / +page).
169
+ for (const f of files) {
170
+ const full = join(destDir, f.path)
171
+ await mkdir(dirname(full), { recursive: true })
172
+ await writeFile(full, f.contents)
173
+ }
174
+ return schemas.map((s) => s.label ?? s.name)
175
+ }
176
+
177
+ function sanitizeName(name) {
178
+ return (
179
+ name
180
+ .trim()
181
+ .toLowerCase()
182
+ .replace(/[^a-z0-9._-]+/g, '-')
183
+ .replace(/^[-_.]+|[-_.]+$/g, '') || 'my-studio-app'
184
+ )
185
+ }
186
+
187
+ async function copyTemplate(srcDir, destDir) {
188
+ // Match only on the path RELATIVE to the template root - the absolute `src`
189
+ // includes the install location, which would match `node_modules` and skip
190
+ // the whole copy.
191
+ const skip = /(^|[\\/])(node_modules|\.svelte-kit|\.vercel|build|dist)([\\/]|$)/
192
+ await cp(srcDir, destDir, {
193
+ recursive: true,
194
+ filter: (src) => !skip.test(src.slice(srcDir.length)),
195
+ })
196
+ await renameBack(destDir)
197
+ }
198
+
199
+ async function renameBack(dir) {
200
+ for (const entry of await readdir(dir)) {
201
+ const full = join(dir, entry)
202
+ if ((await stat(full)).isDirectory()) {
203
+ await renameBack(full)
204
+ } else if (RENAME_BACK.has(entry)) {
205
+ await rename(full, join(dir, RENAME_BACK.get(entry)))
206
+ }
207
+ }
208
+ }
209
+
210
+ async function setProjectName(destDir, name) {
211
+ const pkgPath = join(destDir, 'package.json')
212
+ if (!existsSync(pkgPath)) return
213
+ try {
214
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
215
+ pkg.name = name
216
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
217
+ } catch {
218
+ // leave the template's name if it isn't valid JSON for some reason
219
+ }
220
+ }
221
+
222
+ async function isEmptyDir(dir) {
223
+ if (!existsSync(dir)) return true
224
+ const entries = await readdir(dir)
225
+ return entries.filter((e) => e !== '.git').length === 0
226
+ }
227
+
228
+ async function main() {
229
+ const args = parseArgs(process.argv.slice(2))
230
+ if (args.help) return printHelp()
231
+
232
+ const interactive = stdin.isTTY && stdout.isTTY
233
+ let rl = null
234
+ const ask = async (q, def) => {
235
+ if (!interactive) return def
236
+ rl ??= createInterface({ input: stdin, output: stdout })
237
+ const a = (await rl.question(`${q} ${color('dim', `(${def})`)} `)).trim()
238
+ return a || def
239
+ }
240
+
241
+ stdout.write(`\n${color('bold', '◆ @svgrid/create-studio')} ${color('dim', 'a runnable data app')}\n\n`)
242
+
243
+ // 1. Target directory.
244
+ let target = args._[0]
245
+ if (!target) target = await ask('Project directory:', 'my-studio-app')
246
+ const destDir = resolve(process.cwd(), target)
247
+ const projectName = sanitizeName(basename(destDir))
248
+
249
+ // 2. Safety: don't clobber a non-empty directory.
250
+ if (!(await isEmptyDir(destDir)) && !args.force) {
251
+ const ok = await ask(
252
+ `\n${color('yellow', '!')} ${target} is not empty. Continue and overwrite files? (y/N)`,
253
+ 'N',
254
+ )
255
+ if (!/^y(es)?$/i.test(ok)) {
256
+ if (rl) rl.close()
257
+ stdout.write(`${color('red', '✖')} Aborted.\n`)
258
+ process.exit(1)
259
+ }
260
+ }
261
+
262
+ if (!existsSync(TEMPLATE_DIR)) {
263
+ if (rl) rl.close()
264
+ stdout.write(`\n${color('red', '✖')} Template is missing from this build.\n`)
265
+ process.exit(1)
266
+ }
267
+
268
+ // 3. Scaffold.
269
+ await mkdir(destDir, { recursive: true })
270
+ await copyTemplate(TEMPLATE_DIR, destDir)
271
+ await setProjectName(destDir, projectName)
272
+ if (rl) rl.close()
273
+
274
+ // 3b. Generate from a designer project (--project) or a schema file (--from).
275
+ let entities = null
276
+ let source = null
277
+ try {
278
+ if (args.project) {
279
+ entities = await applyProject(destDir, args.project)
280
+ source = args.project
281
+ } else if (args.from) {
282
+ entities = await applyFromSchema(destDir, args.from)
283
+ source = args.from
284
+ }
285
+ } catch (err) {
286
+ stdout.write(`\n${color('red', '✖')} ${err instanceof Error ? err.message : String(err)}\n`)
287
+ process.exit(1)
288
+ }
289
+
290
+ // 4. Next steps.
291
+ const rel = isAbsolute(target) || target.startsWith('.') ? target : `./${target}`
292
+ stdout.write(`\n${color('green', '✔')} Scaffolded ${color('bold', projectName)} into ${rel}\n`)
293
+ if (entities) {
294
+ stdout.write(` ${color('dim', 'from')} ${source} ${color('dim', '->')} ${entities.join(', ')}\n`)
295
+ }
296
+ stdout.write(`\n`)
297
+ stdout.write(`${color('bold', 'Next steps')}\n`)
298
+ stdout.write(` cd ${target}\n`)
299
+ stdout.write(` npm install\n`)
300
+ stdout.write(` npm run dev\n\n`)
301
+ stdout.write(`${color('dim', 'Docs:')} https://www.svgrid.com/docs/studio\n\n`)
302
+ }
303
+
304
+ main().catch((err) => {
305
+ console.error(err)
306
+ process.exit(1)
307
+ })
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@svgrid/create-studio",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a runnable SvGrid Studio data app in one command: npm create @svgrid/studio@latest",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "jQWidgets Ltd",
8
+ "homepage": "https://www.svgrid.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/sv-grid/sv-grid.git",
12
+ "directory": "packages/create-studio"
13
+ },
14
+ "keywords": [
15
+ "svelte",
16
+ "sveltekit",
17
+ "data-grid",
18
+ "datagrid",
19
+ "data-app",
20
+ "crud",
21
+ "svgrid",
22
+ "studio",
23
+ "create",
24
+ "scaffold",
25
+ "starter",
26
+ "template"
27
+ ],
28
+ "bin": "index.mjs",
29
+ "files": [
30
+ "index.mjs",
31
+ "templates"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dependencies": {
37
+ "@svgrid/enterprise": "workspace:*"
38
+ }
39
+ }
@@ -0,0 +1,51 @@
1
+ # My Studio App
2
+
3
+ A data app built with [SvGrid Studio](https://www.svgrid.com/docs/studio).
4
+
5
+ ```bash
6
+ npm install
7
+ npm run dev # http://localhost:5173
8
+ ```
9
+
10
+ ## How it works
11
+
12
+ Each screen is driven by one `EntitySchema`. A schema describes an entity's
13
+ fields once, and that single definition drives:
14
+
15
+ - the **grid** columns, sorting, filtering, and paging,
16
+ - the **edit form** (controls + validation), and
17
+ - the generated code when you scaffold from a real database.
18
+
19
+ ### Where things live
20
+
21
+ | File | What it does |
22
+ | --- | --- |
23
+ | `src/lib/schemas.ts` | The entity definitions. Add a field here and it shows up in the grid and the form. |
24
+ | `src/lib/data.ts` | The data sources. Seeded in-memory by default; swap for a real adapter when ready. |
25
+ | `src/lib/EntityScreen.svelte` | The reusable grid + modal CRUD screen. |
26
+ | `src/routes/` | One route per entity, plus the nav shell (`+layout.svelte`) and home page. |
27
+
28
+ ### Add a field
29
+
30
+ Open `src/lib/schemas.ts`, add an entry to a schema's `fields` array (and the
31
+ matching property on its TypeScript type), and it appears in both the grid and
32
+ the edit form on the next reload.
33
+
34
+ ### Connect a real database
35
+
36
+ The in-memory sources in `src/lib/data.ts` are the only thing tied to fake data.
37
+ Replace `createInMemoryDataSource` with `createSqlDataSource` or
38
+ `createSupabaseDataSource`, or generate a connected entity:
39
+
40
+ ```bash
41
+ npx @svgrid/studio add public.invoices --db postgres --url "$DATABASE_URL"
42
+ ```
43
+
44
+ Sorting, filtering, paging, and CRUD keep working unchanged.
45
+
46
+ ## Scripts
47
+
48
+ - `npm run dev` - start the dev server
49
+ - `npm run build` - production build
50
+ - `npm run preview` - preview the production build
51
+ - `npm run check` - type-check with `svelte-check`
@@ -0,0 +1,22 @@
1
+ node_modules
2
+
3
+ # Output
4
+ .output
5
+ .vercel
6
+ .netlify
7
+ .wrangler
8
+ /.svelte-kit
9
+ /build
10
+
11
+ # OS / editor
12
+ .DS_Store
13
+ Thumbs.db
14
+
15
+ # Env
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+
20
+ # Vite
21
+ vite.config.js.timestamp-*
22
+ vite.config.ts.timestamp-*
@@ -0,0 +1 @@
1
+ engine-strict=true
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "my-studio-app",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
11
+ },
12
+ "devDependencies": {
13
+ "@sveltejs/adapter-auto": "^6.0.0",
14
+ "@sveltejs/kit": "^2.15.0",
15
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
16
+ "svelte": "^5.55.5",
17
+ "svelte-check": "^4.4.6",
18
+ "typescript": "^5.7.0",
19
+ "vite": "^8.0.10"
20
+ },
21
+ "dependencies": {
22
+ "@svgrid/grid": "latest",
23
+ "@svgrid/enterprise": "latest"
24
+ }
25
+ }
@@ -0,0 +1,65 @@
1
+ /* App theme + chrome. The grid ships its own styles; these tokens drive the
2
+ * accent + surfaces (light / dark friendly), the nav shell, and the buttons. */
3
+ :root {
4
+ --sg-accent: #4f46e5;
5
+ color-scheme: light dark;
6
+ }
7
+
8
+ * { box-sizing: border-box; }
9
+ html, body { margin: 0; height: 100%; }
10
+ body {
11
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
12
+ color: var(--sg-fg, #0f172a);
13
+ background: var(--sg-bg, #ffffff);
14
+ }
15
+
16
+ /* ---- App shell ---- */
17
+ .app { display: grid; grid-template-columns: 232px 1fr; min-height: 100vh; }
18
+ .app__nav {
19
+ display: flex; flex-direction: column; gap: 4px; padding: 18px 12px;
20
+ border-right: 1px solid var(--sg-border, #e6e8ec); background: var(--sg-header-bg, #f8fafc);
21
+ }
22
+ .app__brand { padding: 6px 10px 14px; font-weight: 750; font-size: 16px; color: inherit; text-decoration: none; letter-spacing: -0.01em; }
23
+ .app__links { display: flex; flex-direction: column; gap: 2px; }
24
+ .app__link { padding: 8px 11px; border-radius: 8px; color: var(--sg-muted, #475569); text-decoration: none; font-size: 14px; font-weight: 500; }
25
+ .app__link:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); color: var(--sg-fg, #0f172a); }
26
+ .app__link.is-active { background: color-mix(in srgb, var(--sg-accent) 14%, transparent); color: var(--sg-accent); font-weight: 600; }
27
+ .app__foot { margin-top: auto; padding: 10px; font-size: 11px; color: var(--sg-muted, #94a3b8); }
28
+ .app__main { padding: 24px 28px; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
29
+
30
+ /* ---- Headings ---- */
31
+ .st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
32
+ .st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
33
+ .st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
34
+
35
+ /* ---- Toolbar + buttons ---- */
36
+ .st__toolbar { display: flex; align-items: center; gap: 10px; }
37
+ .st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
38
+ .st-btn {
39
+ display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
40
+ font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1;
41
+ border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px;
42
+ background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer;
43
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
44
+ transition: background 0.15s, border-color 0.15s, filter 0.15s, transform 0.06s;
45
+ }
46
+ .st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
47
+ .st-btn:active { transform: translateY(0.5px); }
48
+ .st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
49
+ .st-btn--primary {
50
+ border-color: transparent; color: #fff;
51
+ background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent));
52
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent);
53
+ }
54
+ .st-btn--primary:hover { filter: brightness(1.06); }
55
+
56
+ /* ---- Home cards ---- */
57
+ .home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
58
+ .home__card {
59
+ display: flex; flex-direction: column; gap: 6px; padding: 18px 18px 20px;
60
+ border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit;
61
+ background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); transition: border-color 0.15s, box-shadow 0.15s;
62
+ }
63
+ .home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); box-shadow: 0 8px 24px -14px rgba(15, 23, 42, 0.3); }
64
+ .home__card strong { font-size: 15px; }
65
+ .home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
@@ -0,0 +1,12 @@
1
+ // See https://svelte.dev/docs/kit/types#app.d.ts
2
+ declare global {
3
+ namespace App {
4
+ // interface Error {}
5
+ // interface Locals {}
6
+ // interface PageData {}
7
+ // interface PageState {}
8
+ // interface Platform {}
9
+ }
10
+ }
11
+
12
+ export {}
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ %sveltekit.head%
8
+ </head>
9
+ <body data-sveltekit-preload-data="hover">
10
+ <div style="display: contents">%sveltekit.body%</div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,95 @@
1
+ <script lang="ts" module>
2
+ type Row = Record<string, unknown>
3
+ </script>
4
+
5
+ <script lang="ts" generics="T extends Row">
6
+ /**
7
+ * A full CRUD screen for one entity: grid + create/edit modal + multi-select
8
+ * delete, over a ServerDataSource. Driven entirely by the EntitySchema.
9
+ */
10
+ import { SvGrid, createServerDataSource, type ServerState, type ServerDataSource } from '@svgrid/grid'
11
+ import { SvGridEditPanel, schemaToColumns, type EntitySchema, type RelationLookup } from '@svgrid/enterprise'
12
+
13
+ let { schema, source, lookups, newId }: {
14
+ schema: EntitySchema<T>
15
+ source: ServerDataSource<T>
16
+ lookups?: Record<string, RelationLookup>
17
+ newId: () => string
18
+ } = $props()
19
+
20
+ const idField = schema.idField ?? 'id'
21
+ const columns = schemaToColumns(schema)
22
+
23
+ let view = $state<ServerState<T>>({
24
+ rows: [], total: 0, loading: false, saving: false, error: null,
25
+ pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},
26
+ })
27
+ const controller = createServerDataSource<T>(source, {
28
+ pageSize: 10, optimistic: true, getRowId: (r) => String(r[idField]), onChange: (s) => (view = s),
29
+ })
30
+ $effect(() => {
31
+ controller.refresh()
32
+ return () => controller.dispose()
33
+ })
34
+
35
+ let editing = $state<T | null | undefined>(undefined)
36
+ let selected = $state<T[]>([])
37
+
38
+ async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<T> }) {
39
+ if (mode === 'create') {
40
+ await controller.createRow({ [idField]: newId(), ...values } as Partial<T>)
41
+ controller.setPage(view.pageCount - 1)
42
+ } else if (id) {
43
+ await controller.updateRow(id, values)
44
+ }
45
+ editing = undefined
46
+ }
47
+ async function removeSelected() {
48
+ for (const row of selected) await controller.deleteRow(String(row[idField]))
49
+ selected = []
50
+ }
51
+ </script>
52
+
53
+ <div class="st__toolbar">
54
+ <button class="st-btn st-btn--primary" onclick={() => (editing = null)}>+ New {schema.label ?? schema.name}</button>
55
+ <button class="st-btn" disabled={selected.length === 0} onclick={removeSelected}>
56
+ Delete{selected.length ? ` (${selected.length})` : ''}
57
+ </button>
58
+ <span class="st-hint">Double-click a row to edit</span>
59
+ </div>
60
+
61
+ <SvGrid
62
+ data={view.rows}
63
+ {columns}
64
+ loading={view.loading}
65
+ fitColumns
66
+ enableRowSummaries={false}
67
+ showRowSelection
68
+ sortable
69
+ externalSort
70
+ onSortingChange={(s) => controller.setSort(s)}
71
+ filterable
72
+ filterMode="row"
73
+ showGlobalFilter
74
+ externalFilter
75
+ onFiltersChange={(f) =>
76
+ controller.setFilter({
77
+ global: f.global || undefined,
78
+ columns: Object.fromEntries(
79
+ f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }]),
80
+ ),
81
+ })}
82
+ onRowDoubleClick={(e) => (editing = e.row)}
83
+ onRowSelectionChange={(_sel, rows) => (selected = rows)}
84
+ showPagination
85
+ externalPagination
86
+ rowCount={view.total}
87
+ pageIndex={view.pageIndex}
88
+ pageSize={view.pageSize}
89
+ onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
90
+ containerHeight={460}
91
+ />
92
+
93
+ {#if editing !== undefined}
94
+ <SvGridEditPanel {schema} row={editing} presentation="modal" persistKey={schema.name} {lookups} onSubmit={save} onCancel={() => (editing = undefined)} />
95
+ {/if}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Data sources. This starter runs on seeded in-memory sources, so it works with
3
+ * no backend. To point an entity at a real database, swap `createInMemoryDataSource`
4
+ * for `createSqlDataSource` / `createSupabaseDataSource` (or scaffold a connected
5
+ * one with `npx @svgrid/studio add <table> --db postgres --url ...`) - the grid,
6
+ * form, sorting, filtering, and paging keep working unchanged.
7
+ */
8
+ import {
9
+ createInMemoryDataSource,
10
+ createRelationLookup,
11
+ type ServerDataSource,
12
+ } from '@svgrid/enterprise'
13
+ import { customerSchema, orderSchema, type Customer, type Order } from './schemas'
14
+
15
+ const customerSeed: Customer[] = [
16
+ { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },
17
+ { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },
18
+ { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },
19
+ { id: 'c4', name: 'Linus Torvalds', email: 'linus@kernel.org', tier: 'pro', mrr: 360, active: true },
20
+ { id: 'c5', name: 'Barbara Liskov', email: 'barbara@substitution.dev', tier: 'free', mrr: 0, active: false },
21
+ ]
22
+
23
+ const orderSeed: Order[] = [
24
+ { id: 'o1', ref: 'INV-1001', customerId: 'c1', amount: 1200, status: 'paid' },
25
+ { id: 'o2', ref: 'INV-1002', customerId: 'c2', amount: 240, status: 'paid' },
26
+ { id: 'o3', ref: 'INV-1003', customerId: 'c1', amount: 300, status: 'draft' },
27
+ { id: 'o4', ref: 'INV-1004', customerId: 'c3', amount: 980, status: 'refunded' },
28
+ ]
29
+
30
+ export const customersSource = createInMemoryDataSource(customerSeed, customerSchema)
31
+
32
+ // Orders reference a customer. Enrich every method's rows with the customer
33
+ // NAME so the grid shows it (the form stores the id via the lookup below).
34
+ const nameById = () => new Map((customersSource.rows() as Customer[]).map((c) => [c.id, c.name]))
35
+ const withCustomer = (r: Order): Order => ({ ...r, customer: nameById().get(r.customerId) ?? '' })
36
+ const rawOrders = createInMemoryDataSource(orderSeed, orderSchema)
37
+ export const ordersSource: ServerDataSource<Order> = {
38
+ ...rawOrders,
39
+ async getRows(req) {
40
+ const res = await rawOrders.getRows(req)
41
+ return { ...res, rows: res.rows.map(withCustomer) }
42
+ },
43
+ createRow: (input) => rawOrders.createRow(input).then(withCustomer),
44
+ updateRow: (id, patch) => rawOrders.updateRow(id, patch).then(withCustomer),
45
+ }
46
+
47
+ // A searchable Customer picker for the order form.
48
+ export const customerLookup = createRelationLookup<Customer>({
49
+ source: customersSource,
50
+ schema: customerSchema,
51
+ labelField: 'name',
52
+ })
53
+
54
+ let seq = customerSeed.length + orderSeed.length
55
+ export const nextId = (prefix: string) => `${prefix}${++seq}`
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One EntitySchema per entity drives everything: the grid columns, the edit
3
+ * form (with validation), and - when you scaffold from a real database - the
4
+ * generated code. Add a field here and it shows up in the grid and the form.
5
+ */
6
+ import type { EntitySchema } from '@svgrid/enterprise'
7
+
8
+ export type Customer = {
9
+ id: string
10
+ name: string
11
+ email: string
12
+ tier: string
13
+ mrr: number
14
+ active: boolean
15
+ }
16
+
17
+ export type Order = {
18
+ id: string
19
+ ref: string
20
+ customerId: string
21
+ customer?: string // resolved customer name, for the grid (see data.ts)
22
+ amount: number
23
+ status: string
24
+ }
25
+
26
+ export const customerSchema: EntitySchema<Customer> = {
27
+ name: 'customers',
28
+ label: 'Customer',
29
+ idField: 'id',
30
+ fields: [
31
+ { field: 'id', type: 'text', primaryKey: true, readonly: true, hidden: { form: true } },
32
+ { field: 'name', type: 'text', required: true, minLength: 2 },
33
+ { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },
34
+ { field: 'tier', type: 'enum', options: [
35
+ { value: 'free', label: 'Free' },
36
+ { value: 'pro', label: 'Pro' },
37
+ { value: 'enterprise', label: 'Enterprise' },
38
+ ] },
39
+ { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },
40
+ { field: 'active', type: 'boolean' },
41
+ ],
42
+ }
43
+
44
+ export const orderSchema: EntitySchema<Order> = {
45
+ name: 'orders',
46
+ label: 'Order',
47
+ idField: 'id',
48
+ fields: [
49
+ { field: 'id', type: 'text', primaryKey: true, readonly: true, hidden: { form: true } },
50
+ { field: 'ref', type: 'text', label: 'Reference', required: true },
51
+ // A foreign key: a searchable Customer picker in the form, the name in the grid.
52
+ { field: 'customerId', type: 'relation', label: 'Customer', relation: { entity: 'customers', labelField: 'name' }, hidden: { grid: true } },
53
+ { field: 'customer', type: 'text', label: 'Customer', readonly: true, hidden: { form: true } },
54
+ { field: 'amount', type: 'number', label: 'Amount ($)', min: 0 },
55
+ { field: 'status', type: 'enum', options: [
56
+ { value: 'draft', label: 'Draft' },
57
+ { value: 'paid', label: 'Paid' },
58
+ { value: 'refunded', label: 'Refunded' },
59
+ ] },
60
+ ],
61
+ }
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import '../app.css'
3
+ import { page } from '$app/stores'
4
+
5
+ let { children } = $props()
6
+
7
+ const nav = [
8
+ { href: '/', label: 'Home' },
9
+ { href: '/customers', label: 'Customers' },
10
+ { href: '/orders', label: 'Orders' },
11
+ ]
12
+ </script>
13
+
14
+ <div class="app">
15
+ <aside class="app__nav">
16
+ <a class="app__brand" href="/">My Studio App</a>
17
+ <nav class="app__links">
18
+ {#each nav as item (item.href)}
19
+ <a class="app__link" class:is-active={$page.url.pathname === item.href} href={item.href}>{item.label}</a>
20
+ {/each}
21
+ </nav>
22
+ <span class="app__foot">Built with SvGrid Studio</span>
23
+ </aside>
24
+ <main class="app__main">
25
+ {@render children()}
26
+ </main>
27
+ </div>
@@ -0,0 +1,5 @@
1
+ // This starter runs on in-memory data, so render it as a client SPA (the seeded
2
+ // sources are module singletons that persist as you navigate). When you move an
3
+ // entity to a real database, you can drop this and use SSR / server routes.
4
+ export const ssr = false
5
+ export const prerender = false
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ const entities = [
3
+ { href: '/customers', label: 'Customers', desc: 'Tiers, MRR, and status. Sort, filter, edit inline in a modal.' },
4
+ { href: '/orders', label: 'Orders', desc: 'Linked to a customer via a searchable lookup field.' },
5
+ ]
6
+ </script>
7
+
8
+ <h1 class="st__title">Welcome to your data app</h1>
9
+ <p class="st__sub">
10
+ Built with SvGrid Studio. Each screen is driven by one <code>EntitySchema</code>.
11
+ It runs on seeded in-memory data - point an entity at a real database, or add one with
12
+ <code>npx @svgrid/studio add &lt;table&gt; --db postgres --url ...</code>
13
+ </p>
14
+
15
+ <div class="home">
16
+ {#each entities as e (e.href)}
17
+ <a class="home__card" href={e.href}>
18
+ <strong>{e.label}</strong>
19
+ <span>{e.desc}</span>
20
+ </a>
21
+ {/each}
22
+ </div>
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import EntityScreen from '$lib/EntityScreen.svelte'
3
+ import { customerSchema } from '$lib/schemas'
4
+ import { customersSource, nextId } from '$lib/data'
5
+ </script>
6
+
7
+ <h1 class="st__title">Customers</h1>
8
+ <EntityScreen schema={customerSchema} source={customersSource} newId={() => nextId('c')} />
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import EntityScreen from '$lib/EntityScreen.svelte'
3
+ import { orderSchema } from '$lib/schemas'
4
+ import { ordersSource, customerLookup, nextId } from '$lib/data'
5
+ </script>
6
+
7
+ <h1 class="st__title">Orders</h1>
8
+ <EntityScreen
9
+ schema={orderSchema}
10
+ source={ordersSource}
11
+ lookups={{ customerId: customerLookup }}
12
+ newId={() => nextId('o')}
13
+ />
@@ -0,0 +1,12 @@
1
+ import adapter from '@sveltejs/adapter-auto'
2
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
3
+
4
+ /** @type {import('@sveltejs/kit').Config} */
5
+ const config = {
6
+ preprocess: vitePreprocess(),
7
+ kit: {
8
+ adapter: adapter(),
9
+ },
10
+ }
11
+
12
+ export default config
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "moduleResolution": "bundler"
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ import { sveltekit } from '@sveltejs/vite-plugin-svelte'
2
+ import { defineConfig } from 'vite'
3
+
4
+ export default defineConfig({
5
+ plugins: [sveltekit()],
6
+ })